feat: parse desktop OneNote table of contents
This commit is contained in:
332
src/onenote/parser/parse-toc.ts
Normal file
332
src/onenote/parser/parse-toc.ts
Normal file
@@ -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<OneNoteParserLimits>;
|
||||
}
|
||||
|
||||
interface TocContext {
|
||||
space: ObjectSpace;
|
||||
limits: OneNoteParserLimits;
|
||||
diagnostics: ParserDiagnostic[];
|
||||
active: Set<string>;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user