561 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|