feat: export parser sessions from the browser

This commit is contained in:
2026-07-22 19:50:19 +02:00
parent 239bb3c465
commit f21d97de21
7 changed files with 355 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { OneNoteApplication } from './App.js';
import type {
@@ -105,6 +105,17 @@ class SuccessfulParserWorker implements WorkerPort {
diagnostics: [],
},
});
} else if (message.type === 'export-session') {
this.respond({
type: 'export-artifact',
requestId: message.requestId,
sessionId: message.sessionId,
artifact: {
filename: 'Project notes.zip',
mediaType: 'application/zip',
bytes: Uint8Array.of(0x50, 0x4b, 3, 4).buffer,
},
});
}
});
}
@@ -183,6 +194,43 @@ describe('OneNoteApplication', () => {
).toBeInTheDocument();
});
it('downloads a worker-built export after explicit user action', async () => {
const user = userEvent.setup();
const worker = new SuccessfulParserWorker();
const createObjectUrl = vi
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:local-export');
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
render(
<OneNoteApplication
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
/>
);
await user.upload(
screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1, 2, 3])], 'Section.one')
);
await screen.findByRole('heading', { name: 'First meeting' });
await user.click(screen.getByRole('button', { name: 'Create export' }));
expect(
await screen.findByText('The export download is ready.')
).toBeVisible();
expect(worker.lastMessage).toEqual({
type: 'export-session',
requestId: 'export-3',
sessionId: 'session-one',
format: 'static-zip',
});
expect(createObjectUrl).toHaveBeenCalledWith(
expect.objectContaining({ type: 'application/zip' })
);
expect(click).toHaveBeenCalledOnce();
});
it('opens a dropped OneNote package through the local worker', async () => {
const notebook: OneNotePackageDto = {
format: 'onepkg',

View File

@@ -3,6 +3,10 @@ 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';
@@ -337,21 +341,39 @@ export function OneNoteApplication({
suggestedName ??
`onenote-resource.${resource.extension ?? 'bin'}`
);
const url = URL.createObjectURL(
new Blob([resource.bytes], { type: resource.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);
downloadBrowserFile(resource.bytes, resource.mediaType, filename);
},
[loadSelectedResource]
);
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 [];
@@ -409,6 +431,8 @@ export function OneNoteApplication({
<PackageInspector notebook={state.source.notebook} />
) : null}
<ExportControls onExport={exportLoadedSource} />
<div className="reader-workspace">
{state.source.kind === 'one' ? (
<SingleSectionNavigation
@@ -486,6 +510,22 @@ function safeDownloadFilename(value: string): string {
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);

View File

@@ -75,6 +75,7 @@ p {
.status-card,
.source-toolbar,
.package-inspector,
.export-controls,
.diagnostics {
border: 1px solid #c9b9d5;
border-radius: 1rem;
@@ -82,6 +83,60 @@ p {
box-shadow: 0 0.75rem 2.5rem rgb(62 31 85 / 8%);
}
.export-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(17rem, auto);
align-items: end;
gap: 0.7rem 1.5rem;
padding: 1.15rem 1.35rem;
}
.export-controls h2 {
margin: 0.15rem 0 0;
font-size: 1.15rem;
}
.export-controls > div > p:last-child {
margin: 0.35rem 0 0;
color: #716675;
font-size: 0.86rem;
}
.export-controls__actions {
display: grid;
grid-template-columns: minmax(13rem, 1fr) auto;
gap: 0.35rem 0.55rem;
}
.export-controls__actions label {
grid-column: 1 / -1;
color: #574b5b;
font-size: 0.78rem;
font-weight: 700;
}
.export-controls__actions select {
min-width: 0;
border: 1px solid #a98ab9;
border-radius: 0.55rem;
padding: 0.55rem 2rem 0.55rem 0.7rem;
color: inherit;
background: #fff;
font: inherit;
}
.export-controls__status {
grid-column: 1 / -1;
min-height: 1.2em;
margin: 0;
color: #716675;
font-size: 0.78rem;
}
.export-controls__status--error {
color: #9b3131;
}
.drop-zone,
.status-card {
padding: clamp(1.5rem, 4vw, 3rem);
@@ -706,6 +761,10 @@ p {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.export-controls {
grid-template-columns: 1fr;
}
.entry-table {
min-width: 42rem;
}
@@ -722,6 +781,7 @@ p {
.source-toolbar,
.package-inspector,
.diagnostics,
.export-controls,
.reader-workspace,
.page-reader,
.help-dialog,
@@ -748,6 +808,21 @@ p {
background: #35283d;
}
.export-controls__actions select {
border-color: #755e82;
background: #35283d;
}
.export-controls > div > p:last-child,
.export-controls__actions label,
.export-controls__status {
color: #cfc2d6;
}
.export-controls__status--error {
color: #ffb3b3;
}
code,
.privacy-note,
.package-summary > div {

View File

@@ -149,6 +149,39 @@ describe('OneNoteWorkerClient', () => {
});
});
it('requests an export from the retained parser session', async () => {
const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker);
const result = client.exportSession('session-1', 'static-zip');
expect(worker.lastMessage).toEqual({
type: 'export-session',
requestId: 'export-1',
sessionId: 'session-1',
format: 'static-zip',
});
expect(worker.lastTransfer).toEqual([]);
const bytes = Uint8Array.of(0x50, 0x4b, 3, 4).buffer;
worker.respond({
type: 'export-artifact',
requestId: 'export-1',
sessionId: 'session-1',
artifact: {
filename: 'Notebook.zip',
mediaType: 'application/zip',
bytes,
},
});
await expect(result).resolves.toEqual(
expect.objectContaining({
type: 'export-artifact',
artifact: expect.objectContaining({ bytes }),
})
);
});
it('rejects outstanding work when the worker is terminated', async () => {
const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker);

View File

@@ -1,10 +1,12 @@
import type {
ExportWorkerResponse,
PageWorkerResponse,
ParseWorkerRequest,
ParseWorkerResponse,
ResourceWorkerResponse,
WorkerRequest,
WorkerCabinetPart,
WorkerExportFormat,
WorkerFailure,
WorkerResponse,
} from './worker-protocol.js';
@@ -20,7 +22,11 @@ export interface WorkerPort {
type PendingRequest = {
resolve: (
response: ParseWorkerResponse | PageWorkerResponse | ResourceWorkerResponse
response:
| ParseWorkerResponse
| PageWorkerResponse
| ResourceWorkerResponse
| ExportWorkerResponse
) => void;
reject: (error: WorkerClientError) => void;
};
@@ -172,6 +178,34 @@ export class OneNoteWorkerClient {
});
}
exportSession(
sessionId: string,
format: WorkerExportFormat
): Promise<ExportWorkerResponse> {
if (this.terminated) {
return Promise.reject(
new WorkerClientError(
'worker-failure',
'The local parser worker has already been terminated.'
)
);
}
const requestId = `export-${++this.requestSequence}`;
return new Promise((resolve, reject) => {
this.pending.set(requestId, {
resolve: (response) => resolve(response as ExportWorkerResponse),
reject,
});
this.worker.postMessage({
type: 'export-session',
requestId,
sessionId,
format,
});
});
}
terminate(): void {
if (this.terminated) return;
this.terminated = true;

View File

@@ -1,5 +1,11 @@
import type { OneNotePageDto } from '../onenote/model/dto.js';
import { OneNoteExportError } from '../onenote/export/index.js';
import type {
OneNotePackageDto,
OneNotePageDto,
OneNoteSectionDto,
} from '../onenote/model/dto.js';
import type { OneNoteResource } from '../onenote/one/content-model.js';
import { createWorkerExportArtifact } from './export-adapter.js';
import {
failureFromUnknown,
openPackageForWorker,
@@ -9,7 +15,11 @@ import {
workerPageKey,
workerResourceKey,
} from './parser-adapter.js';
import type { WorkerRequest, WorkerResponse } from './worker-protocol.js';
import type {
WorkerFailure,
WorkerRequest,
WorkerResponse,
} from './worker-protocol.js';
interface ModuleWorkerScope {
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
@@ -17,6 +27,7 @@ interface ModuleWorkerScope {
}
interface ParserSession {
source: OneNoteSectionDto | OneNotePackageDto;
pages: Map<string, OneNotePageDto>;
resources: Map<string, OneNoteResource>;
}
@@ -26,11 +37,12 @@ const sessions = new Map<string, ParserSession>();
let sessionSequence = 0;
function createSession(
source: OneNoteSectionDto | OneNotePackageDto,
pages: Map<string, OneNotePageDto>,
resources: Map<string, OneNoteResource>
): string {
const id = `session-${++sessionSequence}`;
sessions.set(id, { pages, resources });
sessions.set(id, { source, pages, resources });
return id;
}
@@ -42,7 +54,11 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
new Uint8Array(data.bytes),
data.fileName
);
const sessionId = createSession(parsed.pages, parsed.resources);
const sessionId = createSession(
parsed.section,
parsed.pages,
parsed.resources
);
workerScope.postMessage({
type: 'section',
requestId: data.requestId,
@@ -79,7 +95,11 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
new Uint8Array(data.bytes),
data.fileName
);
const sessionId = createSession(parsed.pages, parsed.resources);
const sessionId = createSession(
parsed.notebook,
parsed.pages,
parsed.resources
);
workerScope.postMessage({
type: 'package',
requestId: data.requestId,
@@ -172,6 +192,45 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
);
return;
}
case 'export-session': {
const session = sessions.get(data.sessionId);
if (!session) {
workerScope.postMessage({
type: 'failure',
requestId: data.requestId,
error: {
code: 'session-not-available',
message: 'The parser session is no longer available.',
recoverable: false,
},
});
return;
}
try {
const artifact = createWorkerExportArtifact(
session.source,
session.pages,
session.resources,
data.format
);
workerScope.postMessage(
{
type: 'export-artifact',
requestId: data.requestId,
sessionId: data.sessionId,
artifact,
},
[artifact.bytes]
);
} catch (error) {
workerScope.postMessage({
type: 'failure',
requestId: data.requestId,
error: exportFailureFromUnknown(error),
});
}
return;
}
case 'dispose':
sessions.delete(data.sessionId);
workerScope.postMessage({
@@ -181,6 +240,27 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
}
}
function exportFailureFromUnknown(error: unknown): WorkerFailure {
if (error instanceof OneNoteExportError) {
return {
code:
error.code === 'limit-exceeded' ? 'limit-exceeded' : 'export-failed',
message: error.message,
recoverable: true,
structure: 'OneNote export',
};
}
return {
code: 'export-failed',
message:
error instanceof Error
? error.message
: 'The local notebook export could not be created.',
recoverable: true,
structure: 'OneNote export',
};
}
workerScope.onmessage = ({ data }) => {
void handleRequest(data);
};

View File

@@ -10,6 +10,15 @@ export interface WorkerCabinetPart {
bytes: ArrayBuffer;
}
export type WorkerExportFormat =
'static-zip' | 'json' | 'text' | 'markdown' | 'html';
export interface WorkerExportArtifact {
filename: string;
mediaType: string;
bytes: ArrayBuffer;
}
export type WorkerRequest =
| {
type: 'parse-one';
@@ -38,6 +47,12 @@ export type WorkerRequest =
resourceId: string;
sectionId?: string;
}
| {
type: 'export-session';
requestId: string;
sessionId: string;
format: WorkerExportFormat;
}
| {
type: 'dispose';
sessionId: string;
@@ -50,6 +65,7 @@ export interface WorkerFailure {
| 'limit-exceeded'
| 'page-not-available'
| 'resource-not-available'
| 'export-failed'
| 'session-not-available'
| 'worker-failure';
message: string;
@@ -83,6 +99,12 @@ export type WorkerResponse =
sessionId: string;
resource: OneNoteResourcePayloadDto;
}
| {
type: 'export-artifact';
requestId: string;
sessionId: string;
artifact: WorkerExportArtifact;
}
| {
type: 'failure';
requestId: string;
@@ -111,3 +133,8 @@ export type ResourceWorkerResponse = Extract<
WorkerResponse,
{ type: 'resource' | 'failure' }
>;
export type ExportWorkerResponse = Extract<
WorkerResponse,
{ type: 'export-artifact' | 'failure' }
>;