feat: render OneNote notebook hierarchy
This commit is contained in:
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 {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user