Files
onenote-tools/src/onenote/parser/parse-toc.ts

366 lines
11 KiB
TypeScript

// 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<OneNoteParserLimits>;
}
interface TocContext {
space: ObjectSpace;
limits: OneNoteParserLimits;
diagnostics: ParserDiagnostic[];
active: Set<string>;
}
/** 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,
});
}