From 670310ea9e88e0c2d40f1cd060777d92cf10a7e7 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 19:05:49 +0200 Subject: [PATCH] feat: render OneNote notebook hierarchy --- src/App.test.tsx | 34 +- src/App.tsx | 36 +- src/components/SectionNavigation.test.tsx | 46 +++ src/components/SectionNavigation.tsx | 191 ++++++--- src/components/page-tree.ts | 29 ++ src/onenote/model/dto.ts | 27 ++ src/onenote/onepkg/joplin-fixture.test.ts | 17 + src/onenote/onepkg/package.ts | 8 + src/onenote/onepkg/toc-hierarchy.test.ts | 20 + src/onenote/onepkg/toc-hierarchy.ts | 476 ++++++++++++++++++++++ src/onenote/parser/parse-toc.ts | 9 +- src/styles.css | 41 +- src/worker/parser-adapter.test.ts | 17 +- src/worker/parser-adapter.ts | 39 +- 14 files changed, 887 insertions(+), 103 deletions(-) create mode 100644 src/components/SectionNavigation.test.tsx create mode 100644 src/components/page-tree.ts create mode 100644 src/onenote/onepkg/toc-hierarchy.test.ts create mode 100644 src/onenote/onepkg/toc-hierarchy.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index d689121..e623f96 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -169,6 +169,7 @@ describe('OneNoteApplication', () => { sourceSize: 1, entries: [], sections: [], + tree: { interpreted: false, nodes: [] }, diagnostics: [], }; render( @@ -237,11 +238,36 @@ describe('OneNoteApplication', () => { ], }, ], + tree: { + interpreted: true, + tocPath: 'Open Notebook.onetoc2', + nodes: [ + { + kind: 'section-group', + id: 'Notes', + displayName: 'Notes', + children: [ + { + kind: 'section', + id: 'readable', + sectionId: 'readable', + displayName: 'Readable', + }, + { + kind: 'section', + id: 'broken', + sectionId: 'broken', + displayName: 'Broken', + }, + ], + }, + ], + }, diagnostics: [ { severity: 'warning', - code: 'TOC_NOT_INTERPRETED', - message: 'Package path order is temporary.', + code: 'TEST_PACKAGE_WARNING', + message: 'Example recoverable package warning.', recoverable: true, }, ], @@ -263,10 +289,12 @@ describe('OneNoteApplication', () => { expect( await screen.findByRole('heading', { name: 'Package summary' }) ).toBeInTheDocument(); + expect(screen.getByText('Notes')).toBeVisible(); + expect(screen.getByText('OneNote notebook order')).toBeVisible(); expect( screen.getByRole('button', { name: /Broken, failed/i }) ).toBeVisible(); - expect(screen.getByText('TOC_NOT_INTERPRETED')).toBeInTheDocument(); + expect(screen.getByText('TEST_PACKAGE_WARNING')).toBeInTheDocument(); expect( await within( screen.getByRole('article', { name: 'First meeting' }) diff --git a/src/App.tsx b/src/App.tsx index 999b332..ed75957 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,9 @@ import { SingleSectionNavigation, } from './components/SectionNavigation.js'; import type { + OneNoteNotebookNodeDto, OneNotePackageDto, + OneNotePackagedSectionDto, OneNotePageDto, OneNoteSectionDto, } from './onenote/model/dto.js'; @@ -75,6 +77,32 @@ function uniqueDiagnostics( }); } +function sectionsInNotebookOrder( + notebook: OneNotePackageDto +): OneNotePackagedSectionDto[] { + const sectionById = new Map( + notebook.sections.map((section) => [section.id, section] as const) + ); + const ordered: OneNotePackagedSectionDto[] = []; + const seen = new Set(); + const visit = (nodes: OneNoteNotebookNodeDto[]) => { + for (const node of nodes) { + if (node.kind === 'section-group') { + visit(node.children); + continue; + } + const section = sectionById.get(node.sectionId); + if (section && !seen.has(section.id)) { + ordered.push(section); + seen.add(section.id); + } + } + }; + visit(notebook.tree.nodes); + ordered.push(...notebook.sections.filter((section) => !seen.has(section.id))); + return ordered; +} + export interface OneNoteApplicationProps { createWorkerClient?: () => OneNoteWorkerClient; } @@ -209,17 +237,18 @@ export function OneNoteApplication({ return; } + const orderedSections = sectionsInNotebookOrder(response.notebook); const preferredSection = - response.notebook.sections.find( + orderedSections.find( (section) => section.parseStatus === 'parsed' && section.section !== undefined && section.section.pages.length > 0 ) ?? - response.notebook.sections.find( + orderedSections.find( (section) => section.parseStatus === 'parsed' && section.section ) ?? - response.notebook.sections[0]; + orderedSections[0]; setSelectedSectionId(preferredSection?.id); setState({ phase: 'loaded', @@ -341,6 +370,7 @@ export function OneNoteApplication({ /> ) : ( { + it('turns authored page levels into parent/subpage relationships', () => { + expect( + buildPageTree([ + page('root', 1), + page('child', 2), + page('grandchild', 3), + page('next-root', 1), + page('skipped-level', 3), + ]) + ).toEqual([ + { + page: page('root', 1), + children: [ + { + page: page('child', 2), + children: [{ page: page('grandchild', 3), children: [] }], + }, + ], + }, + { + page: page('next-root', 1), + children: [{ page: page('skipped-level', 3), children: [] }], + }, + ]); + }); + + it('treats absent and invalid levels as top-level pages', () => { + expect( + buildPageTree([page('unset'), page('zero', 0), page('nan', Number.NaN)]) + ).toMatchObject([ + { page: { id: 'unset' }, children: [] }, + { page: { id: 'zero' }, children: [] }, + { page: { id: 'nan' }, children: [] }, + ]); + }); +}); diff --git a/src/components/SectionNavigation.tsx b/src/components/SectionNavigation.tsx index 4ae670e..ef8cc46 100644 --- a/src/components/SectionNavigation.tsx +++ b/src/components/SectionNavigation.tsx @@ -1,40 +1,69 @@ import type { + OneNoteNotebookNodeDto, + OneNoteNotebookTreeDto, OneNotePackagedSectionDto, OneNotePageSummaryDto, OneNoteSectionDto, } from '../onenote/model/dto.js'; +import { buildPageTree, type PageTreeNode } from './page-tree.js'; interface PageListProps { pages: OneNotePageSummaryDto[]; selectedPageId?: string; onSelectPage(pageId: string): void; + nested?: boolean; } -function PageList({ pages, selectedPageId, onSelectPage }: PageListProps) { +function PageTreeItems({ + nodes, + selectedPageId, + onSelectPage, +}: { + nodes: PageTreeNode[]; + selectedPageId?: string; + onSelectPage(pageId: string): void; +}) { + return nodes.map(({ page, children }) => ( +
  • + + {children.length > 0 ? ( +
      + +
    + ) : null} +
  • + )); +} + +function PageList({ + pages, + selectedPageId, + onSelectPage, + nested = false, +}: PageListProps) { if (pages.length === 0) { return

    No pages were extracted.

    ; } return ( -
      - {pages.map((page) => ( -
    • - -
    • - ))} +
        +
      ); } @@ -67,6 +96,7 @@ export function SingleSectionNavigation({ } interface PackageNavigationProps { + tree: OneNoteNotebookTreeDto; sections: OneNotePackagedSectionDto[]; selectedSectionId?: string; selectedPageId?: string; @@ -74,60 +104,109 @@ interface PackageNavigationProps { onSelectPage(pageId: string): void; } +function NotebookTreeItems({ + nodes, + sections, + selectedSectionId, + selectedPageId, + onSelectSection, + onSelectPage, +}: { + nodes: OneNoteNotebookNodeDto[]; + sections: ReadonlyMap; + selectedSectionId?: string; + selectedPageId?: string; + onSelectSection(sectionId: string): void; + onSelectPage(pageId: string): void; +}) { + return nodes.map((node) => { + if (node.kind === 'section-group') { + return ( +
    • +
      + {node.displayName} +
        + +
      +
      +
    • + ); + } + + const section = sections.get(node.sectionId); + if (!section) return null; + const selected = section.id === selectedSectionId; + return ( +
    • + + {selected && section.section ? ( + + ) : selected ? ( +

      + This section did not produce a readable page list. +

      + ) : null} +
    • + ); + }); +} + export function PackageNavigation({ + tree, sections, selectedSectionId, selectedPageId, onSelectSection, onSelectPage, }: PackageNavigationProps) { - const selected = sections.find((section) => section.id === selectedSectionId); + const sectionById = new Map( + sections.map((section) => [section.id, section] as const) + ); return ( ); } diff --git a/src/components/page-tree.ts b/src/components/page-tree.ts new file mode 100644 index 0000000..0246214 --- /dev/null +++ b/src/components/page-tree.ts @@ -0,0 +1,29 @@ +import type { OneNotePageSummaryDto } from '../onenote/model/dto.js'; + +export interface PageTreeNode { + page: OneNotePageSummaryDto; + children: PageTreeNode[]; +} + +/** Convert OneNote's flat authored page-level sequence to a bounded tree. */ +export function buildPageTree(pages: OneNotePageSummaryDto[]): PageTreeNode[] { + const roots: PageTreeNode[] = []; + const ancestors: PageTreeNode[] = []; + + for (const page of pages) { + const level = Number.isFinite(page.level) ? Math.trunc(page.level ?? 1) : 1; + const authoredDepth = Math.max(0, Math.min(level - 1, 8)); + // Attach a skipped level to the deepest parent that actually exists. + const depth = Math.min(authoredDepth, ancestors.length); + const node: PageTreeNode = { page, children: [] }; + if (depth === 0) { + roots.push(node); + } else { + ancestors[depth - 1]!.children.push(node); + } + ancestors[depth] = node; + ancestors.length = depth + 1; + } + + return roots; +} diff --git a/src/onenote/model/dto.ts b/src/onenote/model/dto.ts index 306bc96..3f732e1 100644 --- a/src/onenote/model/dto.ts +++ b/src/onenote/model/dto.ts @@ -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[]; } diff --git a/src/onenote/onepkg/joplin-fixture.test.ts b/src/onenote/onepkg/joplin-fixture.test.ts index f88b397..69f6713 100644 --- a/src/onenote/onepkg/joplin-fixture.test.ts +++ b/src/onenote/onepkg/joplin-fixture.test.ts @@ -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 () => { diff --git a/src/onenote/onepkg/package.ts b/src/onenote/onepkg/package.ts index 3fc9bf1..f6fdac9 100644 --- a/src/onenote/onepkg/package.ts +++ b/src/onenote/onepkg/package.ts @@ -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, diff --git a/src/onenote/onepkg/toc-hierarchy.test.ts b/src/onenote/onepkg/toc-hierarchy.test.ts new file mode 100644 index 0000000..78c64a7 --- /dev/null +++ b/src/onenote/onepkg/toc-hierarchy.test.ts @@ -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 + ); + } + ); +}); diff --git a/src/onenote/onepkg/toc-hierarchy.ts b/src/onenote/onepkg/toc-hierarchy.ts new file mode 100644 index 0000000..deed07b --- /dev/null +++ b/src/onenote/onepkg/toc-hierarchy.ts @@ -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(); + for (const [key, entry] of extractedSectionByPath) { + const identity = readSectionIdentity(entry.bytes); + if (identity) sectionIdentityByPath.set(key, identity); + } + const tocByDirectory = new Map(); + 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(); + 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; + sectionByPath: ReadonlyMap; + sectionIdentityByPath: ReadonlyMap; + sections: OneNotePackagedSectionDto[]; + usedSectionIds: Set; + diagnostics: ParserDiagnostic[]; + activeTocs: Set; + usedTocs: Set; + 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); +} diff --git a/src/onenote/parser/parse-toc.ts b/src/onenote/parser/parse-toc.ts index a7d15bf..23f34d8 100644 --- a/src/onenote/parser/parse-toc.ts +++ b/src/onenote/parser/parse-toc.ts @@ -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 diff --git a/src/styles.css b/src/styles.css index ad22066..4c87814 100644 --- a/src/styles.css +++ b/src/styles.css @@ -190,6 +190,7 @@ p { .page-list, .section-list, +.notebook-tree ul, .diagnostic-list { margin: 0; padding: 0; @@ -201,7 +202,7 @@ p { } .page-list button, -.section-list button { +.notebook-section > button { display: grid; gap: 0.25rem; width: 100%; @@ -223,7 +224,7 @@ p { } .page-list button.is-selected, -.section-list button.is-selected { +.notebook-section > button.is-selected { border-color: #af8bc6; color: #3e145c; background: #eee3f5; @@ -233,15 +234,39 @@ p { padding: 0.55rem 0.55rem 0; } -.section-list button { +.notebook-section > button { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } -.section-list + .page-list { - margin: 0.55rem; - border-top: 1px solid #ded5e3; - padding-top: 0.85rem; +.notebook-tree ul { + margin-inline-start: 0.7rem; + border-inline-start: 1px solid #ded5e3; + padding-inline-start: 0.45rem; +} + +.section-group summary { + cursor: pointer; + padding: 0.62rem 0.45rem; + color: #574b5b; + font-size: 0.86rem; + font-weight: 700; +} + +.notebook-section > .page-list { + margin: 0 0 0.4rem 0.7rem; + border-inline-start: 1px solid #ded5e3; + padding: 0.25rem 0 0.2rem 0.45rem; +} + +.page-list ul { + margin: 0; + border: 0; + padding-inline-start: 0.7rem; +} + +.page-list--nested button { + padding-block: 0.55rem; } .navigation-empty, @@ -582,7 +607,7 @@ p { .drop-zone--active, button:hover:not(:disabled), .page-list button.is-selected, - .section-list button.is-selected { + .notebook-section > button.is-selected { color: #f6effa; background: #3c2b48; } diff --git a/src/worker/parser-adapter.test.ts b/src/worker/parser-adapter.test.ts index 4a0e2f3..0a7a6b3 100644 --- a/src/worker/parser-adapter.test.ts +++ b/src/worker/parser-adapter.test.ts @@ -43,9 +43,20 @@ describe('worker parser adapter', () => { (section) => section.parseStatus === 'parsed' ) ).toBe(true); - expect( - result.notebook.diagnostics.map((diagnostic) => diagnostic.code) - ).toContain('PACKAGE_TOC_ORDER_NOT_INTERPRETED'); + expect(result.notebook.tree.interpreted).toBe(true); + expect(result.notebook.tree.nodes).toMatchObject([ + { 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 ⸩' }, + ]); for (const packagedSection of result.notebook.sections) { for (const page of packagedSection.section?.pages ?? []) { diff --git a/src/worker/parser-adapter.ts b/src/worker/parser-adapter.ts index a2a9b51..b90d491 100644 --- a/src/worker/parser-adapter.ts +++ b/src/worker/parser-adapter.ts @@ -83,39 +83,22 @@ export async function openPackageForWorker( }); const pages = new Map(); - const sections = [...result.package.sections] - .sort((left, right) => left.path.localeCompare(right.path)) - .map((packagedSection) => { - if (!packagedSection.section) return packagedSection; - const section = { - ...packagedSection.section, - pages: packagedSection.section.pages.map((page) => { - pages.set(workerPageKey(page.id, packagedSection.id), pageDto(page)); - return page; - }), - }; - return { ...packagedSection, section }; - }); + const sections = result.package.sections.map((packagedSection) => { + if (!packagedSection.section) return packagedSection; + const section = { + ...packagedSection.section, + pages: packagedSection.section.pages.map((page) => { + pages.set(workerPageKey(page.id, packagedSection.id), pageDto(page)); + return page; + }), + }; + return { ...packagedSection, section }; + }); return { notebook: { ...result.package, sections, - diagnostics: [ - ...result.package.diagnostics, - ...(sections.length > 0 - ? [ - { - severity: 'warning' as const, - code: 'PACKAGE_TOC_ORDER_NOT_INTERPRETED', - message: - 'OneNote notebook hierarchy and TOC ordering are not interpreted yet; sections are shown in normalized package-path order.', - structure: 'OneNote package section navigation', - recoverable: true, - }, - ] - : []), - ], }, pages, };