diff --git a/src/onenote/onepkg/joplin-fixture.test.ts b/src/onenote/onepkg/joplin-fixture.test.ts index 9c67f2a..f88b397 100644 --- a/src/onenote/onepkg/joplin-fixture.test.ts +++ b/src/onenote/onepkg/joplin-fixture.test.ts @@ -5,6 +5,7 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { parseOneNoteSection } from '../parser/parse-section.js'; +import { parseOneNoteTableOfContents } from '../parser/parse-toc.js'; import { enumerateCabinet, extractCabinet } from './cabinet.js'; import { openOneNotePackage } from './package.js'; @@ -111,4 +112,36 @@ describe('pinned Joplin .onepkg fixture', () => { text: 'Link to page: Page 2', }); }); + + it('interprets both desktop TOCs with their authored ordering', async () => { + const extraction = await extractCabinet(JOPLIN_ONEPKG); + const root = extraction.extractedEntries.find( + (entry) => entry.normalizedPath === 'Open Notebook.onetoc2' + ); + const group = extraction.extractedEntries.find( + (entry) => entry.normalizedPath === 'Section group/Open Notebook.onetoc2' + ); + expect(root).toBeDefined(); + expect(group).toBeDefined(); + + expect( + parseOneNoteTableOfContents(root!.bytes, { + sourceName: root!.normalizedPath, + }).entries.map((entry) => [entry.orderingId, entry.filename]) + ).toEqual([ + [1, 'Tést!.one'], + [2, 'OneNote_RecycleBin'], + [3, 'Another section.one'], + [4, 'Section group'], + [5, '⅀⸨ Unicode ⸩.one'], + ]); + expect( + parseOneNoteTableOfContents(group!.bytes, { + sourceName: group!.normalizedPath, + }).entries.map((entry) => [entry.orderingId, entry.filename]) + ).toEqual([ + [1, 'A.one'], + [2, 'B.one'], + ]); + }); }); diff --git a/src/onenote/onestore/desktop-store.ts b/src/onenote/onestore/desktop-store.ts index 2e1d0dd..8de6e14 100644 --- a/src/onenote/onestore/desktop-store.ts +++ b/src/onenote/onestore/desktop-store.ts @@ -16,6 +16,7 @@ import type { ParserDiagnostic } from '../model/diagnostics.js'; import { DESKTOP_REVISION_STORE_FORMAT, ONE_SECTION_FILE_TYPE, + ONE_TOC_FILE_TYPE, detectOneNoteFormat, } from '../parser/detect-format.js'; import { OneNoteParserError } from '../parser/error.js'; @@ -37,6 +38,8 @@ import type { import { exGuidKey } from './types.js'; export interface DesktopOneStore { + kind: 'section' | 'table-of-contents'; + fileIdentity: string; rootObjectSpace: ObjectSpace; objectSpaces: Map; } @@ -52,6 +55,29 @@ export function parseDesktopOneStore( bytes: Uint8Array, limits: OneNoteParserLimits, diagnostics: ParserDiagnostic[] +): DesktopOneStore { + return parseDesktopRevisionStore(bytes, limits, diagnostics, 'desktop-one'); +} + +/** Parse the desktop revision-store layer used by a `.onetoc2` file. */ +export function parseDesktopOneNoteTableOfContentsStore( + bytes: Uint8Array, + limits: OneNoteParserLimits, + diagnostics: ParserDiagnostic[] +): DesktopOneStore { + return parseDesktopRevisionStore( + bytes, + limits, + diagnostics, + 'desktop-onetoc2' + ); +} + +function parseDesktopRevisionStore( + bytes: Uint8Array, + limits: OneNoteParserLimits, + diagnostics: ParserDiagnostic[], + expectedKind: 'desktop-one' | 'desktop-onetoc2' ): DesktopOneStore { if (bytes.byteLength > limits.maxFileBytes) { throw new OneNoteParserError( @@ -62,12 +88,12 @@ export function parseDesktopOneStore( } const detected = detectOneNoteFormat(bytes); - if (detected.kind !== 'desktop-one') { + if (detected.kind !== expectedKind) { throw new OneNoteParserError( detected.kind === 'not-onenote' ? 'INVALID_FORMAT' : 'UNSUPPORTED_FORMAT', detected.kind === 'fsshttp-one' - ? 'This OneNote section uses FSSHTTP packaging, which is not yet supported' - : `Expected a desktop .one section; detected ${detected.kind}`, + ? 'This OneNote source uses FSSHTTP packaging, which is not yet supported' + : `Expected ${expectedKind}; detected ${detected.kind}`, { structure: 'OneStore header' } ); } @@ -81,10 +107,13 @@ export function parseDesktopOneStore( const header = new BinaryReader(bytes, 0, 1024, 'OneStore header'); const fileType = readGuid(header); - header.skip(32); + const fileIdentity = readGuid(header); + header.skip(16); const fileFormat = readGuid(header); + const expectedFileType = + expectedKind === 'desktop-one' ? ONE_SECTION_FILE_TYPE : ONE_TOC_FILE_TYPE; if ( - fileType !== ONE_SECTION_FILE_TYPE || + fileType !== expectedFileType || fileFormat !== DESKTOP_REVISION_STORE_FORMAT ) { throw new OneNoteParserError( @@ -145,7 +174,12 @@ export function parseDesktopOneStore( ); } - return { rootObjectSpace, objectSpaces }; + return { + kind: expectedKind === 'desktop-one' ? 'section' : 'table-of-contents', + fileIdentity, + rootObjectSpace, + objectSpaces, + }; } function parseObjectSpace(context: ParseContext, node: FileNode): ObjectSpace { @@ -191,6 +225,7 @@ function parseRevisionManifestList( ); } + const revisionMappings = new Map>(); let index = 1; while (index < list.nodes.length) { const node = list.nodes[index]!; @@ -199,14 +234,20 @@ function parseRevisionManifestList( } else if (node.id === 0x05c || node.id === 0x05d) { index += 1; } else if (node.id === 0x01b || node.id === 0x01e || node.id === 0x01f) { - index = parseRevision( + const identity = readRevisionIdentity(context, node); + const parsed = parseRevision( context, list.nodes, index + 1, contextId, roots, - objects + objects, + revisionMappings.get(exGuidKey(identity.dependencyId)) ); + index = parsed.nextIndex; + if (parsed.mapping) { + revisionMappings.set(exGuidKey(identity.id), parsed.mapping); + } } else { throw new OneNoteParserError( 'INVALID_STRUCTURE', @@ -223,14 +264,17 @@ function parseRevision( startIndex: number, contextId: ExGuid, roots: Map, - objects: Map -): number { + objects: Map, + dependencyMapping: ReadonlyMap | undefined +): { nextIndex: number; mapping?: ReadonlyMap } { let index = startIndex; let mostRecentMapping: ReadonlyMap | undefined; while (index < nodes.length) { const node = nodes[index]!; - if (node.id === 0x01c) return index + 1; + if (node.id === 0x01c) { + return { nextIndex: index + 1, mapping: mostRecentMapping }; + } if (node.id === 0x0b0) { parseObjectGroup( @@ -248,7 +292,12 @@ function parseRevision( continue; } if (node.id === 0x021 || node.id === 0x022) { - const result = parseGlobalIdTable(context, nodes, index); + const result = parseGlobalIdTable( + context, + nodes, + index, + dependencyMapping + ); index = result.nextIndex; mostRecentMapping = result.mapping; while (index < nodes.length && isObjectDeclaration(nodes[index]!.id)) { @@ -264,6 +313,24 @@ function parseRevision( } continue; } + if (isObjectDeclaration(node.id)) { + if (!mostRecentMapping) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + 'Object declaration appears before a GlobalIdTable', + { offset: node.offset, structure: 'Revision' } + ); + } + parseObjectDeclaration( + context, + node, + contextId, + mostRecentMapping, + objects + ); + index += 1; + continue; + } if (node.id === 0x05a) { const reader = nodeReader(context, node); const objectId = readExGuid(reader); @@ -382,7 +449,8 @@ function parseInlineObjectGroup( function parseGlobalIdTable( context: ParseContext, nodes: FileNode[], - startIndex: number + startIndex: number, + dependencyMapping?: ReadonlyMap ): { mapping: ReadonlyMap; nextIndex: number } { const start = nodes[startIndex]; if (!start || (start.id !== 0x021 && start.id !== 0x022)) { @@ -394,6 +462,7 @@ function parseGlobalIdTable( } const mapping = new Map(); + const usedGuids = new Set(); let index = startIndex + 1; while (index < nodes.length) { const node = nodes[index]!; @@ -402,14 +471,47 @@ function parseGlobalIdTable( } if (node.id === 0x024) { const reader = nodeReader(context, node); - mapping.set(reader.u32(), readGuid(reader)); - } else if (node.id === 0x025 || node.id === 0x026) { - diagnostic( - context, - 'UNSUPPORTED_GLOBAL_ID_MAPPING', - `Skipped dependent GlobalIdTable entry 0x${node.id.toString(16)}`, + addGlobalIdMapping( + mapping, + usedGuids, + reader.u32(), + readGuid(reader), node ); + } else if (node.id === 0x025) { + const reader = nodeReader(context, node); + const sourceIndex = reader.u32(); + const targetIndex = reader.u32(); + addDependentGlobalIdMapping( + mapping, + usedGuids, + dependencyMapping, + sourceIndex, + targetIndex, + node + ); + } else if (node.id === 0x026) { + const reader = nodeReader(context, node); + const sourceStart = reader.u32(); + const count = reader.u32(); + const targetStart = reader.u32(); + if (count > context.limits.maxStreamIds) { + throw new OneNoteParserError( + 'LIMIT_EXCEEDED', + `GlobalIdTable copy range ${count} exceeds ${context.limits.maxStreamIds}`, + { offset: node.offset, structure: 'GlobalIdTableEntry3FNDX' } + ); + } + for (let offset = 0; offset < count; offset += 1) { + addDependentGlobalIdMapping( + mapping, + usedGuids, + dependencyMapping, + sourceStart + offset, + targetStart + offset, + node + ); + } } else { throw new OneNoteParserError( 'INVALID_STRUCTURE', @@ -426,6 +528,43 @@ function parseGlobalIdTable( ); } +function addDependentGlobalIdMapping( + mapping: Map, + usedGuids: Set, + dependencyMapping: ReadonlyMap | undefined, + sourceIndex: number, + targetIndex: number, + node: FileNode +): void { + const guid = dependencyMapping?.get(sourceIndex); + if (!guid) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Dependency GlobalIdTable has no GUID at index ${sourceIndex}`, + { offset: node.offset, structure: 'GlobalIdTable' } + ); + } + addGlobalIdMapping(mapping, usedGuids, targetIndex, guid, node); +} + +function addGlobalIdMapping( + mapping: Map, + usedGuids: Set, + index: number, + guid: string, + node: FileNode +): void { + if (index >= 0x00ff_ffff || mapping.has(index) || usedGuids.has(guid)) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Invalid or duplicate GlobalIdTable entry ${index}`, + { offset: node.offset, structure: 'GlobalIdTable' } + ); + } + mapping.set(index, guid); + usedGuids.add(guid); +} + function parseObjectDeclaration( context: ParseContext, node: FileNode, @@ -452,19 +591,68 @@ function parseObjectDeclaration( const reader = nodeReader(context, node); const compactId = readCompactId(reader); - const jcid = reader.u32(); - reader.u8(); // reference-stream presence flags - if (node.id === 0x0a4) reader.u8(); - else if (node.id === 0x0a5) reader.u32(); - else if (node.id === 0x0c4) { - reader.u8(); - reader.skip(16); - } else if (node.id === 0x0c5) { - reader.u32(); - reader.skip(16); + const id = resolveCompactId(compactId, mapping); + let jcid: number; + + if (node.id === 0x02d || node.id === 0x02e) { + const metadata = reader.u32(); + const jcidIndex = metadata & 0x3ff; + if ( + jcidIndex !== 0x01 || + (metadata & ~0x0001_03ff) !== 0 || + reader.u16() !== 0 + ) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'Invalid ObjectDeclarationWithRefCountBody metadata', + { + offset: node.offset, + structure: 'ObjectDeclarationWithRefCountBody', + } + ); + } + // MS-ONESTORE 2.1.5 supplies the remaining JCID flags when only jci is + // stored: this is a writable property-set object. + jcid = jcidIndex | 0x0002_0000; + if (node.id === 0x02d) reader.u8(); + else reader.u32(); + } else if (node.id === 0x041 || node.id === 0x042) { + const existing = objects.get(exGuidKey(id)); + if (!existing) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Object revision ${exGuidKey(id)} has no earlier declaration`, + { offset: node.offset, structure: 'ObjectRevisionWithRefCountFNDX' } + ); + } + jcid = existing.jcid; + if (node.id === 0x041) { + reader.u8(); + } else { + const metadata = reader.u32(); + if ((metadata & ~0x3) !== 0) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'ObjectRevisionWithRefCount2FNDX has non-zero reserved bits', + { offset: node.offset, structure: 'ObjectRevisionWithRefCount2FNDX' } + ); + } + reader.u32(); + } + } else { + jcid = reader.u32(); + reader.u8(); // reference-stream presence flags + if (node.id === 0x0a4) reader.u8(); + else if (node.id === 0x0a5) reader.u32(); + else if (node.id === 0x0c4) { + reader.u8(); + reader.skip(16); + } else if (node.id === 0x0c5) { + reader.u32(); + reader.skip(16); + } } - const id = resolveCompactId(compactId, mapping); const props = parseObjectPropSet( context.bytes, node.dataReference, @@ -489,6 +677,10 @@ function parseObjectDeclaration( function isObjectDeclaration(nodeId: number): boolean { return ( + nodeId === 0x02d || + nodeId === 0x02e || + nodeId === 0x041 || + nodeId === 0x042 || nodeId === 0x0a4 || nodeId === 0x0a5 || nodeId === 0x0c4 || @@ -518,6 +710,14 @@ function readCompactId(reader: BinaryReader): CompactId { return { value: raw & 0xff, guidIndex: raw >>> 8 }; } +function readRevisionIdentity( + context: ParseContext, + node: FileNode +): { id: ExGuid; dependencyId: ExGuid } { + const reader = nodeReader(context, node); + return { id: readExGuid(reader), dependencyId: readExGuid(reader) }; +} + function readExGuid(reader: BinaryReader): ExGuid { return { guid: readGuid(reader), value: reader.u32() }; } diff --git a/src/onenote/parser/parse-toc.ts b/src/onenote/parser/parse-toc.ts new file mode 100644 index 0000000..a7d15bf --- /dev/null +++ b/src/onenote/parser/parse-toc.ts @@ -0,0 +1,332 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +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 { + countObjectReferences, + findProperty, + referenceOffset, +} 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 { OneNoteParserError } from './error.js'; +import type { OneNoteParserLimits } from './limits.js'; +import { resolveParserLimits } from './limits.js'; + +const TOC_CONTAINER_JCID = 0x0002_0001; +const TOC_PROPERTY = { + children: 0x2400_1cf6, + fileIdentity: 0x1c00_1d94, + filename: 0x1c00_1d6b, + orderingId: 0x1400_1cb9, + color: 0x1400_1cbe, + enableHistory: 0x0800_1e1e, +} as const; + +const UTF16 = new TextDecoder('utf-16le', { fatal: true }); + +export interface OneNoteTableOfContentsEntry { + filename: string; + fileIdentity: string; + orderingId: number; + color?: number; +} + +export interface OneNoteTableOfContents { + sourceName: string; + fileIdentity: string; + color?: number; + enableHistory?: boolean; + entries: OneNoteTableOfContentsEntry[]; + diagnostics: ParserDiagnostic[]; +} + +export interface ParseOneNoteTableOfContentsOptions { + sourceName?: string; + limits?: Partial; +} + +interface TocContext { + space: ObjectSpace; + limits: OneNoteParserLimits; + diagnostics: ParserDiagnostic[]; + active: Set; +} + +/** Parse a desktop revision-store `.onetoc2` without network access. */ +export function parseOneNoteTableOfContents( + input: ArrayBuffer | Uint8Array, + options: ParseOneNoteTableOfContentsOptions = {} +): OneNoteTableOfContents { + const bytes = toBytes(input); + const limits = resolveParserLimits(options.limits); + const diagnostics: ParserDiagnostic[] = []; + const store = parseDesktopOneNoteTableOfContentsStore( + bytes, + limits, + diagnostics + ); + const space = store.rootObjectSpace; + const rootId = space.roots.get(1); + if (!rootId) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + 'Table of contents has no default content root', + { structure: 'jcidPersistablePropertyContainerForTOC' } + ); + } + const root = space.objects.get(exGuidKey(rootId)); + if (!root) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `Table-of-contents root ${exGuidKey(rootId)} is absent`, + { structure: 'jcidPersistablePropertyContainerForTOC' } + ); + } + assertTocContainer(root, 'table-of-contents root'); + + const context: TocContext = { + space, + limits, + diagnostics, + active: new Set([exGuidKey(root.id)]), + }; + const entries = parseChildren(context, root, 0).sort( + (left, right) => left.orderingId - right.orderingId + ); + + return { + sourceName: options.sourceName ?? 'Open Notebook.onetoc2', + fileIdentity: store.fileIdentity, + color: optionalU32(root, TOC_PROPERTY.color), + enableHistory: optionalBool(root, TOC_PROPERTY.enableHistory), + entries, + diagnostics, + }; +} + +function parseChildren( + context: TocContext, + parent: StoreObject, + depth: number +): OneNoteTableOfContentsEntry[] { + if (depth > context.limits.maxGraphDepth) { + throw new OneNoteParserError( + 'LIMIT_EXCEEDED', + `TOC depth exceeds ${context.limits.maxGraphDepth}`, + { structure: 'TOCEntryIndex_OidIndex' } + ); + } + + const result: OneNoteTableOfContentsEntry[] = []; + for (const childId of objectReferences(parent, TOC_PROPERTY.children)) { + const key = exGuidKey(childId); + try { + if (context.active.has(key)) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `Cycle detected at TOC object ${key}`, + { structure: 'TOCEntryIndex_OidIndex' } + ); + } + const child = context.space.objects.get(key); + if (!child) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `TOC child ${key} is absent`, + { structure: 'TOCEntryIndex_OidIndex' } + ); + } + assertTocContainer(child, `TOC child ${key}`); + context.active.add(key); + try { + const filename = optionalString(child, TOC_PROPERTY.filename); + if (filename !== undefined) { + result.push({ + filename: decodeFolderChildFilename(filename), + fileIdentity: requiredGuid(child, TOC_PROPERTY.fileIdentity), + orderingId: requiredU32(child, TOC_PROPERTY.orderingId), + color: optionalU32(child, TOC_PROPERTY.color), + }); + } else { + result.push(...parseChildren(context, child, depth + 1)); + } + } finally { + context.active.delete(key); + } + } catch (error) { + addEntryDiagnostic(context, error, key); + } + } + return result; +} + +function objectReferences(object: StoreObject, propertyId: number): ExGuid[] { + const property = findProperty(object.props, propertyId); + if (!property) return []; + const count = + property.value.kind === 'objectId' + ? 1 + : property.value.kind === 'objectIds' + ? property.value.count + : undefined; + if (count === undefined) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'TOCEntryIndex_OidIndex is not an object-reference property', + { structure: 'TOCEntryIndex_OidIndex' } + ); + } + const offset = referenceOffset( + object.props, + propertyId, + countObjectReferences + ); + if (offset + count > object.props.objectIds.length) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + 'TOC object-reference range exceeds its OID stream', + { structure: 'TOCEntryIndex_OidIndex' } + ); + } + return object.props.objectIds.slice(offset, offset + count).map((compact) => { + const guid = object.mapping.get(compact.guidIndex); + if (!guid) { + throw new OneNoteParserError( + 'MISSING_REFERENCE', + `TOC GUID mapping ${compact.guidIndex} is absent`, + { structure: 'CompactID' } + ); + } + return { guid, value: compact.value }; + }); +} + +function assertTocContainer(object: StoreObject, structure: string): void { + if (object.jcid !== TOC_CONTAINER_JCID) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + `${structure} has JCID 0x${object.jcid.toString(16)}, expected 0x${TOC_CONTAINER_JCID.toString(16)}`, + { structure } + ); + } +} + +function optionalString( + object: StoreObject, + propertyId: number +): string | undefined { + const value = findProperty(object.props, propertyId)?.value; + if (!value) return undefined; + if (value.kind !== 'bytes' || value.value.byteLength % 2 !== 0) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'FolderChildFilename is not a UTF-16 byte vector', + { structure: 'FolderChildFilename' } + ); + } + try { + return UTF16.decode(value.value).split('\0').join(''); + } catch (error) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'FolderChildFilename is not valid UTF-16', + { structure: 'FolderChildFilename', cause: error } + ); + } +} + +function decodeFolderChildFilename(value: string): string { + const decoded = value.replaceAll('^M', '+').replaceAll('^J', ','); + if (decoded.length === 0) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'FolderChildFilename is empty', + { structure: 'FolderChildFilename' } + ); + } + return decoded; +} + +function requiredGuid(object: StoreObject, propertyId: number): string { + const value = findProperty(object.props, propertyId)?.value; + if (!value || value.kind !== 'bytes' || value.value.byteLength !== 16) { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'FileIdentityGuid is absent or not 16 bytes', + { structure: 'FileIdentityGuid' } + ); + } + return readGuid(new BinaryReader(value.value, 0, 16, 'FileIdentityGuid')); +} + +function requiredU32(object: StoreObject, propertyId: number): number { + const value = findProperty(object.props, propertyId)?.value; + if (!value || value.kind !== 'u32') { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'NotebookElementOrderingID is absent or not an unsigned integer', + { structure: 'NotebookElementOrderingID' } + ); + } + return value.value; +} + +function optionalU32( + object: StoreObject, + propertyId: number +): number | undefined { + const value = findProperty(object.props, propertyId)?.value; + if (!value) return undefined; + if (value.kind !== 'u32') { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'NotebookColor is not an unsigned integer', + { structure: 'NotebookColor' } + ); + } + return value.value; +} + +function optionalBool( + object: StoreObject, + propertyId: number +): boolean | undefined { + const value = findProperty(object.props, propertyId)?.value; + if (!value) return undefined; + if (value.kind !== 'bool') { + throw new OneNoteParserError( + 'INVALID_STRUCTURE', + 'EnableHistory is not a Boolean', + { structure: 'EnableHistory' } + ); + } + return value.value; +} + +function addEntryDiagnostic( + context: TocContext, + error: unknown, + objectKey: string +): void { + if (context.diagnostics.length >= context.limits.maxDiagnostics) return; + context.diagnostics.push({ + severity: 'warning', + code: 'TOC_ENTRY_PARSE_FAILED', + message: + error instanceof Error + ? error.message + : 'A table-of-contents entry could not be interpreted.', + offset: error instanceof OneNoteParserError ? error.offset : undefined, + structure: + error instanceof OneNoteParserError && error.structure + ? `${error.structure} (${objectKey})` + : `TOC object ${objectKey}`, + recoverable: true, + }); +}