fix: bound navigation and inspector rendering

This commit is contained in:
2026-07-22 20:12:52 +02:00
parent e44ff78511
commit 141a06dbf3
5 changed files with 692 additions and 48 deletions

View File

@@ -1,8 +1,16 @@
import { useState } from 'react';
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
import {
DEFAULT_UI_RENDER_LIMITS,
limitUiString,
resolveUiLimit,
} from './ui-render-budget.js';
interface DiagnosticsPanelProps {
diagnostics: ParserDiagnostic[];
title?: string;
pageSize?: number;
}
function formatOffset(offset: number): string {
@@ -12,13 +20,69 @@ function formatOffset(offset: number): string {
export function DiagnosticsPanel({
diagnostics,
title = 'Diagnostics',
pageSize: requestedPageSize,
}: DiagnosticsPanelProps) {
const [requestedPage, setRequestedPage] = useState(0);
const pageSize = resolveUiLimit(
requestedPageSize,
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticsPerPage
);
const pageCount = Math.max(1, Math.ceil(diagnostics.length / pageSize));
const page = Math.min(requestedPage, pageCount - 1);
const firstDiagnostic = page * pageSize;
const limitedTitle = limitUiString(
title,
DEFAULT_UI_RENDER_LIMITS.maxPanelTitleCharacters
);
const displayedDiagnostics = diagnostics
.slice(firstDiagnostic, firstDiagnostic + pageSize)
.map((diagnostic, index) => {
const code = limitUiString(
diagnostic.code,
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticCodeCharacters
);
const message = limitUiString(
diagnostic.message,
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticMessageCharacters
);
const structure =
diagnostic.structure === undefined
? undefined
: limitUiString(
diagnostic.structure,
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticContextCharacters
);
const propertyId =
diagnostic.propertyId === undefined
? undefined
: limitUiString(
diagnostic.propertyId,
DEFAULT_UI_RENDER_LIMITS.maxDiagnosticContextCharacters
);
return {
diagnostic,
index: firstDiagnostic + index,
code: code.value,
message: message.value,
structure: structure?.value,
propertyId: propertyId?.value,
truncated:
code.truncated ||
message.truncated ||
structure?.truncated === true ||
propertyId?.truncated === true,
};
});
const textTruncated =
limitedTitle.truncated ||
displayedDiagnostics.some((diagnostic) => diagnostic.truncated);
return (
<section className="diagnostics" aria-labelledby="diagnostics-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Parser details</p>
<h2 id="diagnostics-title">{title}</h2>
<h2 id="diagnostics-title">{limitedTitle.value}</h2>
</div>
<span className="count-badge">{diagnostics.length}</span>
</div>
@@ -27,36 +91,36 @@ export function DiagnosticsPanel({
<p className="empty-message">No parser diagnostics for this view.</p>
) : (
<ul className="diagnostic-list">
{diagnostics.map((diagnostic, index) => (
{displayedDiagnostics.map((displayed) => (
<li
className={`diagnostic diagnostic--${diagnostic.severity}`}
key={`${diagnostic.code}-${diagnostic.offset ?? 'none'}-${index}`}
className={`diagnostic diagnostic--${displayed.diagnostic.severity}`}
key={displayed.index}
>
<div className="diagnostic__summary">
<strong>{diagnostic.code}</strong>
<span>{diagnostic.severity}</span>
<strong>{displayed.code}</strong>
<span>{displayed.diagnostic.severity}</span>
</div>
<p>{diagnostic.message}</p>
{diagnostic.structure !== undefined ||
diagnostic.offset !== undefined ||
diagnostic.propertyId !== undefined ? (
<p>{displayed.message}</p>
{displayed.structure !== undefined ||
displayed.diagnostic.offset !== undefined ||
displayed.propertyId !== undefined ? (
<dl className="diagnostic__context">
{diagnostic.structure !== undefined ? (
{displayed.structure !== undefined ? (
<>
<dt>Structure</dt>
<dd>{diagnostic.structure}</dd>
<dd>{displayed.structure}</dd>
</>
) : null}
{diagnostic.offset !== undefined ? (
{displayed.diagnostic.offset !== undefined ? (
<>
<dt>Offset</dt>
<dd>{formatOffset(diagnostic.offset)}</dd>
<dd>{formatOffset(displayed.diagnostic.offset)}</dd>
</>
) : null}
{diagnostic.propertyId !== undefined ? (
{displayed.propertyId !== undefined ? (
<>
<dt>Property</dt>
<dd>{diagnostic.propertyId}</dd>
<dd>{displayed.propertyId}</dd>
</>
) : null}
</dl>
@@ -65,6 +129,41 @@ export function DiagnosticsPanel({
))}
</ul>
)}
{diagnostics.length > 0 ? (
<nav className="panel-heading" aria-label="Diagnostic pages">
<p>
Showing diagnostics {firstDiagnostic + 1}
{firstDiagnostic + displayedDiagnostics.length} of{' '}
{diagnostics.length}.
</p>
<div>
<button
type="button"
disabled={page === 0}
onClick={() => setRequestedPage(Math.max(0, page - 1))}
>
Previous
</button>{' '}
<span>
Page {page + 1} of {pageCount}
</span>{' '}
<button
type="button"
disabled={page + 1 >= pageCount}
onClick={() =>
setRequestedPage(Math.min(pageCount - 1, page + 1))
}
>
Next
</button>
</div>
</nav>
) : null}
{textTruncated ? (
<p className="render-limit-notice" role="status">
Long diagnostic text was shortened for safe browser display.
</p>
) : null}
</section>
);
}

View File

@@ -1,7 +1,15 @@
import { useState } from 'react';
import type { OneNotePackageDto } from '../onenote/model/dto.js';
import {
DEFAULT_UI_RENDER_LIMITS,
limitUiString,
resolveUiLimit,
} from './ui-render-budget.js';
interface PackageInspectorProps {
notebook: OneNotePackageDto;
pageSize?: number;
}
function formatBytes(value: number): string {
@@ -16,14 +24,56 @@ function formatBytes(value: number): string {
return `${amount.toFixed(amount >= 10 ? 1 : 2)} ${unit}`;
}
export function PackageInspector({ notebook }: PackageInspectorProps) {
const totalExpanded = notebook.entries.reduce(
(total, entry) => total + entry.uncompressedSize,
0
export function PackageInspector({
notebook,
pageSize: requestedPageSize,
}: PackageInspectorProps) {
const [requestedPage, setRequestedPage] = useState(0);
const pageSize = resolveUiLimit(
requestedPageSize,
DEFAULT_UI_RENDER_LIMITS.maxPackageEntriesPerPage
);
const sectionCount = notebook.entries.filter(
(entry) => entry.kind === 'section'
).length;
const pageCount = Math.max(1, Math.ceil(notebook.entries.length / pageSize));
const page = Math.min(requestedPage, pageCount - 1);
const firstEntry = page * pageSize;
const displayedEntries = notebook.entries
.slice(firstEntry, firstEntry + pageSize)
.map((entry, index) => {
const normalizedPath = limitUiString(
entry.normalizedPath,
DEFAULT_UI_RENDER_LIMITS.maxPackagePathCharacters
);
const originalPath = limitUiString(
entry.path,
DEFAULT_UI_RENDER_LIMITS.maxPackagePathCharacters
);
const compressionMethod = limitUiString(
entry.compressionMethod || 'unknown',
DEFAULT_UI_RENDER_LIMITS.maxPackageMethodCharacters
);
return {
entry,
index: firstEntry + index,
normalizedPath: normalizedPath.value,
originalPath: originalPath.value,
compressionMethod: compressionMethod.value,
truncated:
normalizedPath.truncated ||
originalPath.truncated ||
compressionMethod.truncated,
};
});
const textTruncated = displayedEntries.some((entry) => entry.truncated);
let totalExpanded = 0;
let sectionCount = 0;
for (const entry of notebook.entries) {
if (entry.kind === 'section') sectionCount += 1;
const size = Number.isFinite(entry.uncompressedSize)
? Math.max(0, entry.uncompressedSize)
: 0;
totalExpanded = Math.min(Number.MAX_SAFE_INTEGER, totalExpanded + size);
}
return (
<section className="package-inspector" aria-labelledby="package-title">
@@ -66,20 +116,22 @@ export function PackageInspector({ notebook }: PackageInspectorProps) {
</tr>
</thead>
<tbody>
{notebook.entries.map((entry) => (
<tr key={entry.normalizedPath}>
{displayedEntries.map((displayed) => (
<tr key={displayed.index}>
<td>
<code>{entry.normalizedPath}</code>
{entry.path !== entry.normalizedPath ? (
<small>Original: {entry.path}</small>
<code>{displayed.normalizedPath}</code>
{displayed.entry.path !== displayed.entry.normalizedPath ? (
<small>Original: {displayed.originalPath}</small>
) : null}
</td>
<td>{entry.kind}</td>
<td>{formatBytes(entry.uncompressedSize)}</td>
<td>{entry.compressionMethod || 'unknown'}</td>
<td>{displayed.entry.kind}</td>
<td>{formatBytes(displayed.entry.uncompressedSize)}</td>
<td>{displayed.compressionMethod}</td>
<td>
<span className={`status status--${entry.parseStatus}`}>
{entry.parseStatus}
<span
className={`status status--${displayed.entry.parseStatus}`}
>
{displayed.entry.parseStatus}
</span>
</td>
</tr>
@@ -87,6 +139,42 @@ export function PackageInspector({ notebook }: PackageInspectorProps) {
</tbody>
</table>
</div>
{notebook.entries.length > 0 ? (
<nav className="panel-heading" aria-label="Package entry pages">
<p>
Showing entries {firstEntry + 1}
{firstEntry + displayedEntries.length} of{' '}
{notebook.entries.length}.
</p>
<div>
<button
type="button"
disabled={page === 0}
onClick={() => setRequestedPage(Math.max(0, page - 1))}
>
Previous
</button>{' '}
<span>
Page {page + 1} of {pageCount}
</span>{' '}
<button
type="button"
disabled={page + 1 >= pageCount}
onClick={() =>
setRequestedPage(Math.min(pageCount - 1, page + 1))
}
>
Next
</button>
</div>
</nav>
) : null}
{textTruncated ? (
<p className="render-limit-notice" role="status">
Long package paths or compression labels were shortened for safe
browser display.
</p>
) : null}
</details>
</section>
);

View File

@@ -6,12 +6,21 @@ import type {
OneNoteSectionDto,
} from '../onenote/model/dto.js';
import { buildPageTree, type PageTreeNode } from './page-tree.js';
import {
DEFAULT_UI_RENDER_LIMITS,
limitUiString,
prepareNotebookForNavigation,
preparePagesForNavigation,
resolveUiLimit,
type NavigationRenderLimits,
} from './ui-render-budget.js';
interface PageListProps {
pages: OneNotePageSummaryDto[];
selectedPageId?: string;
onSelectPage(pageId: string): void;
nested?: boolean;
renderLimits?: Partial<NavigationRenderLimits>;
}
function PageTreeItems({
@@ -52,19 +61,31 @@ function PageList({
selectedPageId,
onSelectPage,
nested = false,
renderLimits,
}: PageListProps) {
if (pages.length === 0) {
return <p className="navigation-empty">No pages were extracted.</p>;
}
const projection = preparePagesForNavigation(pages, renderLimits);
return (
<ul className={`page-list${nested ? ' page-list--nested' : ''}`}>
<PageTreeItems
nodes={buildPageTree(pages)}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
</ul>
<>
<ul className={`page-list${nested ? ' page-list--nested' : ''}`}>
<PageTreeItems
nodes={buildPageTree(projection.pages)}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
/>
</ul>
{projection.truncated ? (
<p className="render-limit-notice" role="status">
{projection.pages.length < pages.length
? `Navigation display is limited to the first ${projection.pages.length} of ${pages.length} pages. Long titles and previews may also be shortened.`
: 'Long page titles or previews were shortened for safe browser display.'}
</p>
) : null}
</>
);
}
@@ -72,24 +93,41 @@ interface SingleSectionNavigationProps {
section: OneNoteSectionDto;
selectedPageId?: string;
onSelectPage(pageId: string): void;
renderLimits?: Partial<NavigationRenderLimits>;
}
export function SingleSectionNavigation({
section,
selectedPageId,
onSelectPage,
renderLimits,
}: SingleSectionNavigationProps) {
const titleLimit = resolveUiLimit(
renderLimits?.maxNavigationTitleCharacters,
DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
);
const heading = limitUiString(
section.sectionName || section.sourceName,
titleLimit
);
return (
<nav className="source-navigation" aria-label="Section pages">
<div className="navigation-heading">
<p className="eyebrow">Section</p>
<h2>{section.sectionName || section.sourceName}</h2>
<h2>{heading.value}</h2>
<p>{section.pages.length} pages</p>
</div>
{heading.truncated ? (
<p className="render-limit-notice" role="status">
The section name was shortened for safe browser display.
</p>
) : null}
<PageList
pages={section.pages}
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
renderLimits={renderLimits}
/>
</nav>
);
@@ -102,6 +140,7 @@ interface PackageNavigationProps {
selectedPageId?: string;
onSelectSection(sectionId: string): void;
onSelectPage(pageId: string): void;
renderLimits?: Partial<NavigationRenderLimits>;
}
function NotebookTreeItems({
@@ -111,6 +150,7 @@ function NotebookTreeItems({
selectedPageId,
onSelectSection,
onSelectPage,
renderLimits,
}: {
nodes: OneNoteNotebookNodeDto[];
sections: ReadonlyMap<string, OneNotePackagedSectionDto>;
@@ -118,6 +158,7 @@ function NotebookTreeItems({
selectedPageId?: string;
onSelectSection(sectionId: string): void;
onSelectPage(pageId: string): void;
renderLimits?: Partial<NavigationRenderLimits>;
}) {
return nodes.map((node) => {
if (node.kind === 'section-group') {
@@ -133,6 +174,7 @@ function NotebookTreeItems({
selectedPageId={selectedPageId}
onSelectSection={onSelectSection}
onSelectPage={onSelectPage}
renderLimits={renderLimits}
/>
</ul>
</details>
@@ -147,12 +189,12 @@ function NotebookTreeItems({
<li className="notebook-section" key={node.id}>
<button
type="button"
aria-label={`${section.displayName}, ${section.parseStatus}`}
aria-label={`${node.displayName}, ${section.parseStatus}`}
className={selected ? 'is-selected' : undefined}
aria-current={selected ? 'true' : undefined}
onClick={() => onSelectSection(section.id)}
>
<strong>{section.displayName}</strong>
<strong>{node.displayName}</strong>
<span className={`status status--${section.parseStatus}`}>
{section.parseStatus}
</span>
@@ -163,6 +205,7 @@ function NotebookTreeItems({
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
nested
renderLimits={renderLimits}
/>
) : selected ? (
<p className="navigation-empty">
@@ -181,10 +224,15 @@ export function PackageNavigation({
selectedPageId,
onSelectSection,
onSelectPage,
renderLimits,
}: PackageNavigationProps) {
const sectionById = new Map(
sections.map((section) => [section.id, section] as const)
);
const projection = prepareNotebookForNavigation(tree.nodes, renderLimits);
const sectionById = new Map<string, OneNotePackagedSectionDto>();
for (const section of sections) {
if (projection.sectionIds.has(section.id)) {
sectionById.set(section.id, section);
}
}
return (
<nav className="source-navigation" aria-label="Notebook sections and pages">
@@ -193,20 +241,27 @@ export function PackageNavigation({
<h2>Notebook contents</h2>
<p>{tree.interpreted ? 'OneNote notebook order' : 'Package order'}</p>
</div>
{tree.nodes.length === 0 ? (
{projection.nodes.length === 0 ? (
<p className="navigation-empty">No section entries were found.</p>
) : (
<ul className="notebook-tree section-list">
<NotebookTreeItems
nodes={tree.nodes}
nodes={projection.nodes}
sections={sectionById}
selectedSectionId={selectedSectionId}
selectedPageId={selectedPageId}
onSelectSection={onSelectSection}
onSelectPage={onSelectPage}
renderLimits={renderLimits}
/>
</ul>
)}
{projection.truncated ? (
<p className="render-limit-notice" role="status">
Notebook navigation was shortened to a safe browser display budget
after {projection.nodeCount} nodes.
</p>
) : null}
</nav>
);
}

View File

@@ -0,0 +1,189 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type {
OneNoteNotebookNodeDto,
OneNotePackageDto,
OneNotePageSummaryDto,
OneNoteSectionDto,
} from '../onenote/model/dto.js';
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
import { DiagnosticsPanel } from './DiagnosticsPanel.js';
import { PackageInspector } from './PackageInspector.js';
import { SingleSectionNavigation } from './SectionNavigation.js';
import {
prepareNotebookForNavigation,
preparePagesForNavigation,
} from './ui-render-budget.js';
describe('bounded UI projections', () => {
it('limits page count and display strings before building navigation nodes', () => {
const projection = preparePagesForNavigation(
[
page('first', 'Long title', 'Long preview'),
page('second'),
page('third'),
],
{
maxNavigationPages: 2,
maxNavigationTitleCharacters: 5,
maxNavigationPreviewCharacters: 6,
}
);
expect(projection.pages).toHaveLength(2);
expect(projection.pages[0]).toMatchObject({
title: 'Long…',
text: '',
textPreview: 'Long …',
});
expect(projection.truncated).toBe(true);
});
it('bounds notebook recursion and rejects cyclic DTO branches', () => {
const cyclic: OneNoteNotebookNodeDto = {
kind: 'section-group',
id: 'cycle',
displayName: 'Cycle',
children: [],
};
cyclic.children.push(cyclic);
const cycleProjection = prepareNotebookForNavigation([cyclic], {
maxNotebookNodes: 10,
maxNotebookDepth: 10,
});
expect(cycleProjection.nodes).toEqual([
{
kind: 'section-group',
id: 'cycle',
displayName: 'Cycle',
children: [],
},
]);
expect(cycleProjection.truncated).toBe(true);
const countProjection = prepareNotebookForNavigation(
Array.from({ length: 20 }, (_, index) => notebookSection(index)),
{ maxNotebookNodes: 3 }
);
expect(countProjection.nodeCount).toBe(3);
expect(countProjection.nodes).toHaveLength(3);
expect(countProjection.truncated).toBe(true);
});
it('renders only the configured number of section page links with a notice', () => {
render(
<SingleSectionNavigation
section={section(
Array.from({ length: 5 }, (_, index) => page(`${index}`))
)}
onSelectPage={vi.fn()}
renderLimits={{ maxNavigationPages: 2 }}
/>
);
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.getByText('0')).toBeVisible();
expect(screen.queryByText('2')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('first 2 of 5 pages');
});
});
describe('bounded paged inspectors', () => {
it('paginates package entries instead of mounting the complete table', async () => {
const user = userEvent.setup();
render(<PackageInspector notebook={notebook(5)} pageSize={2} />);
await user.click(
screen.getByText('Inspect 5 package entries', { selector: 'summary' })
);
expect(screen.getByText('entry-0.one')).toBeVisible();
expect(screen.getByText('entry-1.one')).toBeVisible();
expect(screen.queryByText('entry-2.one')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.queryByText('entry-0.one')).not.toBeInTheDocument();
expect(screen.getByText('entry-2.one')).toBeVisible();
expect(screen.getByText('Showing entries 34 of 5.')).toBeVisible();
});
it('paginates diagnostics and truncates the visible message', async () => {
const user = userEvent.setup();
const diagnostics = Array.from({ length: 5 }, (_, index) =>
diagnostic(index, index === 0 ? 'x'.repeat(10_000) : `message-${index}`)
);
render(<DiagnosticsPanel diagnostics={diagnostics} pageSize={2} />);
expect(document.querySelectorAll('.diagnostic')).toHaveLength(2);
expect(document.querySelector('.diagnostic p')?.textContent).toHaveLength(
4_096
);
expect(screen.queryByText('message-2')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent(
'Long diagnostic text was shortened'
);
await user.click(screen.getByRole('button', { name: 'Next' }));
expect(document.querySelectorAll('.diagnostic')).toHaveLength(2);
expect(screen.getByText('message-2')).toBeVisible();
expect(screen.getByText('Showing diagnostics 34 of 5.')).toBeVisible();
});
});
function page(id: string, title = id, textPreview = ''): OneNotePageSummaryDto {
return { id, title, text: `body-${id}`, textPreview };
}
function section(pages: OneNotePageSummaryDto[]): OneNoteSectionDto {
return {
format: 'one',
sourceName: 'section.one',
sectionName: 'Section',
pages,
diagnostics: [],
parserInfo: {
implementation: 'typescript',
supportedVariant: 'test',
referenceRevision: 'test',
},
};
}
function notebookSection(index: number): OneNoteNotebookNodeDto {
return {
kind: 'section',
id: `node-${index}`,
sectionId: `section-${index}`,
displayName: `Section ${index}`,
};
}
function notebook(entryCount: number): OneNotePackageDto {
return {
format: 'onepkg',
sourceName: 'notebook.onepkg',
sourceSize: 100,
entries: Array.from({ length: entryCount }, (_, index) => ({
path: `entry-${index}.one`,
normalizedPath: `entry-${index}.one`,
kind: 'section' as const,
uncompressedSize: 10,
compressionMethod: 'MSZIP',
parseStatus: 'parsed' as const,
})),
sections: [],
tree: { interpreted: false, nodes: [] },
diagnostics: [],
};
}
function diagnostic(index: number, message: string): ParserDiagnostic {
return {
severity: 'warning',
code: `TEST_${index}`,
message,
recoverable: true,
};
}

View File

@@ -0,0 +1,213 @@
import type {
OneNoteNotebookNodeDto,
OneNotePageSummaryDto,
} from '../onenote/model/dto.js';
export interface UiRenderLimits {
maxNavigationPages: number;
maxNotebookNodes: number;
maxNotebookDepth: number;
maxNavigationTitleCharacters: number;
maxNavigationPreviewCharacters: number;
maxPackageEntriesPerPage: number;
maxPackagePathCharacters: number;
maxPackageMethodCharacters: number;
maxDiagnosticsPerPage: number;
maxDiagnosticCodeCharacters: number;
maxDiagnosticMessageCharacters: number;
maxDiagnosticContextCharacters: number;
maxPanelTitleCharacters: number;
}
export const DEFAULT_UI_RENDER_LIMITS: Readonly<UiRenderLimits> = {
maxNavigationPages: 250,
maxNotebookNodes: 500,
maxNotebookDepth: 16,
maxNavigationTitleCharacters: 512,
maxNavigationPreviewCharacters: 1_000,
maxPackageEntriesPerPage: 200,
maxPackagePathCharacters: 1_024,
maxPackageMethodCharacters: 128,
maxDiagnosticsPerPage: 100,
maxDiagnosticCodeCharacters: 128,
maxDiagnosticMessageCharacters: 4_096,
maxDiagnosticContextCharacters: 512,
maxPanelTitleCharacters: 512,
};
export type NavigationRenderLimits = Pick<
UiRenderLimits,
| 'maxNavigationPages'
| 'maxNotebookNodes'
| 'maxNotebookDepth'
| 'maxNavigationTitleCharacters'
| 'maxNavigationPreviewCharacters'
>;
export interface PageNavigationProjection {
pages: OneNotePageSummaryDto[];
truncated: boolean;
}
export interface NotebookNavigationProjection {
nodes: OneNoteNotebookNodeDto[];
sectionIds: ReadonlySet<string>;
nodeCount: number;
truncated: boolean;
}
/** Limit a caller-provided value without permitting the safety cap to grow. */
export function resolveUiLimit(
value: number | undefined,
safetyCap: number
): number {
if (value === undefined || !Number.isSafeInteger(value) || value < 1) {
return safetyCap;
}
return Math.min(value, safetyCap);
}
export function limitUiString(
value: string,
maxCharacters: number
): { value: string; truncated: boolean } {
const limit = Math.max(1, Math.trunc(maxCharacters));
if (value.length <= limit) return { value, truncated: false };
return {
value: `${value.slice(0, Math.max(0, limit - 1))}`,
truncated: true,
};
}
/** Project page summaries before React builds a nested page list. */
export function preparePagesForNavigation(
pages: readonly OneNotePageSummaryDto[],
overrides: Partial<NavigationRenderLimits> = {}
): PageNavigationProjection {
const maxPages = resolveUiLimit(
overrides.maxNavigationPages,
DEFAULT_UI_RENDER_LIMITS.maxNavigationPages
);
const maxTitleCharacters = resolveUiLimit(
overrides.maxNavigationTitleCharacters,
DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
);
const maxPreviewCharacters = resolveUiLimit(
overrides.maxNavigationPreviewCharacters,
DEFAULT_UI_RENDER_LIMITS.maxNavigationPreviewCharacters
);
const projected: OneNotePageSummaryDto[] = [];
let truncated = pages.length > maxPages;
for (let index = 0; index < pages.length && index < maxPages; index += 1) {
const page = pages[index]!;
const title = limitUiString(page.title, maxTitleCharacters);
const preview = limitUiString(page.textPreview, maxPreviewCharacters);
truncated ||= title.truncated || preview.truncated;
projected.push({
id: page.id,
level: page.level,
title: title.value,
text: '',
textPreview: preview.value,
});
}
return { pages: projected, truncated };
}
/** Project the notebook hierarchy with aggregate node and recursion budgets. */
export function prepareNotebookForNavigation(
nodes: readonly OneNoteNotebookNodeDto[],
overrides: Partial<NavigationRenderLimits> = {}
): NotebookNavigationProjection {
const limits = {
maxNotebookNodes: resolveUiLimit(
overrides.maxNotebookNodes,
DEFAULT_UI_RENDER_LIMITS.maxNotebookNodes
),
maxNotebookDepth: resolveUiLimit(
overrides.maxNotebookDepth,
DEFAULT_UI_RENDER_LIMITS.maxNotebookDepth
),
maxNavigationTitleCharacters: resolveUiLimit(
overrides.maxNavigationTitleCharacters,
DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
),
};
const budget = {
count: 0,
truncated: false,
active: new Set<OneNoteNotebookNodeDto>(),
sectionIds: new Set<string>(),
};
const projected = projectNotebookNodes(nodes, 0, limits, budget);
return {
nodes: projected,
sectionIds: budget.sectionIds,
nodeCount: budget.count,
truncated: budget.truncated,
};
}
function projectNotebookNodes(
nodes: readonly OneNoteNotebookNodeDto[],
depth: number,
limits: Pick<
NavigationRenderLimits,
'maxNotebookNodes' | 'maxNotebookDepth' | 'maxNavigationTitleCharacters'
>,
budget: {
count: number;
truncated: boolean;
active: Set<OneNoteNotebookNodeDto>;
sectionIds: Set<string>;
}
): OneNoteNotebookNodeDto[] {
const projected: OneNoteNotebookNodeDto[] = [];
for (const node of nodes) {
if (budget.count >= limits.maxNotebookNodes) {
budget.truncated = true;
break;
}
if (budget.active.has(node)) {
budget.truncated = true;
continue;
}
budget.count += 1;
const displayName = limitUiString(
node.displayName,
limits.maxNavigationTitleCharacters
);
budget.truncated ||= displayName.truncated;
if (node.kind === 'section') {
budget.sectionIds.add(node.sectionId);
projected.push({
kind: 'section',
id: node.id,
sectionId: node.sectionId,
displayName: displayName.value,
});
continue;
}
budget.active.add(node);
let children: OneNoteNotebookNodeDto[] = [];
if (depth >= limits.maxNotebookDepth) {
if (node.children.length > 0) budget.truncated = true;
} else {
children = projectNotebookNodes(node.children, depth + 1, limits, budget);
}
budget.active.delete(node);
projected.push({
kind: 'section-group',
id: node.id,
displayName: displayName.value,
children,
});
}
return projected;
}