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;
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
47
src/App.tsx
47
src/App.tsx
@@ -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' ? (
|
||||||
|
|||||||
@@ -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
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';
|
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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +60,22 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
|||||||
}
|
}
|
||||||
case 'parse-onepkg':
|
case 'parse-onepkg':
|
||||||
try {
|
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),
|
new Uint8Array(data.bytes),
|
||||||
data.fileName
|
data.fileName
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user