feat: add local OneNote and ONEPKG reader

This commit is contained in:
2026-07-22 17:06:03 +02:00
commit f581cbdced
93 changed files with 14949 additions and 0 deletions

410
src/App.tsx Normal file
View File

@@ -0,0 +1,410 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppShell } from '@add-ideas/toolbox-shell-react';
import '@add-ideas/toolbox-shell-react/styles.css';
import { DiagnosticsPanel } from './components/DiagnosticsPanel.js';
import { FileDropZone } from './components/FileDropZone.js';
import { HelpDialog } from './components/HelpDialog.js';
import { PackageInspector } from './components/PackageInspector.js';
import { PageReader } from './components/PageReader.js';
import {
PackageNavigation,
SingleSectionNavigation,
} from './components/SectionNavigation.js';
import type {
OneNotePackageDto,
OneNotePageDto,
OneNoteSectionDto,
} from './onenote/model/dto.js';
import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
import {
MAX_SOURCE_FILE_BYTES,
sourceKindFromFileName,
} from './source-file.js';
import { toolboxApp } from './toolbox/manifest.js';
import {
OneNoteWorkerClient,
WorkerClientError,
} from './worker/onenote.client.js';
type LoadedSource =
| {
kind: 'one';
sessionId: string;
section: OneNoteSectionDto;
}
| {
kind: 'onepkg';
sessionId: string;
notebook: OneNotePackageDto;
};
type ApplicationState =
| { phase: 'idle' }
| { phase: 'reading'; fileName: string }
| { phase: 'loaded'; fileName: string; source: LoadedSource }
| {
phase: 'error';
title: string;
message: string;
detail?: string;
};
type PageState =
| { phase: 'empty' }
| { phase: 'loading'; pageId: string }
| { phase: 'loaded'; page: OneNotePageDto }
| { phase: 'error'; message: string };
function uniqueDiagnostics(
diagnostics: ParserDiagnostic[]
): ParserDiagnostic[] {
const seen = new Set<string>();
return diagnostics.filter((diagnostic) => {
const key = JSON.stringify([
diagnostic.severity,
diagnostic.code,
diagnostic.message,
diagnostic.offset,
diagnostic.structure,
diagnostic.propertyId,
]);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export interface OneNoteApplicationProps {
createWorkerClient?: () => OneNoteWorkerClient;
}
export function OneNoteApplication({
createWorkerClient = () => new OneNoteWorkerClient(),
}: OneNoteApplicationProps) {
const [state, setState] = useState<ApplicationState>({ phase: 'idle' });
const [pageState, setPageState] = useState<PageState>({ phase: 'empty' });
const [selectedSectionId, setSelectedSectionId] = useState<
string | undefined
>();
const activeClient = useRef<OneNoteWorkerClient | null>(null);
const generation = useRef(0);
const pageGeneration = useRef(0);
const clearFile = useCallback(() => {
generation.current += 1;
pageGeneration.current += 1;
activeClient.current?.terminate();
activeClient.current = null;
setSelectedSectionId(undefined);
setPageState({ phase: 'empty' });
setState({ phase: 'idle' });
}, []);
useEffect(() => {
return () => {
generation.current += 1;
pageGeneration.current += 1;
activeClient.current?.terminate();
};
}, []);
const loadPage = async (
sessionId: string,
pageId: string,
sectionId?: string
) => {
const client = activeClient.current;
const requestGeneration = ++pageGeneration.current;
if (!client) {
setPageState({
phase: 'error',
message: 'The local parser session is no longer available.',
});
return;
}
setPageState({ phase: 'loading', pageId });
try {
const response = await client.getPage(sessionId, pageId, sectionId);
if (requestGeneration !== pageGeneration.current) return;
if (response.type === 'failure') {
setPageState({ phase: 'error', message: response.error.message });
return;
}
setPageState({ phase: 'loaded', page: response.page });
} catch (error) {
if (requestGeneration !== pageGeneration.current) return;
setPageState({
phase: 'error',
message:
error instanceof WorkerClientError
? error.message
: 'The selected page could not be read from the parser session.',
});
}
};
const openFile = async (file: File) => {
const sourceKind = sourceKindFromFileName(file.name);
if (!sourceKind) {
setState({
phase: 'error',
title: 'Unsupported file name',
message: 'Choose a file whose name ends in .one or .onepkg.',
});
return;
}
if (file.size > MAX_SOURCE_FILE_BYTES) {
setState({
phase: 'error',
title: 'File is too large',
message: 'The selected file exceeds the current 512 MiB safety limit.',
});
return;
}
generation.current += 1;
pageGeneration.current += 1;
const requestGeneration = generation.current;
activeClient.current?.terminate();
const client = createWorkerClient();
activeClient.current = client;
setSelectedSectionId(undefined);
setPageState({ phase: 'empty' });
setState({ phase: 'reading', fileName: file.name });
try {
const bytes = await file.arrayBuffer();
if (generation.current !== requestGeneration) return;
const response = await client.parseSource(sourceKind, file.name, bytes);
if (generation.current !== requestGeneration) return;
if (response.type === 'failure') {
client.terminate();
if (activeClient.current === client) activeClient.current = null;
setState({
phase: 'error',
title: 'Could not open this OneNote source',
message: response.error.message,
detail: response.error.structure
? `${response.error.code} · ${response.error.structure}`
: response.error.code,
});
return;
}
if (response.type === 'section') {
setState({
phase: 'loaded',
fileName: file.name,
source: {
kind: 'one',
sessionId: response.sessionId,
section: response.section,
},
});
const firstPage = response.section.pages[0];
if (firstPage) void loadPage(response.sessionId, firstPage.id);
return;
}
const preferredSection =
response.notebook.sections.find(
(section) =>
section.parseStatus === 'parsed' &&
section.section !== undefined &&
section.section.pages.length > 0
) ??
response.notebook.sections.find(
(section) => section.parseStatus === 'parsed' && section.section
) ??
response.notebook.sections[0];
setSelectedSectionId(preferredSection?.id);
setState({
phase: 'loaded',
fileName: file.name,
source: {
kind: 'onepkg',
sessionId: response.sessionId,
notebook: response.notebook,
},
});
const firstPage = preferredSection?.section?.pages[0];
if (firstPage) {
void loadPage(response.sessionId, firstPage.id, preferredSection?.id);
}
} catch (error) {
if (generation.current !== requestGeneration) return;
client.terminate();
if (activeClient.current === client) activeClient.current = null;
setState({
phase: 'error',
title: 'Local parser stopped',
message:
error instanceof WorkerClientError
? error.message
: 'The local file could not be handed to the parser worker.',
});
}
};
const selectedPageId =
pageState.phase === 'loading'
? pageState.pageId
: pageState.phase === 'loaded'
? pageState.page.id
: undefined;
const selectedPage =
pageState.phase === 'loaded' ? pageState.page : undefined;
const selectPackageSection = (sectionId: string) => {
if (state.phase !== 'loaded' || state.source.kind !== 'onepkg') return;
setSelectedSectionId(sectionId);
const section = state.source.notebook.sections.find(
(candidate) => candidate.id === sectionId
);
const firstPage = section?.section?.pages[0];
if (firstPage) {
void loadPage(state.source.sessionId, firstPage.id, sectionId);
} else {
pageGeneration.current += 1;
setPageState({ phase: 'empty' });
}
};
const visibleDiagnostics = uniqueDiagnostics(
(() => {
if (state.phase !== 'loaded') return [];
const pageDiagnostics = selectedPage?.diagnostics ?? [];
if (state.source.kind === 'one') {
return [...state.source.section.diagnostics, ...pageDiagnostics];
}
const selectedSection = state.source.notebook.sections.find(
(section) => section.id === selectedSectionId
);
return [
...state.source.notebook.diagnostics,
...(selectedSection?.diagnostics ?? []),
...(selectedSection?.section?.diagnostics ?? []),
...pageDiagnostics,
];
})()
);
return (
<main className="application">
{state.phase === 'idle' ? (
<FileDropZone onFile={(file) => void openFile(file)} />
) : null}
{state.phase === 'reading' ? (
<section className="status-card" aria-live="polite">
<p className="status-card__label">Local parser worker</p>
<h2>Opening {state.fileName}</h2>
<p>
Validating the source and extracting supported content in this
browser.
</p>
<button type="button" onClick={clearFile}>
Cancel
</button>
</section>
) : null}
{state.phase === 'loaded' ? (
<>
<header className="source-toolbar">
<div>
<p className="eyebrow">
{state.source.kind === 'one' ? 'OneNote section' : 'ONEPKG'}
</p>
<h2>{state.fileName}</h2>
</div>
<button type="button" onClick={clearFile}>
Clear file
</button>
</header>
{state.source.kind === 'onepkg' ? (
<PackageInspector notebook={state.source.notebook} />
) : null}
<div className="reader-workspace">
{state.source.kind === 'one' ? (
<SingleSectionNavigation
section={state.source.section}
selectedPageId={selectedPageId}
onSelectPage={(pageId) =>
void loadPage(state.source.sessionId, pageId)
}
/>
) : (
<PackageNavigation
sections={state.source.notebook.sections}
selectedSectionId={selectedSectionId}
selectedPageId={selectedPageId}
onSelectSection={selectPackageSection}
onSelectPage={(pageId) =>
void loadPage(
state.source.sessionId,
pageId,
selectedSectionId
)
}
/>
)}
<PageReader
page={selectedPage}
loading={pageState.phase === 'loading'}
error={
pageState.phase === 'error' ? pageState.message : undefined
}
/>
</div>
<DiagnosticsPanel diagnostics={visibleDiagnostics} />
</>
) : null}
{state.phase === 'error' ? (
<section className="status-card status-card--error" role="alert">
<p className="status-card__label">Could not open file</p>
<h2>{state.title}</h2>
<p>{state.message}</p>
{state.detail ? <code>{state.detail}</code> : null}
<div>
<button type="button" onClick={clearFile}>
Try another file
</button>
</div>
</section>
) : null}
<aside className="privacy-note">
<strong>Local-only.</strong> Files are transferred to an in-browser
worker for parsing and are not uploaded, persisted, or sent to
analytics. Clearing ends the worker session.
</aside>
</main>
);
}
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<AppShell
app={toolboxApp}
manifestUrl="./toolbox-app.json"
appActions={
<button type="button" onClick={() => setHelpOpen(true)}>
Help
</button>
}
>
<OneNoteApplication />
{helpOpen ? <HelpDialog onClose={() => setHelpOpen(false)} /> : null}
</AppShell>
);
}