feat: parse desktop OneNote table of contents
This commit is contained in:
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, ObjectSpace>;
|
||||
}
|
||||
@@ -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<string, ReadonlyMap<number, string>>();
|
||||
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<number, ExGuid>,
|
||||
objects: Map<string, StoreObject>
|
||||
): number {
|
||||
objects: Map<string, StoreObject>,
|
||||
dependencyMapping: ReadonlyMap<number, string> | undefined
|
||||
): { nextIndex: number; mapping?: ReadonlyMap<number, string> } {
|
||||
let index = startIndex;
|
||||
let mostRecentMapping: ReadonlyMap<number, string> | 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<number, string>
|
||||
): { mapping: ReadonlyMap<number, string>; nextIndex: number } {
|
||||
const start = nodes[startIndex];
|
||||
if (!start || (start.id !== 0x021 && start.id !== 0x022)) {
|
||||
@@ -394,6 +462,7 @@ function parseGlobalIdTable(
|
||||
}
|
||||
|
||||
const mapping = new Map<number, string>();
|
||||
const usedGuids = new Set<string>();
|
||||
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<number, string>,
|
||||
usedGuids: Set<string>,
|
||||
dependencyMapping: ReadonlyMap<number, string> | 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<number, string>,
|
||||
usedGuids: Set<string>,
|
||||
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,7 +591,56 @@ function parseObjectDeclaration(
|
||||
|
||||
const reader = nodeReader(context, node);
|
||||
const compactId = readCompactId(reader);
|
||||
const jcid = reader.u32();
|
||||
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();
|
||||
@@ -463,8 +651,8 @@ function parseObjectDeclaration(
|
||||
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() };
|
||||
}
|
||||
|
||||
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