feat: parse FSSHTTP table of contents stores

This commit is contained in:
2026-07-22 19:56:31 +02:00
parent 3c3904f896
commit 622f1e94dd
2 changed files with 160 additions and 7 deletions

View File

@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import type { DesktopOneStore } from '../onestore/desktop-store.js';
import type { ExGuid, StoreObject } from '../onestore/types.js';
import { exGuidKey } from '../onestore/types.js';
import {
FSSHTTP_PACKAGING_FORMAT,
ONE_SECTION_FILE_TYPE,
ONE_TOC_FILE_TYPE,
} from './detect-format.js';
import { parseOneNoteTableOfContents } from './parse-toc.js';
const { parseFssHttpOneStoreMock } = vi.hoisted(() => ({
parseFssHttpOneStoreMock: vi.fn(),
}));
vi.mock('../onestore/fsshttp-store.js', () => ({
parseFssHttpOneStore: parseFssHttpOneStoreMock,
}));
const ROOT_ID: ExGuid = {
guid: '{6B46CF9E-D34C-401E-90B3-9F4C3DB606C4}',
value: 1,
};
const CONTEXT_ID: ExGuid = {
guid: '{00000000-0000-0000-0000-000000000000}',
value: 0,
};
describe('table-of-contents format dispatch', () => {
beforeEach(() => {
parseFssHttpOneStoreMock.mockReset();
});
it('accepts an FSSHTTP-shaped .onetoc2 through the shared store adapter', () => {
parseFssHttpOneStoreMock.mockImplementation(
(
_bytes: Uint8Array,
_limits: unknown,
diagnostics: ParserDiagnostic[]
) => {
diagnostics.push({
severity: 'warning',
code: 'FSSHTTP_TEST_DIAGNOSTIC',
message: 'Retained adapter diagnostic.',
structure: 'Storage Index',
recoverable: true,
});
return tableOfContentsStore();
}
);
const result = parseOneNoteTableOfContents(
fssHttpHeader(ONE_TOC_FILE_TYPE),
{
sourceName: 'Open Notebook.onetoc2',
limits: { maxFileBytes: 4_096 },
}
);
expect(parseFssHttpOneStoreMock).toHaveBeenCalledOnce();
expect(parseFssHttpOneStoreMock.mock.calls[0]?.[1]).toMatchObject({
maxFileBytes: 4_096,
});
expect(result).toMatchObject({
sourceName: 'Open Notebook.onetoc2',
fileIdentity: '{921D7914-E817-4615-AEB5-9B1B5EA2E973}',
entries: [],
diagnostics: [{ code: 'FSSHTTP_TEST_DIAGNOSTIC' }],
});
});
it('rejects an FSSHTTP section even when the adapter returns a valid store', () => {
parseFssHttpOneStoreMock.mockReturnValue(sectionStore());
expect(() =>
parseOneNoteTableOfContents(fssHttpHeader(ONE_SECTION_FILE_TYPE))
).toThrowError(
expect.objectContaining({
code: 'INVALID_FORMAT',
structure: 'OneStore table of contents',
})
);
});
});
function tableOfContentsStore(): DesktopOneStore {
const root: StoreObject = {
id: ROOT_ID,
jcid: 0x0002_0001,
props: {
objectIds: [],
objectSpaceIds: [],
contextIds: [],
properties: { entries: [] },
},
mapping: new Map(),
contextId: CONTEXT_ID,
};
const rootObjectSpace = {
id: ROOT_ID,
roots: new Map([[1, ROOT_ID]]),
objects: new Map([[exGuidKey(ROOT_ID), root]]),
};
return {
kind: 'table-of-contents',
variant: 'fsshttpb-package-store',
fileIdentity: '{921D7914-E817-4615-AEB5-9B1B5EA2E973}',
rootObjectSpace,
objectSpaces: new Map(),
};
}
function sectionStore(): DesktopOneStore {
return { ...tableOfContentsStore(), kind: 'section' };
}
function fssHttpHeader(fileType: string): Uint8Array {
const bytes = new Uint8Array(64);
writeGuid(bytes, 0, fileType);
writeGuid(bytes, 16, '{921D7914-E817-4615-AEB5-9B1B5EA2E973}');
writeGuid(bytes, 48, FSSHTTP_PACKAGING_FORMAT);
return bytes;
}
function writeGuid(target: Uint8Array, offset: number, guid: string): void {
const plain = guid.replace(/[{}-]/gu, '');
const canonical = Array.from({ length: 16 }, (_, index) =>
Number.parseInt(plain.slice(index * 2, index * 2 + 2), 16)
);
const order = [3, 2, 1, 0, 5, 4, 7, 6, 8, 9, 10, 11, 12, 13, 14, 15];
for (let index = 0; index < order.length; index += 1) {
target[offset + order[index]!] = canonical[index]!;
}
}

View File

@@ -6,6 +6,8 @@ import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js'; import { readGuid } from '../binary/guid.js';
import type { ParserDiagnostic } from '../model/diagnostics.js'; import type { ParserDiagnostic } from '../model/diagnostics.js';
import { parseDesktopOneNoteTableOfContentsStore } from '../onestore/desktop-store.js'; import { parseDesktopOneNoteTableOfContentsStore } from '../onestore/desktop-store.js';
import type { DesktopOneStore } from '../onestore/desktop-store.js';
import { parseFssHttpOneStore } from '../onestore/fsshttp-store.js';
import { import {
countObjectReferences, countObjectReferences,
findProperty, findProperty,
@@ -13,7 +15,7 @@ import {
} from '../onestore/property-set.js'; } from '../onestore/property-set.js';
import type { ExGuid, ObjectSpace, StoreObject } from '../onestore/types.js'; import type { ExGuid, ObjectSpace, StoreObject } from '../onestore/types.js';
import { exGuidKey } from '../onestore/types.js'; import { exGuidKey } from '../onestore/types.js';
import { toBytes } from './detect-format.js'; import { detectOneNoteFormat, toBytes } from './detect-format.js';
import { OneNoteParserError } from './error.js'; import { OneNoteParserError } from './error.js';
import type { OneNoteParserLimits } from './limits.js'; import type { OneNoteParserLimits } from './limits.js';
import { resolveParserLimits } from './limits.js'; import { resolveParserLimits } from './limits.js';
@@ -58,7 +60,7 @@ interface TocContext {
active: Set<string>; active: Set<string>;
} }
/** Parse a desktop revision-store `.onetoc2` without network access. */ /** Parse a supported desktop or FSSHTTPB `.onetoc2` without network access. */
export function parseOneNoteTableOfContents( export function parseOneNoteTableOfContents(
input: ArrayBuffer | Uint8Array, input: ArrayBuffer | Uint8Array,
options: ParseOneNoteTableOfContentsOptions = {} options: ParseOneNoteTableOfContentsOptions = {}
@@ -66,11 +68,7 @@ export function parseOneNoteTableOfContents(
const bytes = toBytes(input); const bytes = toBytes(input);
const limits = resolveParserLimits(options.limits); const limits = resolveParserLimits(options.limits);
const diagnostics: ParserDiagnostic[] = []; const diagnostics: ParserDiagnostic[] = [];
const store = parseDesktopOneNoteTableOfContentsStore( const store = parseTableOfContentsStore(bytes, limits, diagnostics);
bytes,
limits,
diagnostics
);
const space = store.rootObjectSpace; const space = store.rootObjectSpace;
const rootId = space.roots.get(1); const rootId = space.roots.get(1);
if (!rootId) { if (!rootId) {
@@ -110,6 +108,25 @@ export function parseOneNoteTableOfContents(
}; };
} }
function parseTableOfContentsStore(
bytes: Uint8Array,
limits: OneNoteParserLimits,
diagnostics: ParserDiagnostic[]
): DesktopOneStore {
const store =
detectOneNoteFormat(bytes).kind === 'fsshttp-one'
? parseFssHttpOneStore(bytes, limits, diagnostics)
: parseDesktopOneNoteTableOfContentsStore(bytes, limits, diagnostics);
if (store.kind !== 'table-of-contents') {
throw new OneNoteParserError(
'INVALID_FORMAT',
'Expected a OneNote table of contents, found a section',
{ structure: 'OneStore table of contents' }
);
}
return store;
}
function parseChildren( function parseChildren(
context: TocContext, context: TocContext,
parent: StoreObject, parent: StoreObject,