// 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 { appendItems } from '../model/append.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, referenceOffset, } from '../onestore/property-set.js'; import type { ExGuid, ObjectSpace, StoreObject } from '../onestore/types.js'; import { exGuidKey } from '../onestore/types.js'; import { detectOneNoteFormat, 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 supported desktop or FSSHTTPB `.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 = parseTableOfContentsStore(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: optionalColor(root), enableHistory: optionalBool(root, TOC_PROPERTY.enableHistory), entries, diagnostics, }; } 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, 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: optionalColor(child), }); } else { appendItems(result, 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 (object.resolvedObjectIds) { if (offset + count > object.resolvedObjectIds.length) { throw new OneNoteParserError( 'MISSING_REFERENCE', 'TOC object-reference range exceeds its resolved OID stream', { structure: 'TOCEntryIndex_OidIndex' } ); } return object.resolvedObjectIds.slice(offset, offset + count); } 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 optionalColor(object: StoreObject): number | undefined { const color = optionalU32(object, TOC_PROPERTY.color); return color === 0xffff_ffff ? undefined : color; } 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, }); }