feat: render rich OneNote pages and resources
This commit is contained in:
@@ -80,7 +80,24 @@ class SuccessfulParserWorker implements WorkerPort {
|
|||||||
page: {
|
page: {
|
||||||
...page,
|
...page,
|
||||||
id: message.pageId,
|
id: message.pageId,
|
||||||
blocks: [{ kind: 'text', text: page.text }],
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
id: 'text-1',
|
||||||
|
sourceText: page.text,
|
||||||
|
text: page.text,
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
start: 0,
|
||||||
|
end: page.text.length,
|
||||||
|
text: page.text,
|
||||||
|
style: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
65
src/App.tsx
65
src/App.tsx
@@ -16,6 +16,7 @@ import type {
|
|||||||
OneNotePackageDto,
|
OneNotePackageDto,
|
||||||
OneNotePackagedSectionDto,
|
OneNotePackagedSectionDto,
|
||||||
OneNotePageDto,
|
OneNotePageDto,
|
||||||
|
OneNoteResourcePayloadDto,
|
||||||
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';
|
||||||
@@ -302,6 +303,54 @@ export function OneNoteApplication({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSelectedResource = useCallback(
|
||||||
|
async (resourceId: string): Promise<OneNoteResourcePayloadDto> => {
|
||||||
|
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.getResource(
|
||||||
|
state.source.sessionId,
|
||||||
|
resourceId,
|
||||||
|
state.source.kind === 'onepkg' ? selectedSectionId : undefined
|
||||||
|
);
|
||||||
|
if (response.type === 'failure') {
|
||||||
|
throw new WorkerClientError(
|
||||||
|
response.error.code,
|
||||||
|
response.error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.resource;
|
||||||
|
},
|
||||||
|
[selectedSectionId, state]
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadSelectedResource = useCallback(
|
||||||
|
async (resourceId: string, suggestedName?: string): Promise<void> => {
|
||||||
|
const resource = await loadSelectedResource(resourceId);
|
||||||
|
const filename = safeDownloadFilename(
|
||||||
|
resource.filename ??
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
[loadSelectedResource]
|
||||||
|
);
|
||||||
|
|
||||||
const visibleDiagnostics = uniqueDiagnostics(
|
const visibleDiagnostics = uniqueDiagnostics(
|
||||||
(() => {
|
(() => {
|
||||||
if (state.phase !== 'loaded') return [];
|
if (state.phase !== 'loaded') return [];
|
||||||
@@ -390,6 +439,8 @@ export function OneNoteApplication({
|
|||||||
error={
|
error={
|
||||||
pageState.phase === 'error' ? pageState.message : undefined
|
pageState.phase === 'error' ? pageState.message : undefined
|
||||||
}
|
}
|
||||||
|
loadResource={loadSelectedResource}
|
||||||
|
downloadResource={downloadSelectedResource}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -420,6 +471,20 @@ export function OneNoteApplication({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeDownloadFilename(value: string): string {
|
||||||
|
const basename = value.split(/[\\/]/u).at(-1) ?? value;
|
||||||
|
const withoutUnsafeCharacters = [...basename.normalize('NFC')]
|
||||||
|
.map((character) => {
|
||||||
|
const code = character.codePointAt(0) ?? 0;
|
||||||
|
return code <= 0x1f || code === 0x7f || '<>:"|?*'.includes(character)
|
||||||
|
? '_'
|
||||||
|
: character;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
const cleaned = withoutUnsafeCharacters.replace(/[. ]+$/gu, '').trim();
|
||||||
|
return (cleaned || 'onenote-resource.bin').slice(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
||||||
|
|||||||
145
src/components/PageReader.test.tsx
Normal file
145
src/components/PageReader.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { OneNotePageDto } from '../onenote/model/dto.js';
|
||||||
|
import { PageReader } from './PageReader.js';
|
||||||
|
|
||||||
|
const page: OneNotePageDto = {
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Visual page',
|
||||||
|
text: 'Styled link\nA\tB',
|
||||||
|
textPreview: 'Styled link A B',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
id: 'text-1',
|
||||||
|
sourceText: 'Styled link',
|
||||||
|
text: 'Styled link',
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
start: 0,
|
||||||
|
end: 7,
|
||||||
|
text: 'Styled ',
|
||||||
|
style: {
|
||||||
|
bold: true,
|
||||||
|
highlight: { kind: 'rgb', red: 255, green: 255, blue: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: 7,
|
||||||
|
end: 11,
|
||||||
|
text: 'link',
|
||||||
|
style: { underline: true },
|
||||||
|
href: 'https://example.com/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'table',
|
||||||
|
id: 'table-1',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: 'row-1',
|
||||||
|
cells: [
|
||||||
|
{ id: 'cell-a', blocks: [textBlock('A', 'text-a')] },
|
||||||
|
{ id: 'cell-b', blocks: [textBlock('B', 'text-b')] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
columnWidths: [2, 3],
|
||||||
|
lockedColumns: [false, false],
|
||||||
|
bordersVisible: true,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'image',
|
||||||
|
id: 'image-1',
|
||||||
|
resourceId: 'resource-image',
|
||||||
|
altText: 'Embedded diagram',
|
||||||
|
isBackground: false,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'attachment',
|
||||||
|
id: 'attachment-1',
|
||||||
|
resourceId: 'resource-file',
|
||||||
|
filename: 'notes.txt',
|
||||||
|
size: 12,
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('PageReader structured content', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('URL', {
|
||||||
|
...URL,
|
||||||
|
createObjectURL: vi.fn(() => 'blob:local-image'),
|
||||||
|
revokeObjectURL: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders semantic formatting, links, tables, and a signed image resource', async () => {
|
||||||
|
const loadResource = vi.fn(async () => ({
|
||||||
|
id: 'resource-image',
|
||||||
|
kind: 'image' as const,
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: true,
|
||||||
|
size: 8,
|
||||||
|
bytes: Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10).buffer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PageReader page={page} loading={false} loadResource={loadResource} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Styled').parentElement).toHaveStyle({
|
||||||
|
fontWeight: '700',
|
||||||
|
});
|
||||||
|
expect(screen.getByRole('link', { name: 'link' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://example.com/'
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('table')).toHaveTextContent('AB');
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole('img', { name: 'Embedded diagram' })
|
||||||
|
).toHaveAttribute('src', 'blob:local-image')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads an attachment only after an explicit user action', async () => {
|
||||||
|
const downloadResource = vi.fn(async () => undefined);
|
||||||
|
render(
|
||||||
|
<PageReader
|
||||||
|
page={page}
|
||||||
|
loading={false}
|
||||||
|
loadResource={async () => {
|
||||||
|
throw new Error('not renderable');
|
||||||
|
}}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getByRole('button', { name: 'Download attachment' })
|
||||||
|
);
|
||||||
|
expect(downloadResource).toHaveBeenCalledWith('resource-file', 'notes.txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function textBlock(text: string, id: string) {
|
||||||
|
return {
|
||||||
|
kind: 'text' as const,
|
||||||
|
id,
|
||||||
|
sourceText: text,
|
||||||
|
text,
|
||||||
|
runs: [{ start: 0, end: text.length, text, style: {} }],
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,26 @@
|
|||||||
import type { OneNotePageDto } from '../onenote/model/dto.js';
|
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
OneNotePageDto,
|
||||||
|
OneNoteResourcePayloadDto,
|
||||||
|
} from '../onenote/model/dto.js';
|
||||||
|
import type {
|
||||||
|
OneNoteColor,
|
||||||
|
OneNoteContentBlock,
|
||||||
|
OneNoteImageBlock,
|
||||||
|
OneNoteInkBlock,
|
||||||
|
OneNoteInkStroke,
|
||||||
|
OneNoteLayout,
|
||||||
|
OneNoteTextRun,
|
||||||
|
OneNoteTextStyle,
|
||||||
|
} from '../onenote/one/content-model.js';
|
||||||
|
|
||||||
interface PageReaderProps {
|
interface PageReaderProps {
|
||||||
page?: OneNotePageDto;
|
page?: OneNotePageDto;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
loadResource?(resourceId: string): Promise<OneNoteResourcePayloadDto>;
|
||||||
|
downloadResource?(resourceId: string, filename?: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string | undefined): string | null {
|
function formatDate(value: string | undefined): string | null {
|
||||||
@@ -16,11 +33,17 @@ function formatDate(value: string | undefined): string | null {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageReader({ page, loading, error }: PageReaderProps) {
|
export function PageReader({
|
||||||
|
page,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
loadResource,
|
||||||
|
downloadResource,
|
||||||
|
}: PageReaderProps) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<section className="page-reader page-reader--empty" aria-live="polite">
|
<section className="page-reader page-reader--empty" aria-live="polite">
|
||||||
<p>Loading extracted page text…</p>
|
<p>Loading structured page content…</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,7 +61,7 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
|
|||||||
if (!page) {
|
if (!page) {
|
||||||
return (
|
return (
|
||||||
<section className="page-reader page-reader--empty">
|
<section className="page-reader page-reader--empty">
|
||||||
<p>Select a readable page to inspect its extracted text.</p>
|
<p>Select a readable page to inspect its content.</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -71,37 +94,542 @@ export function PageReader({ page, loading, error }: PageReaderProps) {
|
|||||||
|
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
{page.blocks.length === 0 && page.text.length === 0 ? (
|
{page.blocks.length === 0 && page.text.length === 0 ? (
|
||||||
<p className="empty-message">No readable plain text was extracted.</p>
|
<p className="empty-message">No readable content was extracted.</p>
|
||||||
) : page.blocks.length === 0 ? (
|
) : page.blocks.length === 0 ? (
|
||||||
<p className="extracted-text">{page.text}</p>
|
<p className="extracted-text">{page.text}</p>
|
||||||
) : (
|
) : (
|
||||||
page.blocks.map((block, index) => {
|
<ContentBlocks
|
||||||
switch (block.kind) {
|
blocks={page.blocks}
|
||||||
case 'text':
|
loadResource={loadResource}
|
||||||
return (
|
downloadResource={downloadResource}
|
||||||
<p className="extracted-text" key={index}>
|
/>
|
||||||
{block.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
case 'line-break':
|
|
||||||
return <br key={index} />;
|
|
||||||
case 'link':
|
|
||||||
return (
|
|
||||||
<p className="extracted-text" key={index}>
|
|
||||||
{block.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
case 'unsupported':
|
|
||||||
return (
|
|
||||||
<aside className="unsupported-block" key={index}>
|
|
||||||
<strong>{block.sourceKind}</strong>
|
|
||||||
<span>{block.description}</span>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ContentBlocks({
|
||||||
|
blocks,
|
||||||
|
loadResource,
|
||||||
|
downloadResource,
|
||||||
|
}: {
|
||||||
|
blocks: readonly OneNoteContentBlock[];
|
||||||
|
loadResource?: PageReaderProps['loadResource'];
|
||||||
|
downloadResource?: PageReaderProps['downloadResource'];
|
||||||
|
}) {
|
||||||
|
return blocks.map((block) => (
|
||||||
|
<ContentBlockView
|
||||||
|
key={block.id}
|
||||||
|
block={block}
|
||||||
|
loadResource={loadResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentBlockView({
|
||||||
|
block,
|
||||||
|
loadResource,
|
||||||
|
downloadResource,
|
||||||
|
}: {
|
||||||
|
block: OneNoteContentBlock;
|
||||||
|
loadResource?: PageReaderProps['loadResource'];
|
||||||
|
downloadResource?: PageReaderProps['downloadResource'];
|
||||||
|
}) {
|
||||||
|
switch (block.kind) {
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className="onenote-text"
|
||||||
|
dir={block.rightToLeft ? 'rtl' : undefined}
|
||||||
|
style={{
|
||||||
|
...layoutStyle(block.layout),
|
||||||
|
...paragraphStyle(block),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{block.runs.map((run, index) => (
|
||||||
|
<TextRunView key={`${run.start}-${run.end}-${index}`} run={run} />
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
case 'outline':
|
||||||
|
return (
|
||||||
|
<section className="onenote-outline" style={layoutStyle(block.layout)}>
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={block.children}
|
||||||
|
loadResource={loadResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
case 'outline-group':
|
||||||
|
return (
|
||||||
|
<div className="onenote-outline-group">
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={block.children}
|
||||||
|
loadResource={loadResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'outline-element':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="onenote-outline-element"
|
||||||
|
style={{
|
||||||
|
marginInlineStart: `${Math.min(block.childLevel ?? 0, 12) * 1.1}rem`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={block.contents}
|
||||||
|
loadResource={loadResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={block.children}
|
||||||
|
loadResource={loadResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'table':
|
||||||
|
return (
|
||||||
|
<div className="onenote-table-scroll" style={layoutStyle(block.layout)}>
|
||||||
|
<table
|
||||||
|
className={
|
||||||
|
block.bordersVisible
|
||||||
|
? 'onenote-table'
|
||||||
|
: 'onenote-table onenote-table--borderless'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{block.columnWidths.length > 0 ? (
|
||||||
|
<colgroup>
|
||||||
|
{block.columnWidths.map((width, index) => (
|
||||||
|
<col key={index} style={{ width: halfInches(width) }} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
) : null}
|
||||||
|
<tbody>
|
||||||
|
{block.rows.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
{row.cells.map((cell) => (
|
||||||
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorCss(cell.backgroundColor),
|
||||||
|
maxWidth: halfInches(cell.maxWidth),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContentBlocks
|
||||||
|
blocks={cell.blocks}
|
||||||
|
loadResource={loadResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<ImageView
|
||||||
|
image={block}
|
||||||
|
loadResource={loadResource}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'attachment':
|
||||||
|
return (
|
||||||
|
<AttachmentView
|
||||||
|
resourceId={block.resourceId}
|
||||||
|
filename={block.filename}
|
||||||
|
size={block.size}
|
||||||
|
downloadResource={downloadResource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'ink':
|
||||||
|
return <InkView ink={block} />;
|
||||||
|
case 'unsupported':
|
||||||
|
return (
|
||||||
|
<aside className="unsupported-block">
|
||||||
|
<strong>Unsupported object 0x{block.sourceJcid.toString(16)}</strong>
|
||||||
|
<span>{block.description}</span>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextRunView({ run }: { run: OneNoteTextRun }) {
|
||||||
|
if (run.style.hidden) return null;
|
||||||
|
const content = textWithBreaks(run.text);
|
||||||
|
const style = textStyle(run.style);
|
||||||
|
return run.href ? (
|
||||||
|
<a
|
||||||
|
className="onenote-link"
|
||||||
|
href={run.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={run.href}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span style={style}>{content}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textWithBreaks(text: string): ReactNode[] {
|
||||||
|
return text
|
||||||
|
.replaceAll('\u000b', '\n')
|
||||||
|
.split('\n')
|
||||||
|
.flatMap((part, index, values) =>
|
||||||
|
index + 1 < values.length
|
||||||
|
? [
|
||||||
|
<span key={`${index}-text`}>{part}</span>,
|
||||||
|
<br key={`${index}-br`} />,
|
||||||
|
]
|
||||||
|
: [<span key={`${index}-text`}>{part}</span>]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageView({
|
||||||
|
image,
|
||||||
|
loadResource,
|
||||||
|
downloadResource,
|
||||||
|
}: {
|
||||||
|
image: OneNoteImageBlock;
|
||||||
|
loadResource?: PageReaderProps['loadResource'];
|
||||||
|
downloadResource?: PageReaderProps['downloadResource'];
|
||||||
|
}) {
|
||||||
|
const [state, setState] = useState<
|
||||||
|
| { phase: 'loading' }
|
||||||
|
| { phase: 'ready'; resourceId: string; url: string }
|
||||||
|
| { phase: 'unavailable'; resourceId: string; message: string }
|
||||||
|
>({ phase: 'loading' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let objectUrl: string | undefined;
|
||||||
|
if (!image.resourceId || !loadResource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resourceId = image.resourceId;
|
||||||
|
void loadResource(image.resourceId)
|
||||||
|
.then((resource) => {
|
||||||
|
if (
|
||||||
|
disposed ||
|
||||||
|
!resource.browserRenderable ||
|
||||||
|
(resource.mediaType !== 'image/png' &&
|
||||||
|
resource.mediaType !== 'image/jpeg')
|
||||||
|
) {
|
||||||
|
if (!disposed) {
|
||||||
|
setState({
|
||||||
|
phase: 'unavailable',
|
||||||
|
resourceId,
|
||||||
|
message:
|
||||||
|
'This image format can be downloaded but not displayed safely.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
objectUrl = URL.createObjectURL(
|
||||||
|
new Blob([resource.bytes], { type: resource.mediaType })
|
||||||
|
);
|
||||||
|
setState({ phase: 'ready', resourceId, url: objectUrl });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!disposed) {
|
||||||
|
setState({
|
||||||
|
phase: 'unavailable',
|
||||||
|
resourceId,
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The image could not be loaded.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [image.resourceId, loadResource]);
|
||||||
|
|
||||||
|
const visibleState =
|
||||||
|
!image.resourceId || !loadResource
|
||||||
|
? {
|
||||||
|
phase: 'unavailable' as const,
|
||||||
|
resourceId: image.resourceId ?? '',
|
||||||
|
message: 'The image payload is not available.',
|
||||||
|
}
|
||||||
|
: state.phase !== 'loading' && state.resourceId === image.resourceId
|
||||||
|
? state
|
||||||
|
: { phase: 'loading' as const };
|
||||||
|
|
||||||
|
const fallback =
|
||||||
|
visibleState.phase === 'unavailable'
|
||||||
|
? visibleState.message
|
||||||
|
: 'Loading embedded image…';
|
||||||
|
const visual =
|
||||||
|
visibleState.phase === 'ready' ? (
|
||||||
|
<img
|
||||||
|
src={visibleState.url}
|
||||||
|
alt={image.altText ?? image.filename ?? ''}
|
||||||
|
style={{
|
||||||
|
width: halfInches(image.pictureWidth),
|
||||||
|
height: halfInches(image.pictureHeight),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="image-placeholder" role="img" aria-label={image.altText}>
|
||||||
|
{fallback}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="onenote-image" style={layoutStyle(image.layout)}>
|
||||||
|
{image.href && visibleState.phase === 'ready' ? (
|
||||||
|
<a
|
||||||
|
href={image.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={image.href}
|
||||||
|
>
|
||||||
|
{visual}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
visual
|
||||||
|
)}
|
||||||
|
{image.altText || image.filename ? (
|
||||||
|
<figcaption>{image.altText ?? image.filename}</figcaption>
|
||||||
|
) : null}
|
||||||
|
{visibleState.phase === 'unavailable' &&
|
||||||
|
image.resourceId &&
|
||||||
|
downloadResource ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void downloadResource(image.resourceId!, image.filename)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Download original image
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InkView({ ink }: { ink: OneNoteInkBlock }) {
|
||||||
|
const box = ink.boundingBox ?? boundingBoxFromInk(ink.strokes);
|
||||||
|
const padding = Math.max(
|
||||||
|
140,
|
||||||
|
...ink.strokes.map((stroke) => stroke.width ?? 0)
|
||||||
|
);
|
||||||
|
const viewBox = box
|
||||||
|
? `${box.x - padding / 2} ${box.y - padding / 2} ${Math.max(box.width + padding, 1)} ${Math.max(box.height + padding, 1)}`
|
||||||
|
: '0 0 1 1';
|
||||||
|
const width = box ? Math.max((box.width + padding) / (2540 / 96), 1) : 1;
|
||||||
|
const height = box ? Math.max((box.height + padding) / (2540 / 96), 1) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="onenote-ink" style={layoutStyle(ink.layout)}>
|
||||||
|
{ink.strokes.length > 0 ? (
|
||||||
|
<svg
|
||||||
|
viewBox={viewBox}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${ink.strokes.length} handwritten or drawn stroke${ink.strokes.length === 1 ? '' : 's'}`}
|
||||||
|
>
|
||||||
|
{ink.strokes.map((stroke, index) => (
|
||||||
|
<path
|
||||||
|
key={index}
|
||||||
|
d={inkPath(stroke)}
|
||||||
|
fill="none"
|
||||||
|
stroke={inkColor(stroke.color)}
|
||||||
|
strokeWidth={Math.max(stroke.width ?? 140, 1)}
|
||||||
|
strokeOpacity={
|
||||||
|
stroke.transparency === undefined
|
||||||
|
? undefined
|
||||||
|
: Math.max(0, Math.min(1, (255 - stroke.transparency) / 256))
|
||||||
|
}
|
||||||
|
strokeLinecap={stroke.penTip === 0 ? 'round' : 'square'}
|
||||||
|
strokeLinejoin={stroke.penTip === 0 ? 'round' : 'bevel'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
{ink.children.map((child) => (
|
||||||
|
<InkView key={child.id} ink={child} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inkPath(stroke: OneNoteInkStroke): string {
|
||||||
|
const [first, ...deltas] = stroke.points;
|
||||||
|
if (!first) return '';
|
||||||
|
if (deltas.length === 0) return `M ${first.x} ${first.y} l 0 0`;
|
||||||
|
return `M ${first.x} ${first.y} l ${deltas
|
||||||
|
.map((point) => `${point.x} ${point.y}`)
|
||||||
|
.join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundingBoxFromInk(
|
||||||
|
strokes: readonly OneNoteInkStroke[]
|
||||||
|
): { x: number; y: number; width: number; height: number } | undefined {
|
||||||
|
let xMin = Infinity;
|
||||||
|
let yMin = Infinity;
|
||||||
|
let xMax = -Infinity;
|
||||||
|
let yMax = -Infinity;
|
||||||
|
for (const stroke of strokes) {
|
||||||
|
const [first, ...deltas] = stroke.points;
|
||||||
|
if (!first) continue;
|
||||||
|
let x = first.x;
|
||||||
|
let y = first.y;
|
||||||
|
xMin = Math.min(xMin, x);
|
||||||
|
yMin = Math.min(yMin, y);
|
||||||
|
xMax = Math.max(xMax, x);
|
||||||
|
yMax = Math.max(yMax, y);
|
||||||
|
for (const delta of deltas) {
|
||||||
|
x += delta.x;
|
||||||
|
y += delta.y;
|
||||||
|
xMin = Math.min(xMin, x);
|
||||||
|
yMin = Math.min(yMin, y);
|
||||||
|
xMax = Math.max(xMax, x);
|
||||||
|
yMax = Math.max(yMax, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Number.isFinite(xMin)
|
||||||
|
? { x: xMin, y: yMin, width: xMax - xMin, height: yMax - yMin }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inkColor(value: number | undefined): string {
|
||||||
|
if (value === undefined) return 'currentColor';
|
||||||
|
return `rgb(${value & 0xff}, ${(value >>> 8) & 0xff}, ${(value >>> 16) & 0xff})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachmentView({
|
||||||
|
resourceId,
|
||||||
|
filename,
|
||||||
|
size,
|
||||||
|
downloadResource,
|
||||||
|
}: {
|
||||||
|
resourceId?: string;
|
||||||
|
filename: string;
|
||||||
|
size?: number;
|
||||||
|
downloadResource?: PageReaderProps['downloadResource'];
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState<string>();
|
||||||
|
const download = async () => {
|
||||||
|
if (!resourceId || !downloadResource) return;
|
||||||
|
setStatus('Preparing local download…');
|
||||||
|
try {
|
||||||
|
await downloadResource(resourceId, filename);
|
||||||
|
setStatus(undefined);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The attachment could not be downloaded.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<aside className="attachment-card">
|
||||||
|
<div>
|
||||||
|
<strong>{filename}</strong>
|
||||||
|
{size === undefined ? null : <span>{formatBytes(size)}</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!resourceId || !downloadResource}
|
||||||
|
onClick={() => void download()}
|
||||||
|
>
|
||||||
|
Download attachment
|
||||||
|
</button>
|
||||||
|
{status ? <small role="status">{status}</small> : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphStyle(
|
||||||
|
block: Extract<OneNoteContentBlock, { kind: 'text' }>
|
||||||
|
): CSSProperties {
|
||||||
|
return {
|
||||||
|
textAlign:
|
||||||
|
block.paragraphAlignment === 'unknown'
|
||||||
|
? undefined
|
||||||
|
: block.paragraphAlignment,
|
||||||
|
marginTop: halfInches(block.paragraphSpaceBefore),
|
||||||
|
marginBottom: halfInches(block.paragraphSpaceAfter),
|
||||||
|
lineHeight:
|
||||||
|
block.paragraphLineSpacingExact === undefined
|
||||||
|
? undefined
|
||||||
|
: halfInches(block.paragraphLineSpacingExact),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function textStyle(style: OneNoteTextStyle): CSSProperties {
|
||||||
|
const decorations = [
|
||||||
|
style.underline ? 'underline' : undefined,
|
||||||
|
style.strikethrough ? 'line-through' : undefined,
|
||||||
|
].filter(Boolean);
|
||||||
|
return {
|
||||||
|
fontFamily: style.font,
|
||||||
|
fontSize:
|
||||||
|
style.fontSize === undefined
|
||||||
|
? undefined
|
||||||
|
: `${Math.min(Math.max(style.fontSize / 2, 6), 144)}pt`,
|
||||||
|
fontWeight: style.bold ? 700 : undefined,
|
||||||
|
fontStyle: style.italic ? 'italic' : undefined,
|
||||||
|
textDecoration: decorations.length > 0 ? decorations.join(' ') : undefined,
|
||||||
|
verticalAlign: style.superscript
|
||||||
|
? 'super'
|
||||||
|
: style.subscript
|
||||||
|
? 'sub'
|
||||||
|
: undefined,
|
||||||
|
color: colorCss(style.fontColor),
|
||||||
|
backgroundColor: colorCss(style.highlight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorCss(color: OneNoteColor | undefined): string | undefined {
|
||||||
|
if (!color || color.kind === 'auto') return undefined;
|
||||||
|
const red = color.red ?? 0;
|
||||||
|
const green = color.green ?? 0;
|
||||||
|
const blue = color.blue ?? 0;
|
||||||
|
return color.kind === 'rgba'
|
||||||
|
? `rgba(${red}, ${green}, ${blue}, ${(color.alpha ?? 255) / 255})`
|
||||||
|
: `rgb(${red}, ${green}, ${blue})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutStyle(layout: OneNoteLayout): CSSProperties {
|
||||||
|
return {
|
||||||
|
maxWidth: halfInches(layout.maxWidth),
|
||||||
|
maxHeight: halfInches(layout.maxHeight),
|
||||||
|
marginInlineStart: halfInches(layout.offsetHorizontal, -1_000, 10_000),
|
||||||
|
marginTop: halfInches(layout.offsetVertical, -1_000, 10_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function halfInches(
|
||||||
|
value: number | undefined,
|
||||||
|
minimum = 0,
|
||||||
|
maximum = 10_000
|
||||||
|
): string | undefined {
|
||||||
|
if (value === undefined || !Number.isFinite(value)) return undefined;
|
||||||
|
return `${Math.min(Math.max(value * 48, minimum), maximum)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(size: number): string {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(1)} MiB`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ParserDiagnostic } from './diagnostics.js';
|
import type { ParserDiagnostic } from './diagnostics.js';
|
||||||
|
import type { OneNoteContentBlock } from '../one/content-model.js';
|
||||||
|
|
||||||
export interface OneNotePageSummaryDto {
|
export interface OneNotePageSummaryDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,21 +12,28 @@ export interface OneNotePageSummaryDto {
|
|||||||
textPreview: string;
|
textPreview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OneNoteContentBlockDto =
|
/** Serializable, inert page content. Resource bytes remain in the worker. */
|
||||||
| { kind: 'text'; text: string }
|
export type OneNoteContentBlockDto = OneNoteContentBlock;
|
||||||
| { kind: 'line-break' }
|
|
||||||
| { kind: 'link'; text: string; href?: string }
|
|
||||||
| {
|
|
||||||
kind: 'unsupported';
|
|
||||||
sourceKind: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface OneNotePageDto extends OneNotePageSummaryDto {
|
export interface OneNotePageDto extends OneNotePageSummaryDto {
|
||||||
blocks: OneNoteContentBlockDto[];
|
blocks: OneNoteContentBlockDto[];
|
||||||
diagnostics: ParserDiagnostic[];
|
diagnostics: ParserDiagnostic[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OneNoteResourceDto {
|
||||||
|
id: string;
|
||||||
|
kind: 'image' | 'attachment';
|
||||||
|
filename?: string;
|
||||||
|
extension?: string;
|
||||||
|
mediaType: string;
|
||||||
|
browserRenderable: boolean;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OneNoteResourcePayloadDto extends OneNoteResourceDto {
|
||||||
|
bytes: ArrayBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OneNoteSectionDto {
|
export interface OneNoteSectionDto {
|
||||||
format: 'one';
|
format: 'one';
|
||||||
sourceName: string;
|
sourceName: string;
|
||||||
|
|||||||
159
src/styles.css
159
src/styles.css
@@ -324,7 +324,9 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
max-width: 48rem;
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 64rem;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +335,135 @@ p {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onenote-outline {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-outline-group,
|
||||||
|
.onenote-outline-element {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-text {
|
||||||
|
min-height: 1lh;
|
||||||
|
margin: 0 0 0.55rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-link {
|
||||||
|
color: #5b2c83;
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-underline-offset: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table-scroll {
|
||||||
|
max-width: 100%;
|
||||||
|
margin-block: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table {
|
||||||
|
width: max-content;
|
||||||
|
min-width: min(100%, 28rem);
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table td {
|
||||||
|
min-width: 5rem;
|
||||||
|
border: 1px solid #cfc5d4;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table--borderless td {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table td > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-image {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 0.45rem;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-image img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-image figcaption {
|
||||||
|
max-width: 40rem;
|
||||||
|
color: #716675;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder {
|
||||||
|
display: grid;
|
||||||
|
min-width: 12rem;
|
||||||
|
min-height: 7rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed #a58caf;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #716675;
|
||||||
|
background: #faf7fc;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin-block: 0.8rem;
|
||||||
|
border: 1px solid #d4c8db;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: #faf7fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card > div {
|
||||||
|
display: grid;
|
||||||
|
flex: 1 1 12rem;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card span,
|
||||||
|
.attachment-card small {
|
||||||
|
color: #716675;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card small {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-ink {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-block: 0.75rem;
|
||||||
|
overflow: visible;
|
||||||
|
color: #241b2b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-ink svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.unsupported-block {
|
.unsupported-block {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
@@ -640,6 +771,32 @@ p {
|
|||||||
background: #3a3040;
|
background: #3a3040;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onenote-link {
|
||||||
|
color: #d7acef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table,
|
||||||
|
.attachment-card,
|
||||||
|
.image-placeholder {
|
||||||
|
color: #f6effa;
|
||||||
|
background: #302638;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-table td {
|
||||||
|
border-color: #62536b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-image figcaption,
|
||||||
|
.attachment-card span,
|
||||||
|
.attachment-card small,
|
||||||
|
.image-placeholder {
|
||||||
|
color: #cfc2d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onenote-ink {
|
||||||
|
color: #f6effa;
|
||||||
|
}
|
||||||
|
|
||||||
.diagnostic {
|
.diagnostic {
|
||||||
color: #f5e8cd;
|
color: #f5e8cd;
|
||||||
background: #493b23;
|
background: #493b23;
|
||||||
|
|||||||
@@ -85,7 +85,17 @@ describe('OneNoteWorkerClient', () => {
|
|||||||
title: 'Page seven',
|
title: 'Page seven',
|
||||||
text: 'Body',
|
text: 'Body',
|
||||||
textPreview: 'Body',
|
textPreview: 'Body',
|
||||||
blocks: [{ kind: 'text', text: 'Body' }],
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
|
id: 'text-1',
|
||||||
|
sourceText: 'Body',
|
||||||
|
text: 'Body',
|
||||||
|
runs: [{ start: 0, end: 4, text: 'Body', style: {} }],
|
||||||
|
paragraphStyle: {},
|
||||||
|
layout: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -95,6 +105,41 @@ describe('OneNoteWorkerClient', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requests a lazy resource from the retained parser session', async () => {
|
||||||
|
const worker = new FakeWorker();
|
||||||
|
const client = new OneNoteWorkerClient(() => worker);
|
||||||
|
const result = client.getResource('session-1', 'resource-2', 'section-3');
|
||||||
|
|
||||||
|
expect(worker.lastMessage).toEqual({
|
||||||
|
type: 'get-resource',
|
||||||
|
requestId: 'resource-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
resourceId: 'resource-2',
|
||||||
|
sectionId: 'section-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
const bytes = Uint8Array.of(1, 2, 3).buffer;
|
||||||
|
worker.respond({
|
||||||
|
type: 'resource',
|
||||||
|
requestId: 'resource-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
resource: {
|
||||||
|
id: 'resource-2',
|
||||||
|
kind: 'attachment',
|
||||||
|
filename: 'file.bin',
|
||||||
|
mediaType: 'application/octet-stream',
|
||||||
|
browserRenderable: false,
|
||||||
|
size: 3,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(result).resolves.toMatchObject({
|
||||||
|
type: 'resource',
|
||||||
|
resource: { bytes },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
PageWorkerResponse,
|
PageWorkerResponse,
|
||||||
ParseWorkerRequest,
|
ParseWorkerRequest,
|
||||||
ParseWorkerResponse,
|
ParseWorkerResponse,
|
||||||
|
ResourceWorkerResponse,
|
||||||
WorkerRequest,
|
WorkerRequest,
|
||||||
WorkerFailure,
|
WorkerFailure,
|
||||||
WorkerResponse,
|
WorkerResponse,
|
||||||
@@ -17,7 +18,9 @@ export interface WorkerPort {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PendingRequest = {
|
type PendingRequest = {
|
||||||
resolve: (response: ParseWorkerResponse | PageWorkerResponse) => void;
|
resolve: (
|
||||||
|
response: ParseWorkerResponse | PageWorkerResponse | ResourceWorkerResponse
|
||||||
|
) => void;
|
||||||
reject: (error: WorkerClientError) => void;
|
reject: (error: WorkerClientError) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,6 +126,36 @@ export class OneNoteWorkerClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getResource(
|
||||||
|
sessionId: string,
|
||||||
|
resourceId: string,
|
||||||
|
sectionId?: string
|
||||||
|
): Promise<ResourceWorkerResponse> {
|
||||||
|
if (this.terminated) {
|
||||||
|
return Promise.reject(
|
||||||
|
new WorkerClientError(
|
||||||
|
'worker-failure',
|
||||||
|
'The local parser worker has already been terminated.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = `resource-${++this.requestSequence}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(requestId, {
|
||||||
|
resolve: (response) => resolve(response as ResourceWorkerResponse),
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: 'get-resource',
|
||||||
|
requestId,
|
||||||
|
sessionId,
|
||||||
|
resourceId,
|
||||||
|
...(sectionId === undefined ? {} : { sectionId }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
terminate(): void {
|
terminate(): void {
|
||||||
if (this.terminated) return;
|
if (this.terminated) return;
|
||||||
this.terminated = true;
|
this.terminated = true;
|
||||||
|
|||||||
@@ -1,28 +1,35 @@
|
|||||||
import type { OneNotePageDto } from '../onenote/model/dto.js';
|
import type { OneNotePageDto } from '../onenote/model/dto.js';
|
||||||
|
import type { OneNoteResource } from '../onenote/one/content-model.js';
|
||||||
import {
|
import {
|
||||||
failureFromUnknown,
|
failureFromUnknown,
|
||||||
openPackageForWorker,
|
openPackageForWorker,
|
||||||
openSectionForWorker,
|
openSectionForWorker,
|
||||||
|
resourcePayload,
|
||||||
workerPageKey,
|
workerPageKey,
|
||||||
|
workerResourceKey,
|
||||||
} from './parser-adapter.js';
|
} from './parser-adapter.js';
|
||||||
import type { WorkerRequest, WorkerResponse } from './worker-protocol.js';
|
import type { WorkerRequest, WorkerResponse } from './worker-protocol.js';
|
||||||
|
|
||||||
interface ModuleWorkerScope {
|
interface ModuleWorkerScope {
|
||||||
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
|
onmessage: ((event: MessageEvent<WorkerRequest>) => void) | null;
|
||||||
postMessage(response: WorkerResponse): void;
|
postMessage(response: WorkerResponse, transfer?: Transferable[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParserSession {
|
interface ParserSession {
|
||||||
pages: Map<string, OneNotePageDto>;
|
pages: Map<string, OneNotePageDto>;
|
||||||
|
resources: Map<string, OneNoteResource>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workerScope = globalThis as unknown as ModuleWorkerScope;
|
const workerScope = globalThis as unknown as ModuleWorkerScope;
|
||||||
const sessions = new Map<string, ParserSession>();
|
const sessions = new Map<string, ParserSession>();
|
||||||
let sessionSequence = 0;
|
let sessionSequence = 0;
|
||||||
|
|
||||||
function createSession(pages: Map<string, OneNotePageDto>): string {
|
function createSession(
|
||||||
|
pages: Map<string, OneNotePageDto>,
|
||||||
|
resources: Map<string, OneNoteResource>
|
||||||
|
): string {
|
||||||
const id = `session-${++sessionSequence}`;
|
const id = `session-${++sessionSequence}`;
|
||||||
sessions.set(id, { pages });
|
sessions.set(id, { pages, resources });
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +41,7 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
|||||||
new Uint8Array(data.bytes),
|
new Uint8Array(data.bytes),
|
||||||
data.fileName
|
data.fileName
|
||||||
);
|
);
|
||||||
const sessionId = createSession(parsed.pages);
|
const sessionId = createSession(parsed.pages, parsed.resources);
|
||||||
workerScope.postMessage({
|
workerScope.postMessage({
|
||||||
type: 'section',
|
type: 'section',
|
||||||
requestId: data.requestId,
|
requestId: data.requestId,
|
||||||
@@ -56,7 +63,7 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
|||||||
new Uint8Array(data.bytes),
|
new Uint8Array(data.bytes),
|
||||||
data.fileName
|
data.fileName
|
||||||
);
|
);
|
||||||
const sessionId = createSession(parsed.pages);
|
const sessionId = createSession(parsed.pages, parsed.resources);
|
||||||
workerScope.postMessage({
|
workerScope.postMessage({
|
||||||
type: 'package',
|
type: 'package',
|
||||||
requestId: data.requestId,
|
requestId: data.requestId,
|
||||||
@@ -108,6 +115,47 @@ async function handleRequest(data: WorkerRequest): Promise<void> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'get-resource': {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const resource = session.resources.get(
|
||||||
|
workerResourceKey(data.resourceId, data.sectionId)
|
||||||
|
);
|
||||||
|
const payload = resource ? resourcePayload(resource) : undefined;
|
||||||
|
if (!payload) {
|
||||||
|
workerScope.postMessage({
|
||||||
|
type: 'failure',
|
||||||
|
requestId: data.requestId,
|
||||||
|
error: {
|
||||||
|
code: 'resource-not-available',
|
||||||
|
message: 'The selected embedded resource is not available.',
|
||||||
|
recoverable: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workerScope.postMessage(
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
requestId: data.requestId,
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
resource: payload,
|
||||||
|
},
|
||||||
|
[payload.bytes]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'dispose':
|
case 'dispose':
|
||||||
sessions.delete(data.sessionId);
|
sessions.delete(data.sessionId);
|
||||||
workerScope.postMessage({
|
workerScope.postMessage({
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
failureFromUnknown,
|
failureFromUnknown,
|
||||||
openPackageForWorker,
|
openPackageForWorker,
|
||||||
openSectionForWorker,
|
openSectionForWorker,
|
||||||
|
resourcePayload,
|
||||||
workerPageKey,
|
workerPageKey,
|
||||||
|
workerResourceKey,
|
||||||
} from './parser-adapter.js';
|
} from './parser-adapter.js';
|
||||||
|
|
||||||
function fixtureBytes(path: string): Uint8Array {
|
function fixtureBytes(path: string): Uint8Array {
|
||||||
@@ -28,6 +30,19 @@ describe('worker parser adapter', () => {
|
|||||||
title: 'Test',
|
title: 'Test',
|
||||||
text: expect.stringContaining('This notebook should have three pages.'),
|
text: expect.stringContaining('This notebook should have three pages.'),
|
||||||
});
|
});
|
||||||
|
expect(result.resources).toHaveLength(1);
|
||||||
|
const resource = [...result.resources.values()][0]!;
|
||||||
|
expect(result.resources.get(workerResourceKey(resource.id))).toBe(resource);
|
||||||
|
const payload = resourcePayload(resource);
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
kind: 'image',
|
||||||
|
mediaType: 'image/png',
|
||||||
|
browserRenderable: true,
|
||||||
|
size: 2639,
|
||||||
|
});
|
||||||
|
expect(new Uint8Array(payload!.bytes).subarray(0, 8)).toEqual(
|
||||||
|
Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens a real ONEPKG and addresses page IDs without rewriting them', async () => {
|
it('opens a real ONEPKG and addresses page IDs without rewriting them', async () => {
|
||||||
@@ -65,6 +80,7 @@ describe('worker parser adapter', () => {
|
|||||||
).toBe(page.id);
|
).toBe(page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
expect(result.resources).toBeInstanceOf(Map);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts malformed section input into a controlled worker failure', () => {
|
it('converts malformed section input into a controlled worker failure', () => {
|
||||||
|
|||||||
@@ -1,28 +1,79 @@
|
|||||||
import { OneNoteParserError, parseOneNoteSection } from '../onenote/index.js';
|
import {
|
||||||
|
detectOneNoteFormat,
|
||||||
|
OneNoteParserError,
|
||||||
|
parseOneNoteSectionContent,
|
||||||
|
} from '../onenote/index.js';
|
||||||
import type {
|
import type {
|
||||||
OneNotePackageDto,
|
OneNotePackageDto,
|
||||||
OneNotePageDto,
|
OneNotePageDto,
|
||||||
OneNotePageSummaryDto,
|
OneNotePageSummaryDto,
|
||||||
|
OneNoteResourcePayloadDto,
|
||||||
OneNoteSectionDto,
|
OneNoteSectionDto,
|
||||||
} from '../onenote/model/dto.js';
|
} from '../onenote/model/dto.js';
|
||||||
|
import type {
|
||||||
|
OneNotePageContentModel,
|
||||||
|
OneNoteResource,
|
||||||
|
OneNoteSectionContentModel,
|
||||||
|
} from '../onenote/one/content-model.js';
|
||||||
import { OnePkgError, openOneNotePackage } from '../onenote/onepkg/index.js';
|
import { OnePkgError, openOneNotePackage } 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 {
|
||||||
section: OneNoteSectionDto;
|
section: OneNoteSectionDto;
|
||||||
pages: Map<string, OneNotePageDto>;
|
pages: Map<string, OneNotePageDto>;
|
||||||
|
resources: Map<string, OneNoteResource>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedWorkerPackage {
|
export interface ParsedWorkerPackage {
|
||||||
notebook: OneNotePackageDto;
|
notebook: OneNotePackageDto;
|
||||||
pages: Map<string, OneNotePageDto>;
|
pages: Map<string, OneNotePageDto>;
|
||||||
|
resources: Map<string, OneNoteResource>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageDto(page: OneNotePageSummaryDto): OneNotePageDto {
|
function pageSummary(page: OneNotePageContentModel): OneNotePageSummaryDto {
|
||||||
return {
|
return {
|
||||||
...page,
|
id: page.id,
|
||||||
blocks: page.text ? [{ kind: 'text', text: page.text }] : [],
|
title: page.title,
|
||||||
diagnostics: [],
|
createdAt: page.createdAt,
|
||||||
|
updatedAt: page.updatedAt,
|
||||||
|
level: page.level,
|
||||||
|
text: page.text,
|
||||||
|
textPreview: preview(page.text),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageDto(
|
||||||
|
page: OneNotePageContentModel,
|
||||||
|
diagnostics: OneNoteSectionContentModel['diagnostics']
|
||||||
|
): OneNotePageDto {
|
||||||
|
return {
|
||||||
|
...pageSummary(page),
|
||||||
|
blocks: page.blocks,
|
||||||
|
diagnostics: diagnostics.filter((diagnostic) =>
|
||||||
|
diagnostic.structure?.includes(`page ${page.id}`)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionDto(
|
||||||
|
model: OneNoteSectionContentModel,
|
||||||
|
sourceBytes: Uint8Array
|
||||||
|
): OneNoteSectionDto {
|
||||||
|
const format = detectOneNoteFormat(sourceBytes);
|
||||||
|
return {
|
||||||
|
format: 'one',
|
||||||
|
sourceName: model.sourceName,
|
||||||
|
sectionName: model.sectionName,
|
||||||
|
pages: model.pages.map(pageSummary),
|
||||||
|
diagnostics: model.diagnostics,
|
||||||
|
parserInfo: {
|
||||||
|
implementation: 'typescript',
|
||||||
|
supportedVariant:
|
||||||
|
format.kind === 'fsshttp-one'
|
||||||
|
? 'fsshttp-packaged-revision-store'
|
||||||
|
: 'desktop-revision-store',
|
||||||
|
referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,15 +81,53 @@ export function workerPageKey(pageId: string, sectionId?: string): string {
|
|||||||
return JSON.stringify([sectionId ?? null, pageId]);
|
return JSON.stringify([sectionId ?? null, pageId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function workerResourceKey(
|
||||||
|
resourceId: string,
|
||||||
|
sectionId?: string
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([sectionId ?? null, resourceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resourcePayload(
|
||||||
|
resource: OneNoteResource
|
||||||
|
): OneNoteResourcePayloadDto | undefined {
|
||||||
|
const blob = resource.blob;
|
||||||
|
if (resource.availability !== 'available' || !blob) return undefined;
|
||||||
|
const bytes = new ArrayBuffer(blob.size);
|
||||||
|
new Uint8Array(bytes).set(
|
||||||
|
blob.source.subarray(blob.offset, blob.offset + blob.size)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: resource.id,
|
||||||
|
kind: resource.kind,
|
||||||
|
filename: resource.filename,
|
||||||
|
extension: resource.extension,
|
||||||
|
mediaType: resource.mediaType,
|
||||||
|
browserRenderable: resource.browserRenderable,
|
||||||
|
size: blob.size,
|
||||||
|
bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function openSectionForWorker(
|
export function openSectionForWorker(
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
sourceName: string
|
sourceName: string
|
||||||
): ParsedWorkerSection {
|
): ParsedWorkerSection {
|
||||||
const section = parseOneNoteSection(bytes, { sourceName });
|
const model = parseOneNoteSectionContent(bytes, { sourceName });
|
||||||
|
const section = sectionDto(model, bytes);
|
||||||
return {
|
return {
|
||||||
section,
|
section,
|
||||||
pages: new Map(
|
pages: new Map(
|
||||||
section.pages.map((page) => [workerPageKey(page.id), pageDto(page)])
|
model.pages.map((page) => [
|
||||||
|
workerPageKey(page.id),
|
||||||
|
pageDto(page, model.diagnostics),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
resources: new Map(
|
||||||
|
[...model.resources].map(([id, resource]) => [
|
||||||
|
workerResourceKey(id),
|
||||||
|
resource,
|
||||||
|
])
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -47,6 +136,7 @@ export async function openPackageForWorker(
|
|||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
sourceName: string
|
sourceName: string
|
||||||
): Promise<ParsedWorkerPackage> {
|
): Promise<ParsedWorkerPackage> {
|
||||||
|
const contentBySection = new Map<string, OneNoteSectionContentModel>();
|
||||||
const result = await openOneNotePackage(bytes, {
|
const result = await openOneNotePackage(bytes, {
|
||||||
sourceName,
|
sourceName,
|
||||||
cancellation: {
|
cancellation: {
|
||||||
@@ -54,9 +144,13 @@ export async function openPackageForWorker(
|
|||||||
},
|
},
|
||||||
parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => {
|
parseSection: ({ sourceName: sectionName, bytes: sectionBytes }) => {
|
||||||
try {
|
try {
|
||||||
|
const model = parseOneNoteSectionContent(sectionBytes, {
|
||||||
|
sourceName: sectionName,
|
||||||
|
});
|
||||||
|
contentBySection.set(sectionName, model);
|
||||||
return {
|
return {
|
||||||
status: 'parsed',
|
status: 'parsed',
|
||||||
value: parseOneNoteSection(sectionBytes, { sourceName: sectionName }),
|
value: sectionDto(model, sectionBytes),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
@@ -83,16 +177,22 @@ export async function openPackageForWorker(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pages = new Map<string, OneNotePageDto>();
|
const pages = new Map<string, OneNotePageDto>();
|
||||||
|
const resources = new Map<string, OneNoteResource>();
|
||||||
const sections = result.package.sections.map((packagedSection) => {
|
const sections = result.package.sections.map((packagedSection) => {
|
||||||
if (!packagedSection.section) return packagedSection;
|
if (!packagedSection.section) return packagedSection;
|
||||||
const section = {
|
const model = contentBySection.get(packagedSection.path);
|
||||||
...packagedSection.section,
|
if (model) {
|
||||||
pages: packagedSection.section.pages.map((page) => {
|
for (const page of model.pages) {
|
||||||
pages.set(workerPageKey(page.id, packagedSection.id), pageDto(page));
|
pages.set(
|
||||||
return page;
|
workerPageKey(page.id, packagedSection.id),
|
||||||
}),
|
pageDto(page, model.diagnostics)
|
||||||
};
|
);
|
||||||
return { ...packagedSection, section };
|
}
|
||||||
|
for (const [id, resource] of model.resources) {
|
||||||
|
resources.set(workerResourceKey(id, packagedSection.id), resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return packagedSection;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -101,9 +201,15 @@ export async function openPackageForWorker(
|
|||||||
sections,
|
sections,
|
||||||
},
|
},
|
||||||
pages,
|
pages,
|
||||||
|
resources,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preview(text: string): string {
|
||||||
|
const compact = text.replace(/\s+/gu, ' ').trim();
|
||||||
|
return compact.length <= 500 ? compact : `${compact.slice(0, 497)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
export function failureFromUnknown(error: unknown): WorkerFailure {
|
export function failureFromUnknown(error: unknown): WorkerFailure {
|
||||||
if (error instanceof OneNoteParserError) {
|
if (error instanceof OneNoteParserError) {
|
||||||
const code =
|
const code =
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
OneNotePackageDto,
|
OneNotePackageDto,
|
||||||
OneNotePageDto,
|
OneNotePageDto,
|
||||||
|
OneNoteResourcePayloadDto,
|
||||||
OneNoteSectionDto,
|
OneNoteSectionDto,
|
||||||
} from '../onenote/model/dto.js';
|
} from '../onenote/model/dto.js';
|
||||||
|
|
||||||
@@ -24,6 +25,13 @@ export type WorkerRequest =
|
|||||||
pageId: string;
|
pageId: string;
|
||||||
sectionId?: string;
|
sectionId?: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'get-resource';
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
resourceId: string;
|
||||||
|
sectionId?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'dispose';
|
type: 'dispose';
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -35,6 +43,7 @@ export interface WorkerFailure {
|
|||||||
| 'unsupported-format'
|
| 'unsupported-format'
|
||||||
| 'limit-exceeded'
|
| 'limit-exceeded'
|
||||||
| 'page-not-available'
|
| 'page-not-available'
|
||||||
|
| 'resource-not-available'
|
||||||
| 'session-not-available'
|
| 'session-not-available'
|
||||||
| 'worker-failure';
|
| 'worker-failure';
|
||||||
message: string;
|
message: string;
|
||||||
@@ -62,6 +71,12 @@ export type WorkerResponse =
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
page: OneNotePageDto;
|
page: OneNotePageDto;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'resource';
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
resource: OneNoteResourcePayloadDto;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'failure';
|
type: 'failure';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -85,3 +100,8 @@ export type PageWorkerResponse = Extract<
|
|||||||
WorkerResponse,
|
WorkerResponse,
|
||||||
{ type: 'page' | 'failure' }
|
{ type: 'page' | 'failure' }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type ResourceWorkerResponse = Extract<
|
||||||
|
WorkerResponse,
|
||||||
|
{ type: 'resource' | 'failure' }
|
||||||
|
>;
|
||||||
|
|||||||
Reference in New Issue
Block a user