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 { 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'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,7 +591,56 @@ 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);
|
||||||
|
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
|
reader.u8(); // reference-stream presence flags
|
||||||
if (node.id === 0x0a4) reader.u8();
|
if (node.id === 0x0a4) reader.u8();
|
||||||
else if (node.id === 0x0a5) reader.u32();
|
else if (node.id === 0x0a5) reader.u32();
|
||||||
@@ -463,8 +651,8 @@ function parseObjectDeclaration(
|
|||||||
reader.u32();
|
reader.u32();
|
||||||
reader.skip(16);
|
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() };
|
||||||
}
|
}
|
||||||
|
|||||||
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