feat: parse desktop OneNote table of contents

This commit is contained in:
2026-07-22 18:54:32 +02:00
parent f581cbdced
commit a0c26d604d
3 changed files with 595 additions and 30 deletions

View File

@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseOneNoteSection } from '../parser/parse-section.js'; import { parseOneNoteSection } from '../parser/parse-section.js';
import { parseOneNoteTableOfContents } from '../parser/parse-toc.js';
import { enumerateCabinet, extractCabinet } from './cabinet.js'; import { enumerateCabinet, extractCabinet } from './cabinet.js';
import { openOneNotePackage } from './package.js'; import { openOneNotePackage } from './package.js';
@@ -111,4 +112,36 @@ describe('pinned Joplin .onepkg fixture', () => {
text: 'Link to page: Page 2', 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'],
]);
});
}); });

View File

@@ -16,6 +16,7 @@ import type { ParserDiagnostic } from '../model/diagnostics.js';
import { import {
DESKTOP_REVISION_STORE_FORMAT, DESKTOP_REVISION_STORE_FORMAT,
ONE_SECTION_FILE_TYPE, ONE_SECTION_FILE_TYPE,
ONE_TOC_FILE_TYPE,
detectOneNoteFormat, detectOneNoteFormat,
} from '../parser/detect-format.js'; } from '../parser/detect-format.js';
import { OneNoteParserError } from '../parser/error.js'; import { OneNoteParserError } from '../parser/error.js';
@@ -37,6 +38,8 @@ import type {
import { exGuidKey } from './types.js'; import { exGuidKey } from './types.js';
export interface DesktopOneStore { export interface DesktopOneStore {
kind: 'section' | 'table-of-contents';
fileIdentity: string;
rootObjectSpace: ObjectSpace; rootObjectSpace: ObjectSpace;
objectSpaces: Map<string, ObjectSpace>; objectSpaces: Map<string, ObjectSpace>;
} }
@@ -52,6 +55,29 @@ export function parseDesktopOneStore(
bytes: Uint8Array, bytes: Uint8Array,
limits: OneNoteParserLimits, limits: OneNoteParserLimits,
diagnostics: ParserDiagnostic[] 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 { ): DesktopOneStore {
if (bytes.byteLength > limits.maxFileBytes) { if (bytes.byteLength > limits.maxFileBytes) {
throw new OneNoteParserError( throw new OneNoteParserError(
@@ -62,12 +88,12 @@ export function parseDesktopOneStore(
} }
const detected = detectOneNoteFormat(bytes); const detected = detectOneNoteFormat(bytes);
if (detected.kind !== 'desktop-one') { if (detected.kind !== expectedKind) {
throw new OneNoteParserError( throw new OneNoteParserError(
detected.kind === 'not-onenote' ? 'INVALID_FORMAT' : 'UNSUPPORTED_FORMAT', detected.kind === 'not-onenote' ? 'INVALID_FORMAT' : 'UNSUPPORTED_FORMAT',
detected.kind === 'fsshttp-one' detected.kind === 'fsshttp-one'
? 'This OneNote section uses FSSHTTP packaging, which is not yet supported' ? 'This OneNote source uses FSSHTTP packaging, which is not yet supported'
: `Expected a desktop .one section; detected ${detected.kind}`, : `Expected ${expectedKind}; detected ${detected.kind}`,
{ structure: 'OneStore header' } { structure: 'OneStore header' }
); );
} }
@@ -81,10 +107,13 @@ export function parseDesktopOneStore(
const header = new BinaryReader(bytes, 0, 1024, 'OneStore header'); const header = new BinaryReader(bytes, 0, 1024, 'OneStore header');
const fileType = readGuid(header); const fileType = readGuid(header);
header.skip(32); const fileIdentity = readGuid(header);
header.skip(16);
const fileFormat = readGuid(header); const fileFormat = readGuid(header);
const expectedFileType =
expectedKind === 'desktop-one' ? ONE_SECTION_FILE_TYPE : ONE_TOC_FILE_TYPE;
if ( if (
fileType !== ONE_SECTION_FILE_TYPE || fileType !== expectedFileType ||
fileFormat !== DESKTOP_REVISION_STORE_FORMAT fileFormat !== DESKTOP_REVISION_STORE_FORMAT
) { ) {
throw new OneNoteParserError( 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 { function parseObjectSpace(context: ParseContext, node: FileNode): ObjectSpace {
@@ -191,6 +225,7 @@ function parseRevisionManifestList(
); );
} }
const revisionMappings = new Map<string, ReadonlyMap<number, string>>();
let index = 1; let index = 1;
while (index < list.nodes.length) { while (index < list.nodes.length) {
const node = list.nodes[index]!; const node = list.nodes[index]!;
@@ -199,14 +234,20 @@ function parseRevisionManifestList(
} else if (node.id === 0x05c || node.id === 0x05d) { } else if (node.id === 0x05c || node.id === 0x05d) {
index += 1; index += 1;
} else if (node.id === 0x01b || node.id === 0x01e || node.id === 0x01f) { } else if (node.id === 0x01b || node.id === 0x01e || node.id === 0x01f) {
index = parseRevision( const identity = readRevisionIdentity(context, node);
const parsed = parseRevision(
context, context,
list.nodes, list.nodes,
index + 1, index + 1,
contextId, contextId,
roots, roots,
objects objects,
revisionMappings.get(exGuidKey(identity.dependencyId))
); );
index = parsed.nextIndex;
if (parsed.mapping) {
revisionMappings.set(exGuidKey(identity.id), parsed.mapping);
}
} else { } else {
throw new OneNoteParserError( throw new OneNoteParserError(
'INVALID_STRUCTURE', 'INVALID_STRUCTURE',
@@ -223,14 +264,17 @@ function parseRevision(
startIndex: number, startIndex: number,
contextId: ExGuid, contextId: ExGuid,
roots: Map<number, ExGuid>, roots: Map<number, ExGuid>,
objects: Map<string, StoreObject> objects: Map<string, StoreObject>,
): number { dependencyMapping: ReadonlyMap<number, string> | undefined
): { nextIndex: number; mapping?: ReadonlyMap<number, string> } {
let index = startIndex; let index = startIndex;
let mostRecentMapping: ReadonlyMap<number, string> | undefined; let mostRecentMapping: ReadonlyMap<number, string> | undefined;
while (index < nodes.length) { while (index < nodes.length) {
const node = nodes[index]!; 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) { if (node.id === 0x0b0) {
parseObjectGroup( parseObjectGroup(
@@ -248,7 +292,12 @@ function parseRevision(
continue; continue;
} }
if (node.id === 0x021 || node.id === 0x022) { if (node.id === 0x021 || node.id === 0x022) {
const result = parseGlobalIdTable(context, nodes, index); const result = parseGlobalIdTable(
context,
nodes,
index,
dependencyMapping
);
index = result.nextIndex; index = result.nextIndex;
mostRecentMapping = result.mapping; mostRecentMapping = result.mapping;
while (index < nodes.length && isObjectDeclaration(nodes[index]!.id)) { while (index < nodes.length && isObjectDeclaration(nodes[index]!.id)) {
@@ -264,6 +313,24 @@ function parseRevision(
} }
continue; 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) { if (node.id === 0x05a) {
const reader = nodeReader(context, node); const reader = nodeReader(context, node);
const objectId = readExGuid(reader); const objectId = readExGuid(reader);
@@ -382,7 +449,8 @@ function parseInlineObjectGroup(
function parseGlobalIdTable( function parseGlobalIdTable(
context: ParseContext, context: ParseContext,
nodes: FileNode[], nodes: FileNode[],
startIndex: number startIndex: number,
dependencyMapping?: ReadonlyMap<number, string>
): { mapping: ReadonlyMap<number, string>; nextIndex: number } { ): { mapping: ReadonlyMap<number, string>; nextIndex: number } {
const start = nodes[startIndex]; const start = nodes[startIndex];
if (!start || (start.id !== 0x021 && start.id !== 0x022)) { if (!start || (start.id !== 0x021 && start.id !== 0x022)) {
@@ -394,6 +462,7 @@ function parseGlobalIdTable(
} }
const mapping = new Map<number, string>(); const mapping = new Map<number, string>();
const usedGuids = new Set<string>();
let index = startIndex + 1; let index = startIndex + 1;
while (index < nodes.length) { while (index < nodes.length) {
const node = nodes[index]!; const node = nodes[index]!;
@@ -402,14 +471,47 @@ function parseGlobalIdTable(
} }
if (node.id === 0x024) { if (node.id === 0x024) {
const reader = nodeReader(context, node); const reader = nodeReader(context, node);
mapping.set(reader.u32(), readGuid(reader)); addGlobalIdMapping(
} else if (node.id === 0x025 || node.id === 0x026) { mapping,
diagnostic( usedGuids,
context, reader.u32(),
'UNSUPPORTED_GLOBAL_ID_MAPPING', readGuid(reader),
`Skipped dependent GlobalIdTable entry 0x${node.id.toString(16)}`,
node 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 { } else {
throw new OneNoteParserError( throw new OneNoteParserError(
'INVALID_STRUCTURE', '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( function parseObjectDeclaration(
context: ParseContext, context: ParseContext,
node: FileNode, node: FileNode,
@@ -452,19 +591,68 @@ function parseObjectDeclaration(
const reader = nodeReader(context, node); const reader = nodeReader(context, node);
const compactId = readCompactId(reader); const compactId = readCompactId(reader);
const jcid = reader.u32(); const id = resolveCompactId(compactId, mapping);
reader.u8(); // reference-stream presence flags let jcid: number;
if (node.id === 0x0a4) reader.u8();
else if (node.id === 0x0a5) reader.u32(); if (node.id === 0x02d || node.id === 0x02e) {
else if (node.id === 0x0c4) { const metadata = reader.u32();
reader.u8(); const jcidIndex = metadata & 0x3ff;
reader.skip(16); if (
} else if (node.id === 0x0c5) { jcidIndex !== 0x01 ||
reader.u32(); (metadata & ~0x0001_03ff) !== 0 ||
reader.skip(16); 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( const props = parseObjectPropSet(
context.bytes, context.bytes,
node.dataReference, node.dataReference,
@@ -489,6 +677,10 @@ function parseObjectDeclaration(
function isObjectDeclaration(nodeId: number): boolean { function isObjectDeclaration(nodeId: number): boolean {
return ( return (
nodeId === 0x02d ||
nodeId === 0x02e ||
nodeId === 0x041 ||
nodeId === 0x042 ||
nodeId === 0x0a4 || nodeId === 0x0a4 ||
nodeId === 0x0a5 || nodeId === 0x0a5 ||
nodeId === 0x0c4 || nodeId === 0x0c4 ||
@@ -518,6 +710,14 @@ function readCompactId(reader: BinaryReader): CompactId {
return { value: raw & 0xff, guidIndex: raw >>> 8 }; 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 { function readExGuid(reader: BinaryReader): ExGuid {
return { guid: readGuid(reader), value: reader.u32() }; return { guid: readGuid(reader), value: reader.u32() };
} }

View 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,
});
}