feat: export parser sessions from the browser
This commit is contained in:
@@ -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',
|
||||
|
||||
62
src/App.tsx
62
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<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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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' }
|
||||
>;
|
||||
|
||||
Reference in New Issue
Block a user