feat: open multi-part packages in browser
This commit is contained in:
@@ -52,10 +52,14 @@ class SuccessfulParserWorker implements WorkerPort {
|
||||
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
terminated = false;
|
||||
lastMessage: WorkerRequest | undefined;
|
||||
lastTransfer: Transferable[] = [];
|
||||
|
||||
constructor(private readonly packageResult?: OneNotePackageDto) {}
|
||||
|
||||
postMessage(message: WorkerRequest): void {
|
||||
postMessage(message: WorkerRequest, transfer: Transferable[] = []): void {
|
||||
this.lastMessage = message;
|
||||
this.lastTransfer = transfer;
|
||||
queueMicrotask(() => {
|
||||
if (message.type === 'parse-one') {
|
||||
this.respond({
|
||||
@@ -158,7 +162,7 @@ describe('OneNoteApplication', () => {
|
||||
);
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText('Choose a OneNote file'),
|
||||
screen.getByLabelText('Choose OneNote files'),
|
||||
new File([new Uint8Array([1, 2, 3])], 'Section.one')
|
||||
);
|
||||
|
||||
@@ -189,23 +193,74 @@ describe('OneNoteApplication', () => {
|
||||
tree: { interpreted: false, nodes: [] },
|
||||
diagnostics: [],
|
||||
};
|
||||
const worker = new SuccessfulParserWorker(notebook);
|
||||
render(
|
||||
<OneNoteApplication
|
||||
createWorkerClient={() =>
|
||||
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
|
||||
}
|
||||
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
|
||||
/>
|
||||
);
|
||||
|
||||
const part3 = new File([new Uint8Array([3])], 'Notebook-3.cab');
|
||||
const primary = new File([new Uint8Array([1])], 'Notebook.onepkg');
|
||||
const part2 = new File([new Uint8Array([2])], 'Notebook-2.cab');
|
||||
fireEvent.drop(screen.getByTestId('file-drop-zone'), {
|
||||
dataTransfer: {
|
||||
files: [new File([new Uint8Array([1])], 'Notebook.onepkg')],
|
||||
files: [part3, primary, part2],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Package summary' })
|
||||
).toBeInTheDocument();
|
||||
expect(worker.lastMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'parse-onepkg',
|
||||
fileName: 'Notebook.onepkg',
|
||||
cabinetParts: [
|
||||
expect.objectContaining({ fileName: 'Notebook-3.cab' }),
|
||||
expect.objectContaining({ fileName: 'Notebook-2.cab' }),
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(worker.lastTransfer).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('hands a multi-file picker selection to the package worker', async () => {
|
||||
const notebook: OneNotePackageDto = {
|
||||
format: 'onepkg',
|
||||
sourceName: 'Notebook.onepkg',
|
||||
sourceSize: 2,
|
||||
entries: [],
|
||||
sections: [],
|
||||
tree: { interpreted: false, nodes: [] },
|
||||
diagnostics: [],
|
||||
};
|
||||
const worker = new SuccessfulParserWorker(notebook);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OneNoteApplication
|
||||
createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText('Choose OneNote files');
|
||||
expect(input).toHaveAttribute('multiple');
|
||||
expect(input).toHaveAttribute('accept', '.one,.onepkg,.cab');
|
||||
await user.upload(input, [
|
||||
new File([new Uint8Array([2])], 'Notebook-2.cab'),
|
||||
new File([new Uint8Array([1])], 'Notebook.onepkg'),
|
||||
]);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('heading', { name: 'Package summary' })
|
||||
).toBeInTheDocument();
|
||||
expect(worker.lastMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'parse-onepkg',
|
||||
fileName: 'Notebook.onepkg',
|
||||
cabinetParts: [expect.objectContaining({ fileName: 'Notebook-2.cab' })],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a failed packaged section visible beside a readable one', async () => {
|
||||
@@ -299,7 +354,7 @@ describe('OneNoteApplication', () => {
|
||||
);
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText('Choose a OneNote file'),
|
||||
screen.getByLabelText('Choose OneNote files'),
|
||||
new File([new Uint8Array([1])], 'Notebook.onepkg')
|
||||
);
|
||||
|
||||
@@ -336,7 +391,7 @@ describe('OneNoteApplication', () => {
|
||||
);
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText('Choose a OneNote file'),
|
||||
screen.getByLabelText('Choose OneNote files'),
|
||||
new File(['not OneNote'], 'Broken.one')
|
||||
);
|
||||
|
||||
@@ -353,12 +408,12 @@ describe('OneNoteApplication', () => {
|
||||
render(<OneNoteApplication />);
|
||||
|
||||
await user.upload(
|
||||
screen.getByLabelText('Choose a OneNote file'),
|
||||
screen.getByLabelText('Choose OneNote files'),
|
||||
new File(['not a notebook'], 'notes.zip')
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Choose a file whose name ends in .one or .onepkg.'
|
||||
'Choose one .one file, or one .onepkg file together with its .cab parts.'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -371,7 +426,7 @@ describe('OneNoteApplication', () => {
|
||||
value: MAX_SOURCE_FILE_BYTES + 1,
|
||||
});
|
||||
|
||||
await user.upload(screen.getByLabelText('Choose a OneNote file'), file);
|
||||
await user.upload(screen.getByLabelText('Choose OneNote files'), file);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
|
||||
});
|
||||
|
||||
47
src/App.tsx
47
src/App.tsx
@@ -20,10 +20,7 @@ import type {
|
||||
OneNoteSectionDto,
|
||||
} from './onenote/model/dto.js';
|
||||
import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
|
||||
import {
|
||||
MAX_SOURCE_FILE_BYTES,
|
||||
sourceKindFromFileName,
|
||||
} from './source-file.js';
|
||||
import { selectSourceFiles } from './source-file.js';
|
||||
import { toolboxApp } from './toolbox/manifest.js';
|
||||
import {
|
||||
OneNoteWorkerClient,
|
||||
@@ -174,21 +171,13 @@ export function OneNoteApplication({
|
||||
}
|
||||
};
|
||||
|
||||
const openFile = async (file: File) => {
|
||||
const sourceKind = sourceKindFromFileName(file.name);
|
||||
if (!sourceKind) {
|
||||
const openFiles = async (files: File[]) => {
|
||||
const selection = selectSourceFiles(files);
|
||||
if (!selection.ok) {
|
||||
setState({
|
||||
phase: 'error',
|
||||
title: 'Unsupported file name',
|
||||
message: 'Choose a file whose name ends in .one or .onepkg.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_SOURCE_FILE_BYTES) {
|
||||
setState({
|
||||
phase: 'error',
|
||||
title: 'File is too large',
|
||||
message: 'The selected file exceeds the current 512 MiB safety limit.',
|
||||
title: selection.title,
|
||||
message: selection.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -201,12 +190,24 @@ export function OneNoteApplication({
|
||||
activeClient.current = client;
|
||||
setSelectedSectionId(undefined);
|
||||
setPageState({ phase: 'empty' });
|
||||
setState({ phase: 'reading', fileName: file.name });
|
||||
setState({ phase: 'reading', fileName: selection.source.name });
|
||||
|
||||
try {
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffers = await Promise.all(
|
||||
[selection.source, ...selection.cabinetParts].map((file) =>
|
||||
file.arrayBuffer()
|
||||
)
|
||||
);
|
||||
if (generation.current !== requestGeneration) return;
|
||||
const response = await client.parseSource(sourceKind, file.name, bytes);
|
||||
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') {
|
||||
@@ -226,7 +227,7 @@ export function OneNoteApplication({
|
||||
if (response.type === 'section') {
|
||||
setState({
|
||||
phase: 'loaded',
|
||||
fileName: file.name,
|
||||
fileName: selection.source.name,
|
||||
source: {
|
||||
kind: 'one',
|
||||
sessionId: response.sessionId,
|
||||
@@ -253,7 +254,7 @@ export function OneNoteApplication({
|
||||
setSelectedSectionId(preferredSection?.id);
|
||||
setState({
|
||||
phase: 'loaded',
|
||||
fileName: file.name,
|
||||
fileName: selection.source.name,
|
||||
source: {
|
||||
kind: 'onepkg',
|
||||
sessionId: response.sessionId,
|
||||
@@ -373,7 +374,7 @@ export function OneNoteApplication({
|
||||
return (
|
||||
<main className="application">
|
||||
{state.phase === 'idle' ? (
|
||||
<FileDropZone onFile={(file) => void openFile(file)} />
|
||||
<FileDropZone onFiles={(files) => void openFiles(files)} />
|
||||
) : null}
|
||||
|
||||
{state.phase === 'reading' ? (
|
||||
|
||||
@@ -2,25 +2,25 @@ import { useRef, useState, type ChangeEvent, type DragEvent } from 'react';
|
||||
|
||||
interface FileDropZoneProps {
|
||||
disabled?: boolean;
|
||||
onFile(file: File): void;
|
||||
onFiles(files: File[]): void;
|
||||
}
|
||||
|
||||
export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
|
||||
export function FileDropZone({ disabled = false, onFiles }: FileDropZoneProps) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const selectFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
const selectFiles = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = [...(event.target.files ?? [])];
|
||||
event.target.value = '';
|
||||
if (file) onFile(file);
|
||||
if (files.length > 0) onFiles(files);
|
||||
};
|
||||
|
||||
const dropFile = (event: DragEvent<HTMLDivElement>) => {
|
||||
const dropFiles = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
if (disabled) return;
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) onFile(file);
|
||||
const files = [...event.dataTransfer.files];
|
||||
if (files.length > 0) onFiles(files);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -36,23 +36,24 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
|
||||
setDragging(false);
|
||||
}
|
||||
}}
|
||||
onDrop={dropFile}
|
||||
onDrop={dropFiles}
|
||||
data-testid="file-drop-zone"
|
||||
>
|
||||
<p className="drop-zone__eyebrow">Private, local-first reader</p>
|
||||
<h2>Open a OneNote file</h2>
|
||||
<p>
|
||||
Inspect a <code>.one</code> section or <code>.onepkg</code> notebook
|
||||
package without sending it to a server.
|
||||
Inspect a <code>.one</code> section, or a <code>.onepkg</code> notebook
|
||||
with any linked <code>.cab</code> parts, without sending it to a server.
|
||||
</p>
|
||||
<input
|
||||
ref={input}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
accept=".one,.onepkg"
|
||||
accept=".one,.onepkg,.cab"
|
||||
multiple
|
||||
disabled={disabled}
|
||||
onChange={selectFile}
|
||||
aria-label="Choose a OneNote file"
|
||||
onChange={selectFiles}
|
||||
aria-label="Choose OneNote files"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,10 +61,10 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
|
||||
disabled={disabled}
|
||||
onClick={() => input.current?.click()}
|
||||
>
|
||||
Choose OneNote file
|
||||
Choose OneNote files
|
||||
</button>
|
||||
<span className="drop-zone__hint">
|
||||
or drop one .one or .onepkg file here
|
||||
or drop one .one, or one .onepkg with its .cab parts, here
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
72
src/source-file.test.ts
Normal file
72
src/source-file.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
MAX_SOURCE_FILE_BYTES,
|
||||
selectSourceFiles,
|
||||
sourceKindFromFileName,
|
||||
} from './source-file.js';
|
||||
|
||||
function source(name: string, size = 1) {
|
||||
return { name, size };
|
||||
}
|
||||
|
||||
describe('local source selection', () => {
|
||||
it('accepts exactly one .one file', () => {
|
||||
const section = source('Section.ONE');
|
||||
expect(sourceKindFromFileName(section.name)).toBe('one');
|
||||
expect(selectSourceFiles([section])).toEqual({
|
||||
ok: true,
|
||||
kind: 'one',
|
||||
source: section,
|
||||
cabinetParts: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('finds one .onepkg and keeps CAB companions in selection order', () => {
|
||||
const part3 = source('Notebook-3.CAB');
|
||||
const primary = source('Notebook.OnePkg');
|
||||
const part2 = source('Notebook-2.cab');
|
||||
expect(selectSourceFiles([part3, primary, part2])).toEqual({
|
||||
ok: true,
|
||||
kind: 'onepkg',
|
||||
source: primary,
|
||||
cabinetParts: [part3, part2],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate names, unsupported files, and ambiguous primaries', () => {
|
||||
expect(
|
||||
selectSourceFiles([
|
||||
source('Notebook.onepkg'),
|
||||
source('Part.cab'),
|
||||
source('PART.CAB'),
|
||||
])
|
||||
).toMatchObject({ ok: false, title: 'Duplicate file name' });
|
||||
expect(selectSourceFiles([source('Notebook.zip')])).toMatchObject({
|
||||
ok: false,
|
||||
title: 'Unsupported file name',
|
||||
});
|
||||
expect(
|
||||
selectSourceFiles([source('First.onepkg'), source('Second.onepkg')])
|
||||
).toMatchObject({ ok: false, title: 'Choose one OneNote source' });
|
||||
expect(
|
||||
selectSourceFiles([source('Section.one'), source('Notebook.onepkg')])
|
||||
).toMatchObject({ ok: false, title: 'Choose one OneNote source' });
|
||||
expect(selectSourceFiles([source('Part.cab')])).toMatchObject({
|
||||
ok: false,
|
||||
title: 'Choose one OneNote source',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies both per-file and aggregate 512 MiB limits', () => {
|
||||
expect(
|
||||
selectSourceFiles([source('Notebook.onepkg', MAX_SOURCE_FILE_BYTES + 1)])
|
||||
).toMatchObject({ ok: false, title: 'File is too large' });
|
||||
expect(
|
||||
selectSourceFiles([
|
||||
source('Notebook.onepkg', 300 * 1024 * 1024),
|
||||
source('Notebook-2.cab', 300 * 1024 * 1024),
|
||||
])
|
||||
).toMatchObject({ ok: false, title: 'Package set is too large' });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,33 @@
|
||||
import type { SourceKind } from './worker/onenote.client.js';
|
||||
|
||||
export const MAX_SOURCE_FILE_BYTES = 512 * 1024 * 1024;
|
||||
export const MAX_SOURCE_PARTS = 256;
|
||||
|
||||
export interface SourceFileDescriptor {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface SourceFileSelectionFailure {
|
||||
ok: false;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type SourceFileSelection<T extends SourceFileDescriptor> =
|
||||
| {
|
||||
ok: true;
|
||||
kind: 'one';
|
||||
source: T;
|
||||
cabinetParts: [];
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
kind: 'onepkg';
|
||||
source: T;
|
||||
cabinetParts: T[];
|
||||
}
|
||||
| SourceFileSelectionFailure;
|
||||
|
||||
export function sourceKindFromFileName(fileName: string): SourceKind | null {
|
||||
const normalized = fileName.toLocaleLowerCase('en-US');
|
||||
@@ -8,3 +35,101 @@ export function sourceKindFromFileName(fileName: string): SourceKind | null {
|
||||
if (normalized.endsWith('.one')) return 'one';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isCabinetPartFileName(fileName: string): boolean {
|
||||
return fileName.toLocaleLowerCase('en-US').endsWith('.cab');
|
||||
}
|
||||
|
||||
export function selectSourceFiles<T extends SourceFileDescriptor>(
|
||||
files: readonly T[]
|
||||
): SourceFileSelection<T> {
|
||||
if (files.length === 0) {
|
||||
return invalidSelection();
|
||||
}
|
||||
if (files.length > MAX_SOURCE_PARTS) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Too many package parts',
|
||||
message: `Choose at most ${MAX_SOURCE_PARTS} files for one cabinet set.`,
|
||||
};
|
||||
}
|
||||
|
||||
const sections: T[] = [];
|
||||
const packages: T[] = [];
|
||||
const cabinetParts: T[] = [];
|
||||
for (const file of files) {
|
||||
const kind = sourceKindFromFileName(file.name);
|
||||
if (kind === 'one') sections.push(file);
|
||||
else if (kind === 'onepkg') packages.push(file);
|
||||
else if (isCabinetPartFileName(file.name)) cabinetParts.push(file);
|
||||
else {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Unsupported file name',
|
||||
message:
|
||||
'Choose one .one file, or one .onepkg file together with its .cab parts.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const validSection =
|
||||
sections.length === 1 && packages.length === 0 && cabinetParts.length === 0;
|
||||
const validPackage = sections.length === 0 && packages.length === 1;
|
||||
if (!validSection && !validPackage) return invalidSelection();
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
let totalSize = 0;
|
||||
for (const file of files) {
|
||||
const nameKey = file.name.normalize('NFC').toLocaleLowerCase('en-US');
|
||||
if (seenNames.has(nameKey)) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Duplicate file name',
|
||||
message: `The selection contains more than one file named ${file.name}.`,
|
||||
};
|
||||
}
|
||||
seenNames.add(nameKey);
|
||||
if (!Number.isSafeInteger(file.size) || file.size < 0) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Invalid file size',
|
||||
message: `The browser reported an invalid size for ${file.name}.`,
|
||||
};
|
||||
}
|
||||
if (file.size > MAX_SOURCE_FILE_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'File is too large',
|
||||
message: `${file.name} exceeds the current 512 MiB safety limit.`,
|
||||
};
|
||||
}
|
||||
totalSize += file.size;
|
||||
if (!Number.isSafeInteger(totalSize) || totalSize > MAX_SOURCE_FILE_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Package set is too large',
|
||||
message:
|
||||
'The selected .onepkg and .cab files exceed the combined 512 MiB safety limit.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (validSection) {
|
||||
return { ok: true, kind: 'one', source: sections[0]!, cabinetParts: [] };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
kind: 'onepkg',
|
||||
source: packages[0]!,
|
||||
cabinetParts,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidSelection(): SourceFileSelectionFailure {
|
||||
return {
|
||||
ok: false,
|
||||
title: 'Choose one OneNote source',
|
||||
message:
|
||||
'Choose one .one file, or one .onepkg file together with its .cab parts.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,20 +29,29 @@ class FakeWorker implements WorkerPort {
|
||||
}
|
||||
|
||||
describe('OneNoteWorkerClient', () => {
|
||||
it('uses the typed parse-onepkg protocol and transfers the source buffer', async () => {
|
||||
it('transfers a ONEPKG and every named CAB companion', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const client = new OneNoteWorkerClient(() => worker);
|
||||
const bytes = new ArrayBuffer(16);
|
||||
const result = client.parseSource('onepkg', 'Notebook.onepkg', bytes);
|
||||
const second = new ArrayBuffer(8);
|
||||
const third = new ArrayBuffer(4);
|
||||
const result = client.parseSource('onepkg', 'Notebook.onepkg', bytes, [
|
||||
{ fileName: 'part-3.cab', bytes: third },
|
||||
{ fileName: 'part-2.cab', bytes: second },
|
||||
]);
|
||||
|
||||
expect(worker.lastMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'parse-onepkg',
|
||||
fileName: 'Notebook.onepkg',
|
||||
bytes,
|
||||
cabinetParts: [
|
||||
{ fileName: 'part-3.cab', bytes: third },
|
||||
{ fileName: 'part-2.cab', bytes: second },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(worker.lastTransfer).toEqual([bytes]);
|
||||
expect(worker.lastTransfer).toEqual([bytes, third, second]);
|
||||
|
||||
const requestId =
|
||||
worker.lastMessage && 'requestId' in worker.lastMessage
|
||||
@@ -143,7 +152,16 @@ describe('OneNoteWorkerClient', () => {
|
||||
it('rejects outstanding work when the worker is terminated', async () => {
|
||||
const worker = new FakeWorker();
|
||||
const client = new OneNoteWorkerClient(() => worker);
|
||||
const result = client.parseSource('one', 'Section.one', new ArrayBuffer(8));
|
||||
const bytes = new ArrayBuffer(8);
|
||||
const result = client.parseSource('one', 'Section.one', bytes);
|
||||
|
||||
expect(worker.lastMessage).toEqual({
|
||||
type: 'parse-one',
|
||||
requestId: 'parse-1',
|
||||
fileName: 'Section.one',
|
||||
bytes,
|
||||
});
|
||||
expect(worker.lastTransfer).toEqual([bytes]);
|
||||
|
||||
client.terminate();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ParseWorkerResponse,
|
||||
ResourceWorkerResponse,
|
||||
WorkerRequest,
|
||||
WorkerCabinetPart,
|
||||
WorkerFailure,
|
||||
WorkerResponse,
|
||||
} from './worker-protocol.js';
|
||||
@@ -68,7 +69,8 @@ export class OneNoteWorkerClient {
|
||||
parseSource(
|
||||
kind: SourceKind,
|
||||
fileName: string,
|
||||
bytes: ArrayBuffer
|
||||
bytes: ArrayBuffer,
|
||||
cabinetParts: readonly WorkerCabinetPart[] = []
|
||||
): Promise<ParseWorkerResponse> {
|
||||
if (this.terminated) {
|
||||
return Promise.reject(
|
||||
@@ -78,6 +80,14 @@ export class OneNoteWorkerClient {
|
||||
)
|
||||
);
|
||||
}
|
||||
if (kind === 'one' && cabinetParts.length > 0) {
|
||||
return Promise.reject(
|
||||
new WorkerClientError(
|
||||
'invalid-format',
|
||||
'CAB companions can only be supplied with a .onepkg source.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const requestId = `parse-${++this.requestSequence}`;
|
||||
const request: ParseWorkerRequest = {
|
||||
@@ -85,6 +95,9 @@ export class OneNoteWorkerClient {
|
||||
requestId,
|
||||
fileName,
|
||||
bytes,
|
||||
...(kind === 'onepkg' && cabinetParts.length > 0
|
||||
? { cabinetParts: [...cabinetParts] }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -92,7 +105,10 @@ export class OneNoteWorkerClient {
|
||||
resolve: (response) => resolve(response as ParseWorkerResponse),
|
||||
reject,
|
||||
});
|
||||
this.worker.postMessage(request, [bytes]);
|
||||
this.worker.postMessage(request, [
|
||||
bytes,
|
||||
...cabinetParts.map((part) => part.bytes),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OneNoteResource } from '../onenote/one/content-model.js';
|
||||
import {
|
||||
failureFromUnknown,
|
||||
openPackageForWorker,
|
||||
openPackageSetForWorker,
|
||||
openSectionForWorker,
|
||||
resourcePayload,
|
||||
workerPageKey,
|
||||
@@ -59,7 +60,22 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
||||
}
|
||||
case 'parse-onepkg':
|
||||
try {
|
||||
const parsed = await openPackageForWorker(
|
||||
const parsed =
|
||||
data.cabinetParts && data.cabinetParts.length > 0
|
||||
? await openPackageSetForWorker(
|
||||
[
|
||||
{
|
||||
fileName: data.fileName,
|
||||
bytes: new Uint8Array(data.bytes),
|
||||
},
|
||||
...data.cabinetParts.map((part) => ({
|
||||
fileName: part.fileName,
|
||||
bytes: new Uint8Array(part.bytes),
|
||||
})),
|
||||
],
|
||||
data.fileName
|
||||
)
|
||||
: await openPackageForWorker(
|
||||
new Uint8Array(data.bytes),
|
||||
data.fileName
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
failureFromUnknown,
|
||||
openPackageForWorker,
|
||||
openPackageSetForWorker,
|
||||
openSectionForWorker,
|
||||
resourcePayload,
|
||||
workerPageKey,
|
||||
@@ -16,6 +17,15 @@ function fixtureBytes(path: string): Uint8Array {
|
||||
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64'));
|
||||
}
|
||||
|
||||
function multiCabPart(index: number) {
|
||||
return {
|
||||
fileName: `cabd_multi_basic_pt${index}.cab`,
|
||||
bytes: fixtureBytes(
|
||||
`../../tests/fixtures/libmspack-multi-basic-pt${index}.cab.b64`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe('worker parser adapter', () => {
|
||||
it('opens a real section and retains stable page identifiers', () => {
|
||||
const result = openSectionForWorker(
|
||||
@@ -83,6 +93,24 @@ describe('worker parser adapter', () => {
|
||||
expect(result.resources).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it('opens named multi-cabinet package parts in arbitrary input order', async () => {
|
||||
const parts = [4, 1, 5, 2, 3].map(multiCabPart);
|
||||
const result = await openPackageSetForWorker(parts, 'Notebook.onepkg');
|
||||
|
||||
expect(result.notebook).toMatchObject({
|
||||
sourceName: 'Notebook.onepkg',
|
||||
sourceSize: parts.reduce((sum, part) => sum + part.bytes.length, 0),
|
||||
entries: [
|
||||
{ path: 'test1.txt', kind: 'other' },
|
||||
{ path: 'test2.txt', kind: 'other' },
|
||||
{ path: 'test3.txt', kind: 'other' },
|
||||
],
|
||||
sections: [],
|
||||
});
|
||||
expect(result.pages).toHaveLength(0);
|
||||
expect(result.resources).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('converts malformed section input into a controlled worker failure', () => {
|
||||
try {
|
||||
openSectionForWorker(new Uint8Array(20), 'broken.one');
|
||||
|
||||
@@ -15,7 +15,13 @@ import type {
|
||||
OneNoteResource,
|
||||
OneNoteSectionContentModel,
|
||||
} from '../onenote/one/content-model.js';
|
||||
import { OnePkgError, openOneNotePackage } from '../onenote/onepkg/index.js';
|
||||
import {
|
||||
OnePkgError,
|
||||
openOneNotePackage,
|
||||
openOneNotePackageSet,
|
||||
type CabSetPart,
|
||||
type OneNotePackageOpenOptions,
|
||||
} from '../onenote/onepkg/index.js';
|
||||
import type { WorkerFailure } from './worker-protocol.js';
|
||||
|
||||
export interface ParsedWorkerSection {
|
||||
@@ -135,12 +141,28 @@ export function openSectionForWorker(
|
||||
export async function openPackageForWorker(
|
||||
bytes: Uint8Array,
|
||||
sourceName: string
|
||||
): Promise<ParsedWorkerPackage> {
|
||||
return openPackageSourceForWorker({ kind: 'single', bytes }, sourceName);
|
||||
}
|
||||
|
||||
export async function openPackageSetForWorker(
|
||||
parts: CabSetPart[],
|
||||
sourceName: string
|
||||
): Promise<ParsedWorkerPackage> {
|
||||
return openPackageSourceForWorker({ kind: 'set', parts }, sourceName);
|
||||
}
|
||||
|
||||
async function openPackageSourceForWorker(
|
||||
source:
|
||||
| { kind: 'single'; bytes: Uint8Array }
|
||||
| { kind: 'set'; parts: CabSetPart[] },
|
||||
sourceName: string
|
||||
): Promise<ParsedWorkerPackage> {
|
||||
const contentBySection = new Map<string, OneNoteSectionContentModel>();
|
||||
const result = await openOneNotePackage(bytes, {
|
||||
const options: OneNotePackageOpenOptions = {
|
||||
sourceName,
|
||||
cancellation: {
|
||||
yield: () => new Promise((resolve) => setTimeout(resolve, 0)),
|
||||
yield: () => new Promise<void>((resolve) => setTimeout(resolve, 0)),
|
||||
},
|
||||
parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => {
|
||||
try {
|
||||
@@ -174,7 +196,11 @@ export async function openPackageForWorker(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const result =
|
||||
source.kind === 'single'
|
||||
? await openOneNotePackage(source.bytes, options)
|
||||
: await openOneNotePackageSet(source.parts, options);
|
||||
|
||||
const pages = new Map<string, OneNotePageDto>();
|
||||
const resources = new Map<string, OneNoteResource>();
|
||||
|
||||
@@ -5,6 +5,11 @@ import type {
|
||||
OneNoteSectionDto,
|
||||
} from '../onenote/model/dto.js';
|
||||
|
||||
export interface WorkerCabinetPart {
|
||||
fileName: string;
|
||||
bytes: ArrayBuffer;
|
||||
}
|
||||
|
||||
export type WorkerRequest =
|
||||
| {
|
||||
type: 'parse-one';
|
||||
@@ -17,6 +22,7 @@ export type WorkerRequest =
|
||||
requestId: string;
|
||||
fileName: string;
|
||||
bytes: ArrayBuffer;
|
||||
cabinetParts?: WorkerCabinetPart[];
|
||||
}
|
||||
| {
|
||||
type: 'get-page';
|
||||
|
||||
Reference in New Issue
Block a user