feat: render OneNote notebook hierarchy

This commit is contained in:
2026-07-22 19:05:49 +02:00
parent d282b832ac
commit 670310ea9e
14 changed files with 887 additions and 103 deletions

View File

@@ -49,12 +49,39 @@ export interface OneNotePackageEntryDto {
parseStatus: 'not-requested' | 'parsed' | 'unsupported' | 'failed';
}
export type OneNoteNotebookNodeDto =
| {
kind: 'section';
id: string;
sectionId: string;
displayName: string;
orderingId?: number;
color?: number;
}
| {
kind: 'section-group';
id: string;
displayName: string;
orderingId?: number;
color?: number;
children: OneNoteNotebookNodeDto[];
};
export interface OneNoteNotebookTreeDto {
/** True when the tree is rooted in a successfully parsed package TOC. */
interpreted: boolean;
tocPath?: string;
color?: number;
nodes: OneNoteNotebookNodeDto[];
}
export interface OneNotePackageDto {
format: 'onepkg';
sourceName: string;
sourceSize: number;
entries: OneNotePackageEntryDto[];
sections: OneNotePackagedSectionDto[];
tree: OneNoteNotebookTreeDto;
diagnostics: ParserDiagnostic[];
}

View File

@@ -84,6 +84,23 @@ describe('pinned Joplin .onepkg fixture', () => {
(section) => section.parseStatus === 'not-requested'
)
).toBe(true);
expect(opened.package.tree).toMatchObject({
interpreted: true,
tocPath: 'Open Notebook.onetoc2',
nodes: [
{ kind: 'section', displayName: 'Tést!' },
{ kind: 'section', displayName: 'Another section' },
{
kind: 'section-group',
displayName: 'Section group',
children: [
{ kind: 'section', displayName: 'A' },
{ kind: 'section', displayName: 'B' },
],
},
{ kind: 'section', displayName: '⅀⸨ Unicode ⸩' },
],
});
});
it('opens and parses all five packaged sections end to end', async () => {

View File

@@ -8,6 +8,7 @@ import type { ParserDiagnostic } from '../model/diagnostics.js';
import { checkCancellation } from './cancellation.js';
import { extractCabinet } from './cabinet.js';
import { diagnosticFromUnknown, OnePkgError, warning } from './errors.js';
import { buildOneNotePackageTree } from './toc-hierarchy.js';
import type {
CabOpenOptions,
ExtractedCabFile,
@@ -162,6 +163,12 @@ export async function openOneNotePackage(
});
}
const treeResult = buildOneNotePackageTree(
extraction.extractedEntries,
packagedSections
);
diagnostics.push(...treeResult.diagnostics);
return {
package: {
format: 'onepkg',
@@ -169,6 +176,7 @@ export async function openOneNotePackage(
sourceSize: bytes.byteLength,
entries: packageEntries,
sections: packagedSections,
tree: treeResult.tree,
diagnostics,
},
extractedEntries: extraction.extractedEntries,

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { resolveTocChildPath } from './toc-hierarchy.js';
describe('ONEPKG TOC child paths', () => {
it('joins a single NFC-normalized child name within its section group', () => {
expect(resolveTocChildPath('Projects', 'Cafe\u0301.one')).toBe(
'Projects/Café.one'
);
});
it.each(['../Secret.one', '..', 'nested/Secret.one', 'C:\\Secret.one'])(
'rejects unsafe FolderChildFilename %s',
(filename) => {
expect(() => resolveTocChildPath('Projects', filename)).toThrow(
/Unsafe FolderChildFilename/u
);
}
);
});

View File

@@ -0,0 +1,476 @@
import { BinaryReader } from '../binary/BinaryReader.js';
import { readGuid } from '../binary/guid.js';
import type {
OneNoteNotebookNodeDto,
OneNoteNotebookTreeDto,
OneNotePackagedSectionDto,
} from '../model/dto.js';
import type { ParserDiagnostic } from '../model/diagnostics.js';
import {
ONE_SECTION_FILE_TYPE,
detectOneNoteFormat,
} from '../parser/detect-format.js';
import { OneNoteParserError } from '../parser/error.js';
import {
parseOneNoteTableOfContents,
type OneNoteTableOfContents,
type OneNoteTableOfContentsEntry,
} from '../parser/parse-toc.js';
import { fail, OnePkgError, warning } from './errors.js';
import type { ExtractedCabFile } from './types.js';
import { DEFAULT_ONEPKG_LIMITS } from './types.js';
interface ParsedTocEntry {
extracted: ExtractedCabFile;
toc: OneNoteTableOfContents;
directory: string;
}
export interface OneNotePackageTreeResult {
tree: OneNoteNotebookTreeDto;
diagnostics: ParserDiagnostic[];
}
/**
* Interpret package TOCs and join them to extracted sections without ever
* materializing an archive path on the host filesystem.
*/
export function buildOneNotePackageTree(
extractedEntries: ExtractedCabFile[],
sections: OneNotePackagedSectionDto[]
): OneNotePackageTreeResult {
const diagnostics: ParserDiagnostic[] = [];
const parsedTocs: ParsedTocEntry[] = [];
for (const extracted of extractedEntries) {
if (!extracted.normalizedPath.toLowerCase().endsWith('.onetoc2')) continue;
try {
const toc = parseOneNoteTableOfContents(extracted.bytes, {
sourceName: extracted.normalizedPath,
});
parsedTocs.push({
extracted,
toc,
directory: parentPath(extracted.normalizedPath),
});
for (const diagnostic of toc.diagnostics) {
if (diagnostics.length >= DEFAULT_ONEPKG_LIMITS.maxFiles) break;
diagnostics.push({
...diagnostic,
structure: diagnostic.structure
? `${extracted.normalizedPath} · ${diagnostic.structure}`
: extracted.normalizedPath,
});
}
} catch (error) {
diagnostics.push(tocFailureDiagnostic(error, extracted.normalizedPath));
}
}
const root = selectRootToc(parsedTocs, diagnostics);
const sectionByPath = new Map(
sections.map((section) => [pathKey(section.path), section])
);
const extractedSectionByPath = new Map(
extractedEntries
.filter((entry) => entry.normalizedPath.toLowerCase().endsWith('.one'))
.map((entry) => [pathKey(entry.normalizedPath), entry])
);
const sectionIdentityByPath = new Map<string, string>();
for (const [key, entry] of extractedSectionByPath) {
const identity = readSectionIdentity(entry.bytes);
if (identity) sectionIdentityByPath.set(key, identity);
}
const tocByDirectory = new Map<string, ParsedTocEntry>();
for (const parsed of parsedTocs) {
const key = pathKey(parsed.directory);
if (tocByDirectory.has(key)) {
diagnostics.push(
warning(
'onetoc2-directory-ambiguous',
`More than one readable .onetoc2 exists in ${parsed.directory || 'the package root'}; the first one is used.`,
{ structure: parsed.directory || 'ONEPKG root' }
)
);
continue;
}
tocByDirectory.set(key, parsed);
}
const usedSectionIds = new Set<string>();
let nodes: OneNoteNotebookNodeDto[] = [];
if (root) {
nodes = buildTocNodes({
parsed: root,
tocByDirectory,
sectionByPath,
sectionIdentityByPath,
sections,
usedSectionIds,
diagnostics,
activeTocs: new Set(),
usedTocs: new Set(),
depth: 0,
});
}
const unlisted = sections
.filter(
(section) =>
!usedSectionIds.has(section.id) && !isRecycleBinPath(section.path)
)
.sort((left, right) => left.path.localeCompare(right.path));
if (unlisted.length > 0 && root) {
diagnostics.push(
warning(
'onetoc2-unlisted-sections',
`${unlisted.length} package section${unlisted.length === 1 ? '' : 's'} were not reachable from the parsed TOC and were appended to navigation.`,
{ structure: root.extracted.normalizedPath }
)
);
}
nodes.push(...unlisted.map(fallbackSectionNode));
return {
tree: {
interpreted: root !== undefined,
tocPath: root?.extracted.normalizedPath,
color: root?.toc.color,
nodes,
},
diagnostics,
};
}
interface BuildTocNodesContext {
parsed: ParsedTocEntry;
tocByDirectory: ReadonlyMap<string, ParsedTocEntry>;
sectionByPath: ReadonlyMap<string, OneNotePackagedSectionDto>;
sectionIdentityByPath: ReadonlyMap<string, string>;
sections: OneNotePackagedSectionDto[];
usedSectionIds: Set<string>;
diagnostics: ParserDiagnostic[];
activeTocs: Set<string>;
usedTocs: Set<string>;
depth: number;
}
function buildTocNodes(
context: BuildTocNodesContext
): OneNoteNotebookNodeDto[] {
if (context.depth > DEFAULT_ONEPKG_LIMITS.maxPathDepth) {
context.diagnostics.push(
warning(
'onetoc2-tree-depth-limit',
'Notebook section-group nesting exceeds the package path-depth limit.',
{ structure: context.parsed.extracted.normalizedPath }
)
);
return [];
}
const tocKey = pathKey(context.parsed.extracted.normalizedPath);
if (context.activeTocs.has(tocKey)) {
context.diagnostics.push(
warning(
'onetoc2-tree-cycle',
'A cycle between notebook table-of-contents files was ignored.',
{ structure: context.parsed.extracted.normalizedPath }
)
);
return [];
}
if (context.usedTocs.has(tocKey)) {
context.diagnostics.push(
warning(
'onetoc2-tree-duplicate',
'A repeated notebook section-group reference was ignored.',
{ structure: context.parsed.extracted.normalizedPath }
)
);
return [];
}
context.activeTocs.add(tocKey);
context.usedTocs.add(tocKey);
try {
const result: OneNoteNotebookNodeDto[] = [];
for (const entry of context.parsed.toc.entries) {
if (isRecycleBinName(entry.filename)) continue;
try {
const childPath = resolveTocChildPath(
context.parsed.directory,
entry.filename
);
const section = resolveSection(context, childPath, entry);
if (section) {
if (context.usedSectionIds.has(section.id)) {
context.diagnostics.push(
warning(
'onetoc2-section-duplicate',
`Repeated TOC reference to ${entry.filename} was ignored.`,
{ structure: context.parsed.extracted.normalizedPath }
)
);
continue;
}
context.usedSectionIds.add(section.id);
result.push({
kind: 'section',
id: section.id,
sectionId: section.id,
displayName: section.displayName,
orderingId: entry.orderingId,
color: entry.color,
});
continue;
}
const childToc = context.tocByDirectory.get(pathKey(childPath));
if (childToc) {
const childTocKey = pathKey(childToc.extracted.normalizedPath);
if (context.activeTocs.has(childTocKey)) {
context.diagnostics.push(
warning(
'onetoc2-tree-cycle',
'A cycle between notebook table-of-contents files was ignored.',
{ structure: childToc.extracted.normalizedPath }
)
);
continue;
}
if (context.usedTocs.has(childTocKey)) {
context.diagnostics.push(
warning(
'onetoc2-tree-duplicate',
'A repeated notebook section-group reference was ignored.',
{ structure: childToc.extracted.normalizedPath }
)
);
continue;
}
result.push({
kind: 'section-group',
id: childPath,
displayName: leafName(childPath),
orderingId: entry.orderingId,
color: entry.color,
children: buildTocNodes({
...context,
parsed: childToc,
depth: context.depth + 1,
}),
});
continue;
}
context.diagnostics.push(
warning(
'onetoc2-entry-unresolved',
`TOC entry ${entry.filename} has no matching section or section group in the package.`,
{ structure: context.parsed.extracted.normalizedPath }
)
);
} catch (error) {
context.diagnostics.push(
tocChildFailureDiagnostic(
error,
context.parsed.extracted.normalizedPath,
entry.filename
)
);
}
}
return result;
} finally {
context.activeTocs.delete(tocKey);
}
}
function resolveSection(
context: BuildTocNodesContext,
childPath: string,
tocEntry: OneNoteTableOfContentsEntry
): OneNotePackagedSectionDto | undefined {
const expectedKey = pathKey(childPath);
const exact = context.sectionByPath.get(expectedKey);
if (exact) {
const identity = context.sectionIdentityByPath.get(expectedKey);
if (identity && identity !== tocEntry.fileIdentity) {
context.diagnostics.push(
warning(
'onetoc2-section-identity-mismatch',
`TOC identity for ${tocEntry.filename} does not match the section header; the package filename takes precedence.`,
{ structure: exact.path }
)
);
}
return exact;
}
const directory = parentPath(childPath);
const identityMatches = context.sections.filter(
(section) =>
pathKey(parentPath(section.path)) === pathKey(directory) &&
context.sectionIdentityByPath.get(pathKey(section.path)) ===
tocEntry.fileIdentity
);
if (identityMatches.length === 1) {
const match = identityMatches[0]!;
context.diagnostics.push(
warning(
'onetoc2-section-filename-mismatch',
`TOC filename ${tocEntry.filename} was resolved to package section ${leafName(match.path)} by FileIdentityGuid.`,
{ structure: context.parsed.extracted.normalizedPath }
)
);
return match;
}
return undefined;
}
function selectRootToc(
parsedTocs: ParsedTocEntry[],
diagnostics: ParserDiagnostic[]
): ParsedTocEntry | undefined {
if (parsedTocs.length === 0) return undefined;
const sorted = [...parsedTocs].sort((left, right) => {
const depth = pathDepth(left.directory) - pathDepth(right.directory);
return (
depth ||
left.extracted.normalizedPath.localeCompare(
right.extracted.normalizedPath
)
);
});
const minimumDepth = pathDepth(sorted[0]!.directory);
const candidates = sorted.filter(
(candidate) => pathDepth(candidate.directory) === minimumDepth
);
if (candidates.length > 1) {
diagnostics.push(
warning(
'onetoc2-root-ambiguous',
'More than one top-level table of contents was found; the first safe package path is used.',
{ structure: 'ONEPKG table of contents' }
)
);
}
return candidates[0];
}
export function resolveTocChildPath(
directory: string,
filename: string
): string {
if (
filename.length === 0 ||
filename === '.' ||
filename === '..' ||
/[\\/:]/u.test(filename) ||
[...filename].some((character) => {
const codePoint = character.codePointAt(0)!;
return codePoint <= 0x1f || codePoint === 0x7f;
})
) {
fail(
'onetoc2-child-path-unsafe',
`Unsafe FolderChildFilename value: ${filename}`,
{ structure: 'FolderChildFilename' }
);
}
const normalized = filename.normalize('NFC');
const path = directory ? `${directory}/${normalized}` : normalized;
if (
path.length > DEFAULT_ONEPKG_LIMITS.maxPathCharacters ||
pathDepth(path) > DEFAULT_ONEPKG_LIMITS.maxPathDepth
) {
fail(
'onetoc2-child-path-limit',
'FolderChildFilename exceeds package path limits.',
{ structure: 'FolderChildFilename' }
);
}
return path;
}
function readSectionIdentity(bytes: Uint8Array): string | undefined {
const detected = detectOneNoteFormat(bytes);
if (
detected.fileType !== ONE_SECTION_FILE_TYPE ||
(detected.kind !== 'desktop-one' && detected.kind !== 'fsshttp-one') ||
bytes.byteLength < 32
) {
return undefined;
}
return readGuid(new BinaryReader(bytes, 16, 16, 'OneStore Header.guidFile'));
}
function fallbackSectionNode(
section: OneNotePackagedSectionDto
): OneNoteNotebookNodeDto {
return {
kind: 'section',
id: section.id,
sectionId: section.id,
displayName: section.displayName,
};
}
function tocFailureDiagnostic(error: unknown, path: string): ParserDiagnostic {
return {
severity: 'error',
code: 'onetoc2-parse-failed',
message: `Could not interpret ${path}${error instanceof Error ? `: ${error.message}` : '.'}`,
offset: error instanceof OneNoteParserError ? error.offset : undefined,
structure:
error instanceof OneNoteParserError && error.structure
? `${path} · ${error.structure}`
: path,
recoverable: true,
};
}
function tocChildFailureDiagnostic(
error: unknown,
tocPath: string,
filename: string
): ParserDiagnostic {
return {
severity: 'warning',
code:
error instanceof OnePkgError
? error.diagnostic.code
: 'onetoc2-entry-failed',
message:
error instanceof Error
? error.message
: `TOC entry ${filename} could not be resolved.`,
structure: `${tocPath} · FolderChildFilename`,
recoverable: true,
};
}
function parentPath(path: string): string {
const slash = path.lastIndexOf('/');
return slash < 0 ? '' : path.slice(0, slash);
}
function leafName(path: string): string {
return path.slice(path.lastIndexOf('/') + 1);
}
function pathDepth(path: string): number {
return path ? path.split('/').length : 0;
}
function pathKey(path: string): string {
return path.normalize('NFC').toLowerCase();
}
function isRecycleBinName(name: string): boolean {
return name.toLowerCase() === 'onenote_recyclebin';
}
function isRecycleBinPath(path: string): boolean {
return path.split('/').some(isRecycleBinName);
}

View File

@@ -103,7 +103,7 @@ export function parseOneNoteTableOfContents(
return {
sourceName: options.sourceName ?? 'Open Notebook.onetoc2',
fileIdentity: store.fileIdentity,
color: optionalU32(root, TOC_PROPERTY.color),
color: optionalColor(root),
enableHistory: optionalBool(root, TOC_PROPERTY.enableHistory),
entries,
diagnostics,
@@ -151,7 +151,7 @@ function parseChildren(
filename: decodeFolderChildFilename(filename),
fileIdentity: requiredGuid(child, TOC_PROPERTY.fileIdentity),
orderingId: requiredU32(child, TOC_PROPERTY.orderingId),
color: optionalU32(child, TOC_PROPERTY.color),
color: optionalColor(child),
});
} else {
result.push(...parseChildren(context, child, depth + 1));
@@ -293,6 +293,11 @@ function optionalU32(
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