feat: open multi-part packages in browser

This commit is contained in:
2026-07-22 19:46:17 +02:00
parent 2c998952e5
commit 3b1777a5b3
11 changed files with 428 additions and 64 deletions

View File

@@ -52,10 +52,14 @@ class SuccessfulParserWorker implements WorkerPort {
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null; onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null; onerror: ((event: ErrorEvent) => void) | null = null;
terminated = false; terminated = false;
lastMessage: WorkerRequest | undefined;
lastTransfer: Transferable[] = [];
constructor(private readonly packageResult?: OneNotePackageDto) {} constructor(private readonly packageResult?: OneNotePackageDto) {}
postMessage(message: WorkerRequest): void { postMessage(message: WorkerRequest, transfer: Transferable[] = []): void {
this.lastMessage = message;
this.lastTransfer = transfer;
queueMicrotask(() => { queueMicrotask(() => {
if (message.type === 'parse-one') { if (message.type === 'parse-one') {
this.respond({ this.respond({
@@ -158,7 +162,7 @@ describe('OneNoteApplication', () => {
); );
await user.upload( await user.upload(
screen.getByLabelText('Choose a OneNote file'), screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1, 2, 3])], 'Section.one') new File([new Uint8Array([1, 2, 3])], 'Section.one')
); );
@@ -189,23 +193,74 @@ describe('OneNoteApplication', () => {
tree: { interpreted: false, nodes: [] }, tree: { interpreted: false, nodes: [] },
diagnostics: [], diagnostics: [],
}; };
const worker = new SuccessfulParserWorker(notebook);
render( render(
<OneNoteApplication <OneNoteApplication
createWorkerClient={() => createWorkerClient={() => new OneNoteWorkerClient(() => worker)}
new OneNoteWorkerClient(() => new SuccessfulParserWorker(notebook))
}
/> />
); );
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'), { fireEvent.drop(screen.getByTestId('file-drop-zone'), {
dataTransfer: { dataTransfer: {
files: [new File([new Uint8Array([1])], 'Notebook.onepkg')], files: [part3, primary, part2],
}, },
}); });
expect( expect(
await screen.findByRole('heading', { name: 'Package summary' }) await screen.findByRole('heading', { name: 'Package summary' })
).toBeInTheDocument(); ).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 () => { it('keeps a failed packaged section visible beside a readable one', async () => {
@@ -299,7 +354,7 @@ describe('OneNoteApplication', () => {
); );
await user.upload( await user.upload(
screen.getByLabelText('Choose a OneNote file'), screen.getByLabelText('Choose OneNote files'),
new File([new Uint8Array([1])], 'Notebook.onepkg') new File([new Uint8Array([1])], 'Notebook.onepkg')
); );
@@ -336,7 +391,7 @@ describe('OneNoteApplication', () => {
); );
await user.upload( await user.upload(
screen.getByLabelText('Choose a OneNote file'), screen.getByLabelText('Choose OneNote files'),
new File(['not OneNote'], 'Broken.one') new File(['not OneNote'], 'Broken.one')
); );
@@ -353,12 +408,12 @@ describe('OneNoteApplication', () => {
render(<OneNoteApplication />); render(<OneNoteApplication />);
await user.upload( await user.upload(
screen.getByLabelText('Choose a OneNote file'), screen.getByLabelText('Choose OneNote files'),
new File(['not a notebook'], 'notes.zip') new File(['not a notebook'], 'notes.zip')
); );
expect(await screen.findByRole('alert')).toHaveTextContent( 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, 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'); expect(await screen.findByRole('alert')).toHaveTextContent('512 MiB');
}); });

View File

@@ -20,10 +20,7 @@ import type {
OneNoteSectionDto, OneNoteSectionDto,
} from './onenote/model/dto.js'; } from './onenote/model/dto.js';
import type { ParserDiagnostic } from './onenote/model/diagnostics.js'; import type { ParserDiagnostic } from './onenote/model/diagnostics.js';
import { import { selectSourceFiles } from './source-file.js';
MAX_SOURCE_FILE_BYTES,
sourceKindFromFileName,
} from './source-file.js';
import { toolboxApp } from './toolbox/manifest.js'; import { toolboxApp } from './toolbox/manifest.js';
import { import {
OneNoteWorkerClient, OneNoteWorkerClient,
@@ -174,21 +171,13 @@ export function OneNoteApplication({
} }
}; };
const openFile = async (file: File) => { const openFiles = async (files: File[]) => {
const sourceKind = sourceKindFromFileName(file.name); const selection = selectSourceFiles(files);
if (!sourceKind) { if (!selection.ok) {
setState({ setState({
phase: 'error', phase: 'error',
title: 'Unsupported file name', title: selection.title,
message: 'Choose a file whose name ends in .one or .onepkg.', message: selection.message,
});
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.',
}); });
return; return;
} }
@@ -201,12 +190,24 @@ export function OneNoteApplication({
activeClient.current = client; activeClient.current = client;
setSelectedSectionId(undefined); setSelectedSectionId(undefined);
setPageState({ phase: 'empty' }); setPageState({ phase: 'empty' });
setState({ phase: 'reading', fileName: file.name }); setState({ phase: 'reading', fileName: selection.source.name });
try { try {
const bytes = await file.arrayBuffer(); const buffers = await Promise.all(
[selection.source, ...selection.cabinetParts].map((file) =>
file.arrayBuffer()
)
);
if (generation.current !== requestGeneration) return; 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 (generation.current !== requestGeneration) return;
if (response.type === 'failure') { if (response.type === 'failure') {
@@ -226,7 +227,7 @@ export function OneNoteApplication({
if (response.type === 'section') { if (response.type === 'section') {
setState({ setState({
phase: 'loaded', phase: 'loaded',
fileName: file.name, fileName: selection.source.name,
source: { source: {
kind: 'one', kind: 'one',
sessionId: response.sessionId, sessionId: response.sessionId,
@@ -253,7 +254,7 @@ export function OneNoteApplication({
setSelectedSectionId(preferredSection?.id); setSelectedSectionId(preferredSection?.id);
setState({ setState({
phase: 'loaded', phase: 'loaded',
fileName: file.name, fileName: selection.source.name,
source: { source: {
kind: 'onepkg', kind: 'onepkg',
sessionId: response.sessionId, sessionId: response.sessionId,
@@ -373,7 +374,7 @@ export function OneNoteApplication({
return ( return (
<main className="application"> <main className="application">
{state.phase === 'idle' ? ( {state.phase === 'idle' ? (
<FileDropZone onFile={(file) => void openFile(file)} /> <FileDropZone onFiles={(files) => void openFiles(files)} />
) : null} ) : null}
{state.phase === 'reading' ? ( {state.phase === 'reading' ? (

View File

@@ -2,25 +2,25 @@ import { useRef, useState, type ChangeEvent, type DragEvent } from 'react';
interface FileDropZoneProps { interface FileDropZoneProps {
disabled?: boolean; 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 input = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const selectFile = (event: ChangeEvent<HTMLInputElement>) => { const selectFiles = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const files = [...(event.target.files ?? [])];
event.target.value = ''; 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(); event.preventDefault();
setDragging(false); setDragging(false);
if (disabled) return; if (disabled) return;
const file = event.dataTransfer.files[0]; const files = [...event.dataTransfer.files];
if (file) onFile(file); if (files.length > 0) onFiles(files);
}; };
return ( return (
@@ -36,23 +36,24 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
setDragging(false); setDragging(false);
} }
}} }}
onDrop={dropFile} onDrop={dropFiles}
data-testid="file-drop-zone" data-testid="file-drop-zone"
> >
<p className="drop-zone__eyebrow">Private, local-first reader</p> <p className="drop-zone__eyebrow">Private, local-first reader</p>
<h2>Open a OneNote file</h2> <h2>Open a OneNote file</h2>
<p> <p>
Inspect a <code>.one</code> section or <code>.onepkg</code> notebook Inspect a <code>.one</code> section, or a <code>.onepkg</code> notebook
package without sending it to a server. with any linked <code>.cab</code> parts, without sending it to a server.
</p> </p>
<input <input
ref={input} ref={input}
className="visually-hidden" className="visually-hidden"
type="file" type="file"
accept=".one,.onepkg" accept=".one,.onepkg,.cab"
multiple
disabled={disabled} disabled={disabled}
onChange={selectFile} onChange={selectFiles}
aria-label="Choose a OneNote file" aria-label="Choose OneNote files"
/> />
<button <button
type="button" type="button"
@@ -60,10 +61,10 @@ export function FileDropZone({ disabled = false, onFile }: FileDropZoneProps) {
disabled={disabled} disabled={disabled}
onClick={() => input.current?.click()} onClick={() => input.current?.click()}
> >
Choose OneNote file Choose OneNote files
</button> </button>
<span className="drop-zone__hint"> <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> </span>
</div> </div>
); );

72
src/source-file.test.ts Normal file
View 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' });
});
});

View File

@@ -1,6 +1,33 @@
import type { SourceKind } from './worker/onenote.client.js'; import type { SourceKind } from './worker/onenote.client.js';
export const MAX_SOURCE_FILE_BYTES = 512 * 1024 * 1024; 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 { export function sourceKindFromFileName(fileName: string): SourceKind | null {
const normalized = fileName.toLocaleLowerCase('en-US'); const normalized = fileName.toLocaleLowerCase('en-US');
@@ -8,3 +35,101 @@ export function sourceKindFromFileName(fileName: string): SourceKind | null {
if (normalized.endsWith('.one')) return 'one'; if (normalized.endsWith('.one')) return 'one';
return null; 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.',
};
}

View File

@@ -29,20 +29,29 @@ class FakeWorker implements WorkerPort {
} }
describe('OneNoteWorkerClient', () => { 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 worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker); const client = new OneNoteWorkerClient(() => worker);
const bytes = new ArrayBuffer(16); 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(worker.lastMessage).toEqual(
expect.objectContaining({ expect.objectContaining({
type: 'parse-onepkg', type: 'parse-onepkg',
fileName: 'Notebook.onepkg', fileName: 'Notebook.onepkg',
bytes, 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 = const requestId =
worker.lastMessage && 'requestId' in worker.lastMessage worker.lastMessage && 'requestId' in worker.lastMessage
@@ -143,7 +152,16 @@ describe('OneNoteWorkerClient', () => {
it('rejects outstanding work when the worker is terminated', async () => { it('rejects outstanding work when the worker is terminated', async () => {
const worker = new FakeWorker(); const worker = new FakeWorker();
const client = new OneNoteWorkerClient(() => worker); 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(); client.terminate();

View File

@@ -4,6 +4,7 @@ import type {
ParseWorkerResponse, ParseWorkerResponse,
ResourceWorkerResponse, ResourceWorkerResponse,
WorkerRequest, WorkerRequest,
WorkerCabinetPart,
WorkerFailure, WorkerFailure,
WorkerResponse, WorkerResponse,
} from './worker-protocol.js'; } from './worker-protocol.js';
@@ -68,7 +69,8 @@ export class OneNoteWorkerClient {
parseSource( parseSource(
kind: SourceKind, kind: SourceKind,
fileName: string, fileName: string,
bytes: ArrayBuffer bytes: ArrayBuffer,
cabinetParts: readonly WorkerCabinetPart[] = []
): Promise<ParseWorkerResponse> { ): Promise<ParseWorkerResponse> {
if (this.terminated) { if (this.terminated) {
return Promise.reject( 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 requestId = `parse-${++this.requestSequence}`;
const request: ParseWorkerRequest = { const request: ParseWorkerRequest = {
@@ -85,6 +95,9 @@ export class OneNoteWorkerClient {
requestId, requestId,
fileName, fileName,
bytes, bytes,
...(kind === 'onepkg' && cabinetParts.length > 0
? { cabinetParts: [...cabinetParts] }
: {}),
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -92,7 +105,10 @@ export class OneNoteWorkerClient {
resolve: (response) => resolve(response as ParseWorkerResponse), resolve: (response) => resolve(response as ParseWorkerResponse),
reject, reject,
}); });
this.worker.postMessage(request, [bytes]); this.worker.postMessage(request, [
bytes,
...cabinetParts.map((part) => part.bytes),
]);
}); });
} }

View File

@@ -3,6 +3,7 @@ import type { OneNoteResource } from '../onenote/one/content-model.js';
import { import {
failureFromUnknown, failureFromUnknown,
openPackageForWorker, openPackageForWorker,
openPackageSetForWorker,
openSectionForWorker, openSectionForWorker,
resourcePayload, resourcePayload,
workerPageKey, workerPageKey,
@@ -59,10 +60,25 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
} }
case 'parse-onepkg': case 'parse-onepkg':
try { try {
const parsed = await openPackageForWorker( const parsed =
new Uint8Array(data.bytes), data.cabinetParts && data.cabinetParts.length > 0
data.fileName ? 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
);
const sessionId = createSession(parsed.pages, parsed.resources); const sessionId = createSession(parsed.pages, parsed.resources);
workerScope.postMessage({ workerScope.postMessage({
type: 'package', type: 'package',

View File

@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';
import { import {
failureFromUnknown, failureFromUnknown,
openPackageForWorker, openPackageForWorker,
openPackageSetForWorker,
openSectionForWorker, openSectionForWorker,
resourcePayload, resourcePayload,
workerPageKey, workerPageKey,
@@ -16,6 +17,15 @@ function fixtureBytes(path: string): Uint8Array {
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64')); 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', () => { describe('worker parser adapter', () => {
it('opens a real section and retains stable page identifiers', () => { it('opens a real section and retains stable page identifiers', () => {
const result = openSectionForWorker( const result = openSectionForWorker(
@@ -83,6 +93,24 @@ describe('worker parser adapter', () => {
expect(result.resources).toBeInstanceOf(Map); 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', () => { it('converts malformed section input into a controlled worker failure', () => {
try { try {
openSectionForWorker(new Uint8Array(20), 'broken.one'); openSectionForWorker(new Uint8Array(20), 'broken.one');

View File

@@ -15,7 +15,13 @@ import type {
OneNoteResource, OneNoteResource,
OneNoteSectionContentModel, OneNoteSectionContentModel,
} from '../onenote/one/content-model.js'; } 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'; import type { WorkerFailure } from './worker-protocol.js';
export interface ParsedWorkerSection { export interface ParsedWorkerSection {
@@ -135,12 +141,28 @@ export function openSectionForWorker(
export async function openPackageForWorker( export async function openPackageForWorker(
bytes: Uint8Array, bytes: Uint8Array,
sourceName: string 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> { ): Promise<ParsedWorkerPackage> {
const contentBySection = new Map<string, OneNoteSectionContentModel>(); const contentBySection = new Map<string, OneNoteSectionContentModel>();
const result = await openOneNotePackage(bytes, { const options: OneNotePackageOpenOptions = {
sourceName, sourceName,
cancellation: { cancellation: {
yield: () => new Promise((resolve) => setTimeout(resolve, 0)), yield: () => new Promise<void>((resolve) => setTimeout(resolve, 0)),
}, },
parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => { parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => {
try { try {
@@ -174,7 +196,11 @@ export async function openPackageForWorker(
throw error; throw error;
} }
}, },
}); };
const result =
source.kind === 'single'
? await openOneNotePackage(source.bytes, options)
: await openOneNotePackageSet(source.parts, options);
const pages = new Map<string, OneNotePageDto>(); const pages = new Map<string, OneNotePageDto>();
const resources = new Map<string, OneNoteResource>(); const resources = new Map<string, OneNoteResource>();

View File

@@ -5,6 +5,11 @@ import type {
OneNoteSectionDto, OneNoteSectionDto,
} from '../onenote/model/dto.js'; } from '../onenote/model/dto.js';
export interface WorkerCabinetPart {
fileName: string;
bytes: ArrayBuffer;
}
export type WorkerRequest = export type WorkerRequest =
| { | {
type: 'parse-one'; type: 'parse-one';
@@ -17,6 +22,7 @@ export type WorkerRequest =
requestId: string; requestId: string;
fileName: string; fileName: string;
bytes: ArrayBuffer; bytes: ArrayBuffer;
cabinetParts?: WorkerCabinetPart[];
} }
| { | {
type: 'get-page'; type: 'get-page';