Files
onenote-tools/src/App.tsx

561 lines
17 KiB
TypeScript

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 {
ExportControls,
type OneNoteExportFormat,
} from './components/ExportControls.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 {
OneNoteNotebookNodeDto,
OneNotePackageDto,
OneNotePackagedSectionDto,
OneNotePageDto,
OneNoteResourcePayloadDto,
OneNoteSectionDto,
} from './onenote/model/dto.js';
import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
import { selectSourceFiles } 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;
});
}
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 {
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 openFiles = async (files: File[]) => {
const selection = selectSourceFiles(files);
if (!selection.ok) {
setState({
phase: 'error',
title: selection.title,
message: selection.message,
});
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: selection.source.name });
try {
const buffers = await Promise.all(
[selection.source, ...selection.cabinetParts].map((file) =>
file.arrayBuffer()
)
);
if (generation.current !== requestGeneration) return;
const response = await client.parseSource(
selection.kind,
selection.source.name,
buffers[0]!,
selection.cabinetParts.map((part, index) => ({
fileName: part.name,
bytes: buffers[index + 1]!,
}))
);
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: selection.source.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 orderedSections = sectionsInNotebookOrder(response.notebook);
const preferredSection =
orderedSections.find(
(section) =>
section.parseStatus === 'parsed' &&
section.section !== undefined &&
section.section.pages.length > 0
) ??
orderedSections.find(
(section) => section.parseStatus === 'parsed' && section.section
) ??
orderedSections[0];
setSelectedSectionId(preferredSection?.id);
setState({
phase: 'loaded',
fileName: selection.source.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 previewSelectedResource = useCallback(
async (resourceId: string): Promise<OneNoteResourcePayloadDto> => {
const client = activeClient.current;
if (!client || state.phase !== 'loaded') {
throw new WorkerClientError(
'session-not-available',
'The local parser session is no longer available.'
);
}
const response = await client.getResourcePreview(
state.source.sessionId,
resourceId,
state.source.kind === 'onepkg' ? selectedSectionId : undefined
);
if (response.type === 'failure') {
throw new WorkerClientError(
response.error.code,
response.error.message
);
}
return response.resource;
},
[selectedSectionId, state]
);
const downloadSelectedResource = useCallback(
async (resourceId: string, suggestedName?: string): Promise<void> => {
const client = activeClient.current;
if (!client || state.phase !== 'loaded') {
throw new WorkerClientError(
'session-not-available',
'The local parser session is no longer available.'
);
}
const response = await client.getResourceDownload(
state.source.sessionId,
resourceId,
state.source.kind === 'onepkg' ? selectedSectionId : undefined
);
if (response.type === 'failure') {
throw new WorkerClientError(
response.error.code,
response.error.message
);
}
const resource = response.resource;
const filename = safeDownloadFilename(
resource.filename ??
suggestedName ??
`onenote-resource.${resource.extension ?? 'bin'}`
);
downloadBrowserFile(resource.bytes, resource.mediaType, filename);
},
[selectedSectionId, state]
);
const exportLoadedSource = useCallback(
async (format: OneNoteExportFormat): Promise<void> => {
const client = activeClient.current;
if (!client || state.phase !== 'loaded') {
throw new WorkerClientError(
'session-not-available',
'The local parser session is no longer available.'
);
}
const response = await client.exportSession(
state.source.sessionId,
format
);
if (response.type === 'failure') {
throw new WorkerClientError(
response.error.code,
response.error.message
);
}
downloadBrowserFile(
response.artifact.bytes,
response.artifact.mediaType,
safeDownloadFilename(response.artifact.filename)
);
},
[state]
);
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 onFiles={(files) => void openFiles(files)} />
) : 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}
<ExportControls onExport={exportLoadedSource} />
<div className="reader-workspace">
{state.source.kind === 'one' ? (
<SingleSectionNavigation
section={state.source.section}
selectedPageId={selectedPageId}
onSelectPage={(pageId) =>
void loadPage(state.source.sessionId, pageId)
}
/>
) : (
<PackageNavigation
tree={state.source.notebook.tree}
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
}
previewResource={previewSelectedResource}
downloadResource={downloadSelectedResource}
/>
</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>
);
}
function safeDownloadFilename(value: string): string {
const basename = value.split(/[\\/]/u).at(-1) ?? value;
const withoutUnsafeCharacters = [...basename.normalize('NFC')]
.map((character) => {
const code = character.codePointAt(0) ?? 0;
return code <= 0x1f || code === 0x7f || '<>:"|?*'.includes(character)
? '_'
: character;
})
.join('');
const cleaned = withoutUnsafeCharacters.replace(/[. ]+$/gu, '').trim();
return (cleaned || 'onenote-resource.bin').slice(0, 180);
}
function downloadBrowserFile(
bytes: ArrayBuffer,
mediaType: string,
filename: string
): void {
const url = URL.createObjectURL(new Blob([bytes], { type: mediaType }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.rel = 'noopener';
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
}
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<AppShell
app={toolboxApp}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<OneNoteApplication />
{helpOpen ? <HelpDialog onClose={() => setHelpOpen(false)} /> : null}
</AppShell>
);
}