From 622f1e94dd08385401b6fdf538507dbe85e3dac1 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 19:56:31 +0200 Subject: [PATCH] feat: parse FSSHTTP table of contents stores --- src/onenote/parser/parse-toc-format.test.ts | 136 ++++++++++++++++++++ src/onenote/parser/parse-toc.ts | 31 ++++- 2 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 src/onenote/parser/parse-toc-format.test.ts diff --git a/src/onenote/parser/parse-toc-format.test.ts b/src/onenote/parser/parse-toc-format.test.ts new file mode 100644 index 0000000..fcda81f --- /dev/null +++ b/src/onenote/parser/parse-toc-format.test.ts @@ -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]!; + } +} diff --git a/src/onenote/parser/parse-toc.ts b/src/onenote/parser/parse-toc.ts index 29669f1..b7c5f39 100644 --- a/src/onenote/parser/parse-toc.ts +++ b/src/onenote/parser/parse-toc.ts @@ -6,6 +6,8 @@ import { BinaryReader } from '../binary/BinaryReader.js'; import { readGuid } from '../binary/guid.js'; import type { ParserDiagnostic } from '../model/diagnostics.js'; import { parseDesktopOneNoteTableOfContentsStore } from '../onestore/desktop-store.js'; +import type { DesktopOneStore } from '../onestore/desktop-store.js'; +import { parseFssHttpOneStore } from '../onestore/fsshttp-store.js'; import { countObjectReferences, findProperty, @@ -13,7 +15,7 @@ import { } from '../onestore/property-set.js'; import type { ExGuid, ObjectSpace, StoreObject } 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 type { OneNoteParserLimits } from './limits.js'; import { resolveParserLimits } from './limits.js'; @@ -58,7 +60,7 @@ interface TocContext { active: Set; } -/** Parse a desktop revision-store `.onetoc2` without network access. */ +/** Parse a supported desktop or FSSHTTPB `.onetoc2` without network access. */ export function parseOneNoteTableOfContents( input: ArrayBuffer | Uint8Array, options: ParseOneNoteTableOfContentsOptions = {} @@ -66,11 +68,7 @@ export function parseOneNoteTableOfContents( const bytes = toBytes(input); const limits = resolveParserLimits(options.limits); const diagnostics: ParserDiagnostic[] = []; - const store = parseDesktopOneNoteTableOfContentsStore( - bytes, - limits, - diagnostics - ); + const store = parseTableOfContentsStore(bytes, limits, diagnostics); const space = store.rootObjectSpace; const rootId = space.roots.get(1); 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( context: TocContext, parent: StoreObject,