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

@@ -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,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 }) => (
<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) {
return <p className="navigation-empty">No pages were extracted.</p>;
}
return (
<ul className="page-list">
{pages.map((page) => (
<li
key={page.id}
style={{
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 className={`page-list${nested ? ' page-list--nested' : ''}`}>
<PageTreeItems
nodes={buildPageTree(pages)}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
</ul>
);
}
@@ -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<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({
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 (
<nav className="source-navigation" aria-label="Notebook sections and pages">
<div className="navigation-heading">
<p className="eyebrow">Sections</p>
<h2>Notebook contents</h2>
<p>Temporary normalized-path order</p>
<p>{tree.interpreted ? 'OneNote notebook order' : 'Package order'}</p>
</div>
{sections.length === 0 ? (
{tree.nodes.length === 0 ? (
<p className="navigation-empty">No section entries were found.</p>
) : (
<ul className="section-list">
{sections.map((section) => (
<li key={section.id}>
<button
type="button"
aria-label={`${section.displayName}, ${section.parseStatus}`}
className={
section.id === selectedSectionId ? 'is-selected' : undefined
}
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 className="notebook-tree section-list">
<NotebookTreeItems
nodes={tree.nodes}
sections={sectionById}
selectedSectionId={selectedSectionId}
selectedPageId={selectedPageId}
onSelectSection={onSelectSection}
onSelectPage={onSelectPage}
/>
</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>
);
}

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