diff --git a/src/App.test.tsx b/src/App.test.tsx index a87ec0e..ed0caab 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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( + 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', diff --git a/src/App.tsx b/src/App.tsx index 7f359d6..9ad58ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 => { + 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({ ) : null} + +
{state.source.kind === 'one' ? ( URL.revokeObjectURL(url), 1_000); +} + export function App() { const [helpOpen, setHelpOpen] = useState(false); diff --git a/src/styles.css b/src/styles.css index f4c3dd1..f690ecf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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 { diff --git a/src/worker/onenote.client.test.ts b/src/worker/onenote.client.test.ts index 6900068..91eb53f 100644 --- a/src/worker/onenote.client.test.ts +++ b/src/worker/onenote.client.test.ts @@ -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); diff --git a/src/worker/onenote.client.ts b/src/worker/onenote.client.ts index 7fb9f88..7029cfc 100644 --- a/src/worker/onenote.client.ts +++ b/src/worker/onenote.client.ts @@ -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 { + 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; diff --git a/src/worker/onenote.worker.ts b/src/worker/onenote.worker.ts index 601b17c..47400c3 100644 --- a/src/worker/onenote.worker.ts +++ b/src/worker/onenote.worker.ts @@ -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) => void) | null; @@ -17,6 +27,7 @@ interface ModuleWorkerScope { } interface ParserSession { + source: OneNoteSectionDto | OneNotePackageDto; pages: Map; resources: Map; } @@ -26,11 +37,12 @@ const sessions = new Map(); let sessionSequence = 0; function createSession( + source: OneNoteSectionDto | OneNotePackageDto, pages: Map, resources: Map ): 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 { 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 { 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 { ); 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 { } } +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); }; diff --git a/src/worker/worker-protocol.ts b/src/worker/worker-protocol.ts index 2f8339c..f625535 100644 --- a/src/worker/worker-protocol.ts +++ b/src/worker/worker-protocol.ts @@ -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' } +>;