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

@@ -169,6 +169,7 @@ describe('OneNoteApplication', () => {
sourceSize: 1, sourceSize: 1,
entries: [], entries: [],
sections: [], sections: [],
tree: { interpreted: false, nodes: [] },
diagnostics: [], diagnostics: [],
}; };
render( 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: [ diagnostics: [
{ {
severity: 'warning', severity: 'warning',
code: 'TOC_NOT_INTERPRETED', code: 'TEST_PACKAGE_WARNING',
message: 'Package path order is temporary.', message: 'Example recoverable package warning.',
recoverable: true, recoverable: true,
}, },
], ],
@@ -263,10 +289,12 @@ describe('OneNoteApplication', () => {
expect( expect(
await screen.findByRole('heading', { name: 'Package summary' }) await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText('Notes')).toBeVisible();
expect(screen.getByText('OneNote notebook order')).toBeVisible();
expect( expect(
screen.getByRole('button', { name: /Broken, failed/i }) screen.getByRole('button', { name: /Broken, failed/i })
).toBeVisible(); ).toBeVisible();
expect(screen.getByText('TOC_NOT_INTERPRETED')).toBeInTheDocument(); expect(screen.getByText('TEST_PACKAGE_WARNING')).toBeInTheDocument();
expect( expect(
await within( await within(
screen.getByRole('article', { name: 'First meeting' }) screen.getByRole('article', { name: 'First meeting' })

View File

@@ -12,7 +12,9 @@ import {
SingleSectionNavigation, SingleSectionNavigation,
} from './components/SectionNavigation.js'; } from './components/SectionNavigation.js';
import type { import type {
OneNoteNotebookNodeDto,
OneNotePackageDto, OneNotePackageDto,
OneNotePackagedSectionDto,
OneNotePageDto, OneNotePageDto,
OneNoteSectionDto, OneNoteSectionDto,
} from './onenote/model/dto.js'; } 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<string>();
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 { export interface OneNoteApplicationProps {
createWorkerClient?: () => OneNoteWorkerClient; createWorkerClient?: () => OneNoteWorkerClient;
} }
@@ -209,17 +237,18 @@ export function OneNoteApplication({
return; return;
} }
const orderedSections = sectionsInNotebookOrder(response.notebook);
const preferredSection = const preferredSection =
response.notebook.sections.find( orderedSections.find(
(section) => (section) =>
section.parseStatus === 'parsed' && section.parseStatus === 'parsed' &&
section.section !== undefined && section.section !== undefined &&
section.section.pages.length > 0 section.section.pages.length > 0
) ?? ) ??
response.notebook.sections.find( orderedSections.find(
(section) => section.parseStatus === 'parsed' && section.section (section) => section.parseStatus === 'parsed' && section.section
) ?? ) ??
response.notebook.sections[0]; orderedSections[0];
setSelectedSectionId(preferredSection?.id); setSelectedSectionId(preferredSection?.id);
setState({ setState({
phase: 'loaded', phase: 'loaded',
@@ -341,6 +370,7 @@ export function OneNoteApplication({
/> />
) : ( ) : (
<PackageNavigation <PackageNavigation
tree={state.source.notebook.tree}
sections={state.source.notebook.sections} sections={state.source.notebook.sections}
selectedSectionId={selectedSectionId} selectedSectionId={selectedSectionId}
selectedPageId={selectedPageId} selectedPageId={selectedPageId}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import type { OneNotePageSummaryDto } from '../onenote/model/dto.js';
import { buildPageTree } from './page-tree.js';
function page(id: string, level?: number): OneNotePageSummaryDto {
return { id, level, title: id, text: '', textPreview: '' };
}
describe('section navigation trees', () => {
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: [] },
]);
});
});

View File

@@ -1,29 +1,30 @@
import type { import type {
OneNoteNotebookNodeDto,
OneNoteNotebookTreeDto,
OneNotePackagedSectionDto, OneNotePackagedSectionDto,
OneNotePageSummaryDto, OneNotePageSummaryDto,
OneNoteSectionDto, OneNoteSectionDto,
} from '../onenote/model/dto.js'; } from '../onenote/model/dto.js';
import { buildPageTree, type PageTreeNode } from './page-tree.js';
interface PageListProps { interface PageListProps {
pages: OneNotePageSummaryDto[]; pages: OneNotePageSummaryDto[];
selectedPageId?: string; selectedPageId?: string;
onSelectPage(pageId: string): void; onSelectPage(pageId: string): void;
nested?: boolean;
} }
function PageList({ pages, selectedPageId, onSelectPage }: PageListProps) { function PageTreeItems({
if (pages.length === 0) { nodes,
return <p className="navigation-empty">No pages were extracted.</p>; selectedPageId,
} onSelectPage,
}: {
return ( nodes: PageTreeNode[];
<ul className="page-list"> selectedPageId?: string;
{pages.map((page) => ( onSelectPage(pageId: string): void;
<li }) {
key={page.id} return nodes.map(({ page, children }) => (
style={{ <li key={page.id}>
paddingInlineStart: `${Math.min(page.level ?? 0, 8) * 0.7}rem`,
}}
>
<button <button
type="button" type="button"
className={page.id === selectedPageId ? 'is-selected' : undefined} className={page.id === selectedPageId ? 'is-selected' : undefined}
@@ -33,8 +34,36 @@ function PageList({ pages, selectedPageId, onSelectPage }: PageListProps) {
<strong>{page.title || 'Untitled page'}</strong> <strong>{page.title || 'Untitled page'}</strong>
{page.textPreview ? <span>{page.textPreview}</span> : null} {page.textPreview ? <span>{page.textPreview}</span> : null}
</button> </button>
{children.length > 0 ? (
<ul>
<PageTreeItems
nodes={children}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
</ul>
) : null}
</li> </li>
))} ));
}
function PageList({
pages,
selectedPageId,
onSelectPage,
nested = false,
}: PageListProps) {
if (pages.length === 0) {
return <p className="navigation-empty">No pages were extracted.</p>;
}
return (
<ul className={`page-list${nested ? ' page-list--nested' : ''}`}>
<PageTreeItems
nodes={buildPageTree(pages)}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
</ul> </ul>
); );
} }
@@ -67,6 +96,7 @@ export function SingleSectionNavigation({
} }
interface PackageNavigationProps { interface PackageNavigationProps {
tree: OneNoteNotebookTreeDto;
sections: OneNotePackagedSectionDto[]; sections: OneNotePackagedSectionDto[];
selectedSectionId?: string; selectedSectionId?: string;
selectedPageId?: string; selectedPageId?: string;
@@ -74,37 +104,52 @@ interface PackageNavigationProps {
onSelectPage(pageId: string): void; onSelectPage(pageId: string): void;
} }
export function PackageNavigation({ function NotebookTreeItems({
nodes,
sections, sections,
selectedSectionId, selectedSectionId,
selectedPageId, selectedPageId,
onSelectSection, onSelectSection,
onSelectPage, onSelectPage,
}: PackageNavigationProps) { }: {
const selected = sections.find((section) => section.id === selectedSectionId); nodes: OneNoteNotebookNodeDto[];
sections: ReadonlyMap<string, OneNotePackagedSectionDto>;
selectedSectionId?: string;
selectedPageId?: string;
onSelectSection(sectionId: string): void;
onSelectPage(pageId: string): void;
}) {
return nodes.map((node) => {
if (node.kind === 'section-group') {
return ( return (
<nav className="source-navigation" aria-label="Notebook sections and pages"> <li className="section-group" key={node.id}>
<div className="navigation-heading"> <details open>
<p className="eyebrow">Sections</p> <summary>{node.displayName}</summary>
<h2>Notebook contents</h2> <ul>
<p>Temporary normalized-path order</p> <NotebookTreeItems
</div> nodes={node.children}
{sections.length === 0 ? ( sections={sections}
<p className="navigation-empty">No section entries were found.</p> selectedSectionId={selectedSectionId}
) : ( selectedPageId={selectedPageId}
<ul className="section-list"> onSelectSection={onSelectSection}
{sections.map((section) => ( onSelectPage={onSelectPage}
<li key={section.id}> />
</ul>
</details>
</li>
);
}
const section = sections.get(node.sectionId);
if (!section) return null;
const selected = section.id === selectedSectionId;
return (
<li className="notebook-section" key={node.id}>
<button <button
type="button" type="button"
aria-label={`${section.displayName}, ${section.parseStatus}`} aria-label={`${section.displayName}, ${section.parseStatus}`}
className={ className={selected ? 'is-selected' : undefined}
section.id === selectedSectionId ? 'is-selected' : undefined aria-current={selected ? 'true' : undefined}
}
aria-current={
section.id === selectedSectionId ? 'true' : undefined
}
onClick={() => onSelectSection(section.id)} onClick={() => onSelectSection(section.id)}
> >
<strong>{section.displayName}</strong> <strong>{section.displayName}</strong>
@@ -112,22 +157,56 @@ export function PackageNavigation({
{section.parseStatus} {section.parseStatus}
</span> </span>
</button> </button>
</li> {selected && section.section ? (
))}
</ul>
)}
{selected?.section ? (
<PageList <PageList
pages={selected.section.pages} pages={section.section.pages}
selectedPageId={selectedPageId} selectedPageId={selectedPageId}
onSelectPage={onSelectPage} onSelectPage={onSelectPage}
nested
/> />
) : selected ? ( ) : selected ? (
<p className="navigation-empty"> <p className="navigation-empty">
This section did not produce a readable page list. This section did not produce a readable page list.
</p> </p>
) : null} ) : null}
</li>
);
});
}
export function PackageNavigation({
tree,
sections,
selectedSectionId,
selectedPageId,
onSelectSection,
onSelectPage,
}: PackageNavigationProps) {
const sectionById = new Map(
sections.map((section) => [section.id, section] as const)
);
return (
<nav className="source-navigation" aria-label="Notebook sections and pages">
<div className="navigation-heading">
<p className="eyebrow">Sections</p>
<h2>Notebook contents</h2>
<p>{tree.interpreted ? 'OneNote notebook order' : 'Package order'}</p>
</div>
{tree.nodes.length === 0 ? (
<p className="navigation-empty">No section entries were found.</p>
) : (
<ul className="notebook-tree section-list">
<NotebookTreeItems
nodes={tree.nodes}
sections={sectionById}
selectedSectionId={selectedSectionId}
selectedPageId={selectedPageId}
onSelectSection={onSelectSection}
onSelectPage={onSelectPage}
/>
</ul>
)}
</nav> </nav>
); );
} }

View File

@@ -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;
}

View File

@@ -49,12 +49,39 @@ export interface OneNotePackageEntryDto {
parseStatus: 'not-requested' | 'parsed' | 'unsupported' | 'failed'; 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 { export interface OneNotePackageDto {
format: 'onepkg'; format: 'onepkg';
sourceName: string; sourceName: string;
sourceSize: number; sourceSize: number;
entries: OneNotePackageEntryDto[]; entries: OneNotePackageEntryDto[];
sections: OneNotePackagedSectionDto[]; sections: OneNotePackagedSectionDto[];
tree: OneNoteNotebookTreeDto;
diagnostics: ParserDiagnostic[]; diagnostics: ParserDiagnostic[];
} }

View File

@@ -84,6 +84,23 @@ describe('pinned Joplin .onepkg fixture', () => {
(section) => section.parseStatus === 'not-requested' (section) => section.parseStatus === 'not-requested'
) )
).toBe(true); ).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 () => { 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 { checkCancellation } from './cancellation.js';
import { extractCabinet } from './cabinet.js'; import { extractCabinet } from './cabinet.js';
import { diagnosticFromUnknown, OnePkgError, warning } from './errors.js'; import { diagnosticFromUnknown, OnePkgError, warning } from './errors.js';
import { buildOneNotePackageTree } from './toc-hierarchy.js';
import type { import type {
CabOpenOptions, CabOpenOptions,
ExtractedCabFile, ExtractedCabFile,
@@ -162,6 +163,12 @@ export async function openOneNotePackage(
}); });
} }
const treeResult = buildOneNotePackageTree(
extraction.extractedEntries,
packagedSections
);
diagnostics.push(...treeResult.diagnostics);
return { return {
package: { package: {
format: 'onepkg', format: 'onepkg',
@@ -169,6 +176,7 @@ export async function openOneNotePackage(
sourceSize: bytes.byteLength, sourceSize: bytes.byteLength,
entries: packageEntries, entries: packageEntries,
sections: packagedSections, sections: packagedSections,
tree: treeResult.tree,
diagnostics, diagnostics,
}, },
extractedEntries: extraction.extractedEntries, 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 { return {
sourceName: options.sourceName ?? 'Open Notebook.onetoc2', sourceName: options.sourceName ?? 'Open Notebook.onetoc2',
fileIdentity: store.fileIdentity, fileIdentity: store.fileIdentity,
color: optionalU32(root, TOC_PROPERTY.color), color: optionalColor(root),
enableHistory: optionalBool(root, TOC_PROPERTY.enableHistory), enableHistory: optionalBool(root, TOC_PROPERTY.enableHistory),
entries, entries,
diagnostics, diagnostics,
@@ -151,7 +151,7 @@ function parseChildren(
filename: decodeFolderChildFilename(filename), filename: decodeFolderChildFilename(filename),
fileIdentity: requiredGuid(child, TOC_PROPERTY.fileIdentity), fileIdentity: requiredGuid(child, TOC_PROPERTY.fileIdentity),
orderingId: requiredU32(child, TOC_PROPERTY.orderingId), orderingId: requiredU32(child, TOC_PROPERTY.orderingId),
color: optionalU32(child, TOC_PROPERTY.color), color: optionalColor(child),
}); });
} else { } else {
result.push(...parseChildren(context, child, depth + 1)); result.push(...parseChildren(context, child, depth + 1));
@@ -293,6 +293,11 @@ function optionalU32(
return value.value; return value.value;
} }
function optionalColor(object: StoreObject): number | undefined {
const color = optionalU32(object, TOC_PROPERTY.color);
return color === 0xffff_ffff ? undefined : color;
}
function optionalBool( function optionalBool(
object: StoreObject, object: StoreObject,
propertyId: number propertyId: number

View File

@@ -190,6 +190,7 @@ p {
.page-list, .page-list,
.section-list, .section-list,
.notebook-tree ul,
.diagnostic-list { .diagnostic-list {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -201,7 +202,7 @@ p {
} }
.page-list button, .page-list button,
.section-list button { .notebook-section > button {
display: grid; display: grid;
gap: 0.25rem; gap: 0.25rem;
width: 100%; width: 100%;
@@ -223,7 +224,7 @@ p {
} }
.page-list button.is-selected, .page-list button.is-selected,
.section-list button.is-selected { .notebook-section > button.is-selected {
border-color: #af8bc6; border-color: #af8bc6;
color: #3e145c; color: #3e145c;
background: #eee3f5; background: #eee3f5;
@@ -233,15 +234,39 @@ p {
padding: 0.55rem 0.55rem 0; padding: 0.55rem 0.55rem 0;
} }
.section-list button { .notebook-section > button {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
} }
.section-list + .page-list { .notebook-tree ul {
margin: 0.55rem; margin-inline-start: 0.7rem;
border-top: 1px solid #ded5e3; border-inline-start: 1px solid #ded5e3;
padding-top: 0.85rem; 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, .navigation-empty,
@@ -582,7 +607,7 @@ p {
.drop-zone--active, .drop-zone--active,
button:hover:not(:disabled), button:hover:not(:disabled),
.page-list button.is-selected, .page-list button.is-selected,
.section-list button.is-selected { .notebook-section > button.is-selected {
color: #f6effa; color: #f6effa;
background: #3c2b48; background: #3c2b48;
} }

View File

@@ -43,9 +43,20 @@ describe('worker parser adapter', () => {
(section) => section.parseStatus === 'parsed' (section) => section.parseStatus === 'parsed'
) )
).toBe(true); ).toBe(true);
expect( expect(result.notebook.tree.interpreted).toBe(true);
result.notebook.diagnostics.map((diagnostic) => diagnostic.code) expect(result.notebook.tree.nodes).toMatchObject([
).toContain('PACKAGE_TOC_ORDER_NOT_INTERPRETED'); { 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 packagedSection of result.notebook.sections) {
for (const page of packagedSection.section?.pages ?? []) { for (const page of packagedSection.section?.pages ?? []) {

View File

@@ -83,9 +83,7 @@ export async function openPackageForWorker(
}); });
const pages = new Map<string, OneNotePageDto>(); const pages = new Map<string, OneNotePageDto>();
const sections = [...result.package.sections] const sections = result.package.sections.map((packagedSection) => {
.sort((left, right) => left.path.localeCompare(right.path))
.map((packagedSection) => {
if (!packagedSection.section) return packagedSection; if (!packagedSection.section) return packagedSection;
const section = { const section = {
...packagedSection.section, ...packagedSection.section,
@@ -101,21 +99,6 @@ export async function openPackageForWorker(
notebook: { notebook: {
...result.package, ...result.package,
sections, 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, pages,
}; };