feat: render OneNote notebook hierarchy
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
src/onenote/onepkg/toc-hierarchy.test.ts
Normal file
20
src/onenote/onepkg/toc-hierarchy.test.ts
Normal 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
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
476
src/onenote/onepkg/toc-hierarchy.ts
Normal file
476
src/onenote/onepkg/toc-hierarchy.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user