feat: render OneNote notebook hierarchy
This commit is contained in:
@@ -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' })
|
||||||
|
|||||||
36
src/App.tsx
36
src/App.tsx
@@ -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}
|
||||||
|
|||||||
46
src/components/SectionNavigation.test.tsx
Normal file
46
src/components/SectionNavigation.test.tsx
Normal 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: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,40 +1,69 @@
|
|||||||
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({
|
||||||
|
nodes,
|
||||||
|
selectedPageId,
|
||||||
|
onSelectPage,
|
||||||
|
}: {
|
||||||
|
nodes: PageTreeNode[];
|
||||||
|
selectedPageId?: string;
|
||||||
|
onSelectPage(pageId: string): void;
|
||||||
|
}) {
|
||||||
|
return nodes.map(({ page, children }) => (
|
||||||
|
<li key={page.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={page.id === selectedPageId ? 'is-selected' : undefined}
|
||||||
|
aria-current={page.id === selectedPageId ? 'page' : undefined}
|
||||||
|
onClick={() => onSelectPage(page.id)}
|
||||||
|
>
|
||||||
|
<strong>{page.title || 'Untitled page'}</strong>
|
||||||
|
{page.textPreview ? <span>{page.textPreview}</span> : null}
|
||||||
|
</button>
|
||||||
|
{children.length > 0 ? (
|
||||||
|
<ul>
|
||||||
|
<PageTreeItems
|
||||||
|
nodes={children}
|
||||||
|
selectedPageId={selectedPageId}
|
||||||
|
onSelectPage={onSelectPage}
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageList({
|
||||||
|
pages,
|
||||||
|
selectedPageId,
|
||||||
|
onSelectPage,
|
||||||
|
nested = false,
|
||||||
|
}: PageListProps) {
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
return <p className="navigation-empty">No pages were extracted.</p>;
|
return <p className="navigation-empty">No pages were extracted.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="page-list">
|
<ul className={`page-list${nested ? ' page-list--nested' : ''}`}>
|
||||||
{pages.map((page) => (
|
<PageTreeItems
|
||||||
<li
|
nodes={buildPageTree(pages)}
|
||||||
key={page.id}
|
selectedPageId={selectedPageId}
|
||||||
style={{
|
onSelectPage={onSelectPage}
|
||||||
paddingInlineStart: `${Math.min(page.level ?? 0, 8) * 0.7}rem`,
|
/>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={page.id === selectedPageId ? 'is-selected' : undefined}
|
|
||||||
aria-current={page.id === selectedPageId ? 'page' : undefined}
|
|
||||||
onClick={() => onSelectPage(page.id)}
|
|
||||||
>
|
|
||||||
<strong>{page.title || 'Untitled page'}</strong>
|
|
||||||
{page.textPreview ? <span>{page.textPreview}</span> : null}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</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,60 +104,109 @@ interface PackageNavigationProps {
|
|||||||
onSelectPage(pageId: string): void;
|
onSelectPage(pageId: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotebookTreeItems({
|
||||||
|
nodes,
|
||||||
|
sections,
|
||||||
|
selectedSectionId,
|
||||||
|
selectedPageId,
|
||||||
|
onSelectSection,
|
||||||
|
onSelectPage,
|
||||||
|
}: {
|
||||||
|
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 (
|
||||||
|
<li className="section-group" key={node.id}>
|
||||||
|
<details open>
|
||||||
|
<summary>{node.displayName}</summary>
|
||||||
|
<ul>
|
||||||
|
<NotebookTreeItems
|
||||||
|
nodes={node.children}
|
||||||
|
sections={sections}
|
||||||
|
selectedSectionId={selectedSectionId}
|
||||||
|
selectedPageId={selectedPageId}
|
||||||
|
onSelectSection={onSelectSection}
|
||||||
|
onSelectPage={onSelectPage}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
type="button"
|
||||||
|
aria-label={`${section.displayName}, ${section.parseStatus}`}
|
||||||
|
className={selected ? 'is-selected' : undefined}
|
||||||
|
aria-current={selected ? 'true' : undefined}
|
||||||
|
onClick={() => onSelectSection(section.id)}
|
||||||
|
>
|
||||||
|
<strong>{section.displayName}</strong>
|
||||||
|
<span className={`status status--${section.parseStatus}`}>
|
||||||
|
{section.parseStatus}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{selected && section.section ? (
|
||||||
|
<PageList
|
||||||
|
pages={section.section.pages}
|
||||||
|
selectedPageId={selectedPageId}
|
||||||
|
onSelectPage={onSelectPage}
|
||||||
|
nested
|
||||||
|
/>
|
||||||
|
) : selected ? (
|
||||||
|
<p className="navigation-empty">
|
||||||
|
This section did not produce a readable page list.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function PackageNavigation({
|
export function PackageNavigation({
|
||||||
|
tree,
|
||||||
sections,
|
sections,
|
||||||
selectedSectionId,
|
selectedSectionId,
|
||||||
selectedPageId,
|
selectedPageId,
|
||||||
onSelectSection,
|
onSelectSection,
|
||||||
onSelectPage,
|
onSelectPage,
|
||||||
}: PackageNavigationProps) {
|
}: PackageNavigationProps) {
|
||||||
const selected = sections.find((section) => section.id === selectedSectionId);
|
const sectionById = new Map(
|
||||||
|
sections.map((section) => [section.id, section] as const)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="source-navigation" aria-label="Notebook sections and pages">
|
<nav className="source-navigation" aria-label="Notebook sections and pages">
|
||||||
<div className="navigation-heading">
|
<div className="navigation-heading">
|
||||||
<p className="eyebrow">Sections</p>
|
<p className="eyebrow">Sections</p>
|
||||||
<h2>Notebook contents</h2>
|
<h2>Notebook contents</h2>
|
||||||
<p>Temporary normalized-path order</p>
|
<p>{tree.interpreted ? 'OneNote notebook order' : 'Package order'}</p>
|
||||||
</div>
|
</div>
|
||||||
{sections.length === 0 ? (
|
{tree.nodes.length === 0 ? (
|
||||||
<p className="navigation-empty">No section entries were found.</p>
|
<p className="navigation-empty">No section entries were found.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="section-list">
|
<ul className="notebook-tree section-list">
|
||||||
{sections.map((section) => (
|
<NotebookTreeItems
|
||||||
<li key={section.id}>
|
nodes={tree.nodes}
|
||||||
<button
|
sections={sectionById}
|
||||||
type="button"
|
selectedSectionId={selectedSectionId}
|
||||||
aria-label={`${section.displayName}, ${section.parseStatus}`}
|
selectedPageId={selectedPageId}
|
||||||
className={
|
onSelectSection={onSelectSection}
|
||||||
section.id === selectedSectionId ? 'is-selected' : undefined
|
onSelectPage={onSelectPage}
|
||||||
}
|
/>
|
||||||
aria-current={
|
|
||||||
section.id === selectedSectionId ? 'true' : undefined
|
|
||||||
}
|
|
||||||
onClick={() => onSelectSection(section.id)}
|
|
||||||
>
|
|
||||||
<strong>{section.displayName}</strong>
|
|
||||||
<span className={`status status--${section.parseStatus}`}>
|
|
||||||
{section.parseStatus}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected?.section ? (
|
|
||||||
<PageList
|
|
||||||
pages={selected.section.pages}
|
|
||||||
selectedPageId={selectedPageId}
|
|
||||||
onSelectPage={onSelectPage}
|
|
||||||
/>
|
|
||||||
) : selected ? (
|
|
||||||
<p className="navigation-empty">
|
|
||||||
This section did not produce a readable page list.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/components/page-tree.ts
Normal file
29
src/components/page-tree.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? []) {
|
||||||
|
|||||||
@@ -83,39 +83,22 @@ 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))
|
if (!packagedSection.section) return packagedSection;
|
||||||
.map((packagedSection) => {
|
const section = {
|
||||||
if (!packagedSection.section) return packagedSection;
|
...packagedSection.section,
|
||||||
const section = {
|
pages: packagedSection.section.pages.map((page) => {
|
||||||
...packagedSection.section,
|
pages.set(workerPageKey(page.id, packagedSection.id), pageDto(page));
|
||||||
pages: packagedSection.section.pages.map((page) => {
|
return page;
|
||||||
pages.set(workerPageKey(page.id, packagedSection.id), pageDto(page));
|
}),
|
||||||
return page;
|
};
|
||||||
}),
|
return { ...packagedSection, section };
|
||||||
};
|
});
|
||||||
return { ...packagedSection, section };
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user