feat: adapt parser sessions for export
This commit is contained in:
362
src/worker/export-adapter.test.ts
Normal file
362
src/worker/export-adapter.test.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { APP_VERSION } from '../version.js';
|
||||
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
|
||||
import type {
|
||||
OneNotePackageDto,
|
||||
OneNotePageDto,
|
||||
OneNoteSectionDto,
|
||||
} from '../onenote/model/dto.js';
|
||||
import type { OneNoteResource } from '../onenote/one/content-model.js';
|
||||
import {
|
||||
createWorkerExportArtifact,
|
||||
createWorkerExportSnapshot,
|
||||
type WorkerExportFormat,
|
||||
} from './export-adapter.js';
|
||||
import { workerPageKey, workerResourceKey } from './parser-adapter.js';
|
||||
|
||||
const PNG = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00);
|
||||
|
||||
describe('worker export adapter', () => {
|
||||
it('builds a partial section snapshot from full session pages and copied resources', () => {
|
||||
const pageDiagnostic = diagnostic('page-warning', 'page page-1');
|
||||
const sectionDiagnostic = diagnostic('section-warning');
|
||||
const source = sectionDto('Unsafe/Section.one', 'Section name', [
|
||||
pageDiagnostic,
|
||||
sectionDiagnostic,
|
||||
]);
|
||||
const page = fullPage('page-1', pageDiagnostic);
|
||||
const resourceSource = Uint8Array.of(7, ...PNG, 9);
|
||||
const available = resource('image-1', resourceSource, 1, PNG.length);
|
||||
const attachmentSource = Uint8Array.of(6, 5, 4, 3);
|
||||
const attachment = resource(
|
||||
'attachment-1',
|
||||
attachmentSource,
|
||||
1,
|
||||
2,
|
||||
'attachment'
|
||||
);
|
||||
const unavailable: OneNoteResource = {
|
||||
...resource('missing', resourceSource, 0, 1),
|
||||
availability: 'missing',
|
||||
blob: undefined,
|
||||
};
|
||||
|
||||
const snapshot = createWorkerExportSnapshot(
|
||||
source,
|
||||
new Map([[workerPageKey(page.id), page]]),
|
||||
new Map([
|
||||
[workerResourceKey(available.id), available],
|
||||
[workerResourceKey(attachment.id), attachment],
|
||||
[workerResourceKey(unavailable.id), unavailable],
|
||||
])
|
||||
);
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
app: { name: 'OneNote Tools', version: APP_VERSION },
|
||||
parser: {
|
||||
implementation: 'typescript',
|
||||
supportedVariants: ['desktop-revision-store'],
|
||||
referenceRevision: 'MS-ONE fixture revision',
|
||||
},
|
||||
supportLevel: 'partial',
|
||||
source: { name: 'Unsafe/Section.one', format: 'one' },
|
||||
notebookName: 'Section name',
|
||||
});
|
||||
expect(snapshot.sections).toHaveLength(1);
|
||||
expect(snapshot.sections[0]?.pages[0]).toBe(page);
|
||||
expect(snapshot.sections[0]?.pages[0]?.blocks).toEqual([
|
||||
expect.objectContaining({ kind: 'text', text: 'Full rich page body' }),
|
||||
]);
|
||||
expect(snapshot.sections[0]?.diagnostics).toEqual([sectionDiagnostic]);
|
||||
expect(snapshot.sections[0]?.pages[0]?.diagnostics).toEqual([
|
||||
pageDiagnostic,
|
||||
]);
|
||||
expect(snapshot.resources).toHaveLength(2);
|
||||
expect(snapshot.resources[0]).toMatchObject({
|
||||
sectionId: 'section',
|
||||
id: 'image-1',
|
||||
mediaType: 'image/png',
|
||||
size: PNG.length,
|
||||
});
|
||||
expect(new Uint8Array(snapshot.resources[0]!.bytes)).toEqual(PNG);
|
||||
expect(snapshot.resources[1]).toMatchObject({
|
||||
sectionId: 'section',
|
||||
id: 'attachment-1',
|
||||
kind: 'attachment',
|
||||
size: 2,
|
||||
});
|
||||
expect(new Uint8Array(snapshot.resources[1]!.bytes)).toEqual(
|
||||
Uint8Array.of(5, 4)
|
||||
);
|
||||
resourceSource.fill(0);
|
||||
attachmentSource.fill(0);
|
||||
expect(new Uint8Array(snapshot.resources[0]!.bytes)).toEqual(PNG);
|
||||
expect(new Uint8Array(snapshot.resources[1]!.bytes)).toEqual(
|
||||
Uint8Array.of(5, 4)
|
||||
);
|
||||
});
|
||||
|
||||
it('exports only parsed package sections while retaining every diagnostic and the tree', () => {
|
||||
const packageDiagnostic = diagnostic('cab-warning');
|
||||
const parsedWrapperDiagnostic = diagnostic('parsed-wrapper-warning');
|
||||
const failedDiagnostic = diagnostic('failed-section-warning');
|
||||
const unsupportedDiagnostic = diagnostic('unsupported-section-warning');
|
||||
const pageDiagnostic = diagnostic('page-warning', 'page page-1');
|
||||
const sectionDiagnostic = diagnostic('section-warning');
|
||||
const parsedSection = sectionDto(
|
||||
'Folder/Parsed.one',
|
||||
'Parsed',
|
||||
[sectionDiagnostic, pageDiagnostic],
|
||||
'fsshttp-packaged-revision-store'
|
||||
);
|
||||
const page = fullPage('page-1', pageDiagnostic);
|
||||
const source: OneNotePackageDto = {
|
||||
format: 'onepkg',
|
||||
sourceName: '../../CON.onepkg',
|
||||
sourceSize: 123,
|
||||
entries: [],
|
||||
sections: [
|
||||
{
|
||||
id: 'Folder/Parsed.one',
|
||||
path: 'Folder/Parsed.one',
|
||||
displayName: 'Parsed',
|
||||
parseStatus: 'parsed',
|
||||
section: parsedSection,
|
||||
diagnostics: [parsedWrapperDiagnostic],
|
||||
},
|
||||
{
|
||||
id: 'Failed.one',
|
||||
path: 'Failed.one',
|
||||
displayName: 'Failed',
|
||||
parseStatus: 'failed',
|
||||
diagnostics: [failedDiagnostic],
|
||||
},
|
||||
{
|
||||
id: 'Unsupported.one',
|
||||
path: 'Unsupported.one',
|
||||
displayName: 'Unsupported',
|
||||
parseStatus: 'unsupported',
|
||||
diagnostics: [unsupportedDiagnostic],
|
||||
},
|
||||
],
|
||||
tree: {
|
||||
interpreted: true,
|
||||
tocPath: 'Open Notebook.onetoc2',
|
||||
nodes: [
|
||||
{
|
||||
kind: 'section',
|
||||
id: 'tree-section',
|
||||
sectionId: 'Folder/Parsed.one',
|
||||
displayName: 'Parsed',
|
||||
},
|
||||
],
|
||||
},
|
||||
diagnostics: [packageDiagnostic],
|
||||
};
|
||||
const bytes = Uint8Array.of(...PNG);
|
||||
const image = resource('shared-id', bytes, 0, bytes.length);
|
||||
const wrongSection = resource('other-id', bytes, 0, bytes.length);
|
||||
|
||||
const snapshot = createWorkerExportSnapshot(
|
||||
source,
|
||||
new Map([[workerPageKey(page.id, 'Folder/Parsed.one'), page]]),
|
||||
new Map([
|
||||
[workerResourceKey(image.id, 'Folder/Parsed.one'), image],
|
||||
[workerResourceKey(wrongSection.id, 'Failed.one'), wrongSection],
|
||||
])
|
||||
);
|
||||
|
||||
expect(snapshot.source).toEqual({
|
||||
name: '../../CON.onepkg',
|
||||
format: 'onepkg',
|
||||
});
|
||||
expect(snapshot.notebookName).toBe('CON');
|
||||
expect(snapshot.tree).toBe(source.tree);
|
||||
expect(snapshot.sections.map(({ id }) => id)).toEqual([
|
||||
'Folder/Parsed.one',
|
||||
]);
|
||||
expect(snapshot.sections[0]?.pages).toEqual([page]);
|
||||
expect(snapshot.resources).toEqual([
|
||||
expect.objectContaining({
|
||||
sectionId: 'Folder/Parsed.one',
|
||||
id: 'shared-id',
|
||||
}),
|
||||
]);
|
||||
expect(snapshot.parser.supportedVariants).toEqual([
|
||||
'onepkg-cabinet',
|
||||
'fsshttp-packaged-revision-store',
|
||||
]);
|
||||
expect(snapshot.supportNotes.join(' ')).toContain(
|
||||
'2 package sections were omitted'
|
||||
);
|
||||
const allDiagnostics = [
|
||||
...snapshot.diagnostics,
|
||||
...snapshot.sections.flatMap((section) => section.diagnostics),
|
||||
...snapshot.sections.flatMap((section) =>
|
||||
section.pages.flatMap((parsedPage) => parsedPage.diagnostics)
|
||||
),
|
||||
];
|
||||
expect(allDiagnostics.map(({ code }) => code)).toEqual([
|
||||
'cab-warning',
|
||||
'parsed-wrapper-warning',
|
||||
'failed-section-warning',
|
||||
'unsupported-section-warning',
|
||||
'section-warning',
|
||||
'page-warning',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits safe, typed text artifacts and a static notebook ZIP', () => {
|
||||
const source = sectionDto('../../CON.one', undefined, []);
|
||||
const page = fullPage('page-1');
|
||||
page.blocks.push({
|
||||
kind: 'image',
|
||||
id: 'image-block',
|
||||
resourceId: 'image-1',
|
||||
filename: 'image-1.png',
|
||||
isBackground: false,
|
||||
layout: {},
|
||||
});
|
||||
const pages = new Map([[workerPageKey(page.id), page]]);
|
||||
const image = resource('image-1', PNG, 0, PNG.length);
|
||||
const resources = new Map([[workerResourceKey(image.id), image]]);
|
||||
const expectations: Array<[WorkerExportFormat, string, string]> = [
|
||||
['json', '_CON.json', 'application/json;charset=utf-8'],
|
||||
['text', '_CON.txt', 'text/plain;charset=utf-8'],
|
||||
['markdown', '_CON.md', 'text/markdown;charset=utf-8'],
|
||||
['html', '_CON.html', 'text/html;charset=utf-8'],
|
||||
];
|
||||
|
||||
for (const [format, filename, mediaType] of expectations) {
|
||||
const artifact = createWorkerExportArtifact(
|
||||
source,
|
||||
pages,
|
||||
resources,
|
||||
format
|
||||
);
|
||||
expect(artifact).toMatchObject({ filename, mediaType });
|
||||
expect(artifact.bytes).toBeInstanceOf(ArrayBuffer);
|
||||
expect(artifact.bytes.byteLength).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const json = createWorkerExportArtifact(source, pages, resources, 'json');
|
||||
const structured = JSON.parse(new TextDecoder().decode(json.bytes)) as {
|
||||
supportLevel: string;
|
||||
sections: Array<{ pages: OneNotePageDto[] }>;
|
||||
};
|
||||
expect(structured.supportLevel).toBe('partial');
|
||||
expect(structured.sections[0]?.pages[0]?.blocks[0]).toMatchObject({
|
||||
kind: 'text',
|
||||
text: 'Full rich page body',
|
||||
});
|
||||
|
||||
const zip = createWorkerExportArtifact(
|
||||
source,
|
||||
pages,
|
||||
resources,
|
||||
'static-zip'
|
||||
);
|
||||
expect(zip).toMatchObject({
|
||||
filename: '_CON.zip',
|
||||
mediaType: 'application/zip',
|
||||
});
|
||||
expect(zip.bytes).toBeInstanceOf(ArrayBuffer);
|
||||
const files = unzipSync(new Uint8Array(zip.bytes));
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'assets/_CON/image-1.png',
|
||||
'index.html',
|
||||
'manifest.json',
|
||||
'notebook.json',
|
||||
'notebook.md',
|
||||
'notebook.txt',
|
||||
'warnings.json',
|
||||
]);
|
||||
expect(strFromU8(files['notebook.txt']!)).toContain('Full rich page body');
|
||||
expect(files['assets/_CON/image-1.png']).toEqual(PNG);
|
||||
});
|
||||
});
|
||||
|
||||
function sectionDto(
|
||||
sourceName: string,
|
||||
sectionName: string | undefined,
|
||||
diagnostics: ParserDiagnostic[],
|
||||
supportedVariant = 'desktop-revision-store'
|
||||
): OneNoteSectionDto {
|
||||
return {
|
||||
format: 'one',
|
||||
sourceName,
|
||||
sectionName,
|
||||
pages: [
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Summary title',
|
||||
text: 'Summary-only text',
|
||||
textPreview: 'Summary-only text',
|
||||
},
|
||||
],
|
||||
diagnostics,
|
||||
parserInfo: {
|
||||
implementation: 'typescript',
|
||||
supportedVariant,
|
||||
referenceRevision: 'MS-ONE fixture revision',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fullPage(
|
||||
id: string,
|
||||
pageDiagnostic?: ParserDiagnostic
|
||||
): OneNotePageDto {
|
||||
const text = 'Full rich page body';
|
||||
return {
|
||||
id,
|
||||
title: 'Full page title',
|
||||
text,
|
||||
textPreview: text,
|
||||
blocks: [
|
||||
{
|
||||
kind: 'text',
|
||||
id: 'text-1',
|
||||
sourceText: text,
|
||||
text,
|
||||
runs: [{ start: 0, end: text.length, text, style: { bold: true } }],
|
||||
paragraphStyle: {},
|
||||
layout: {},
|
||||
},
|
||||
],
|
||||
diagnostics: pageDiagnostic ? [pageDiagnostic] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function resource(
|
||||
id: string,
|
||||
source: Uint8Array,
|
||||
offset: number,
|
||||
size: number,
|
||||
kind: OneNoteResource['kind'] = 'image'
|
||||
): OneNoteResource {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
sourceJcid: 1,
|
||||
filename: `${id}.${kind === 'image' ? 'png' : 'bin'}`,
|
||||
extension: kind === 'image' ? 'png' : 'bin',
|
||||
mediaType: kind === 'image' ? 'image/png' : 'application/octet-stream',
|
||||
browserRenderable: kind === 'image',
|
||||
size,
|
||||
blob: { source, offset, size },
|
||||
availability: 'available',
|
||||
};
|
||||
}
|
||||
|
||||
function diagnostic(code: string, structure?: string): ParserDiagnostic {
|
||||
return {
|
||||
severity: 'warning',
|
||||
code,
|
||||
message: `${code} message`,
|
||||
structure,
|
||||
recoverable: true,
|
||||
};
|
||||
}
|
||||
397
src/worker/export-adapter.ts
Normal file
397
src/worker/export-adapter.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import {
|
||||
createOneNoteExportSnapshot,
|
||||
createOneNoteStaticNotebookZip,
|
||||
forceFilenameExtension,
|
||||
sanitizeExportFilename,
|
||||
serializeOneNoteMarkdown,
|
||||
serializeOneNotePlainText,
|
||||
serializeOneNoteSemanticHtml,
|
||||
serializeOneNoteStructuredJson,
|
||||
type OneNoteExportOptions,
|
||||
type OneNoteExportParserMetadata,
|
||||
type OneNoteExportResource,
|
||||
type OneNoteExportSection,
|
||||
type OneNoteExportSnapshot,
|
||||
} from '../onenote/export/index.js';
|
||||
import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
|
||||
import type {
|
||||
OneNotePackageDto,
|
||||
OneNotePageDto,
|
||||
OneNotePageSummaryDto,
|
||||
OneNoteSectionDto,
|
||||
} from '../onenote/model/dto.js';
|
||||
import type { OneNoteResource } from '../onenote/one/content-model.js';
|
||||
import {
|
||||
resourcePayload,
|
||||
workerPageKey,
|
||||
workerResourceKey,
|
||||
} from './parser-adapter.js';
|
||||
|
||||
export type WorkerExportFormat =
|
||||
'static-zip' | 'json' | 'text' | 'markdown' | 'html';
|
||||
|
||||
export interface WorkerExportArtifact {
|
||||
filename: string;
|
||||
mediaType: string;
|
||||
bytes: ArrayBuffer;
|
||||
}
|
||||
|
||||
interface ExportedSectionContext {
|
||||
exportSection: OneNoteExportSection;
|
||||
parserSection: OneNoteSectionDto;
|
||||
sessionSectionId?: string;
|
||||
}
|
||||
|
||||
interface CollectedPages {
|
||||
pages: OneNotePageDto[];
|
||||
diagnostics: ParserDiagnostic[];
|
||||
missingCount: number;
|
||||
}
|
||||
|
||||
/** Build an inert export snapshot from data retained by one parser session. */
|
||||
export function createWorkerExportSnapshot(
|
||||
source: OneNoteSectionDto | OneNotePackageDto,
|
||||
pages: ReadonlyMap<string, OneNotePageDto>,
|
||||
resources: ReadonlyMap<string, OneNoteResource>
|
||||
): OneNoteExportSnapshot {
|
||||
return source.format === 'one'
|
||||
? sectionSnapshot(source, pages, resources)
|
||||
: packageSnapshot(source, pages, resources);
|
||||
}
|
||||
|
||||
/** Serialize one parser session into a browser-downloadable export artifact. */
|
||||
export function createWorkerExportArtifact(
|
||||
source: OneNoteSectionDto | OneNotePackageDto,
|
||||
pages: ReadonlyMap<string, OneNotePageDto>,
|
||||
resources: ReadonlyMap<string, OneNoteResource>,
|
||||
format: WorkerExportFormat,
|
||||
options: OneNoteExportOptions = {}
|
||||
): WorkerExportArtifact {
|
||||
const snapshot = createWorkerExportSnapshot(source, pages, resources);
|
||||
if (format === 'static-zip') {
|
||||
const artifact = createOneNoteStaticNotebookZip(snapshot, options);
|
||||
return {
|
||||
filename: artifact.filename,
|
||||
mediaType: artifact.mediaType,
|
||||
bytes: copyToArrayBuffer(artifact.bytes),
|
||||
};
|
||||
}
|
||||
|
||||
const serialization = TEXT_EXPORTS[format];
|
||||
const filename = forceFilenameExtension(
|
||||
sanitizeExportFilename(snapshot.notebookName, 'onenote-export'),
|
||||
serialization.extension
|
||||
);
|
||||
return {
|
||||
filename,
|
||||
mediaType: serialization.mediaType,
|
||||
bytes: copyToArrayBuffer(
|
||||
new TextEncoder().encode(serialization.serialize(snapshot, options))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const TEXT_EXPORTS: Record<
|
||||
Exclude<WorkerExportFormat, 'static-zip'>,
|
||||
{
|
||||
extension: string;
|
||||
mediaType: string;
|
||||
serialize: (
|
||||
snapshot: OneNoteExportSnapshot,
|
||||
options: OneNoteExportOptions
|
||||
) => string;
|
||||
}
|
||||
> = {
|
||||
json: {
|
||||
extension: 'json',
|
||||
mediaType: 'application/json;charset=utf-8',
|
||||
serialize: serializeOneNoteStructuredJson,
|
||||
},
|
||||
text: {
|
||||
extension: 'txt',
|
||||
mediaType: 'text/plain;charset=utf-8',
|
||||
serialize: serializeOneNotePlainText,
|
||||
},
|
||||
markdown: {
|
||||
extension: 'md',
|
||||
mediaType: 'text/markdown;charset=utf-8',
|
||||
serialize: serializeOneNoteMarkdown,
|
||||
},
|
||||
html: {
|
||||
extension: 'html',
|
||||
mediaType: 'text/html;charset=utf-8',
|
||||
serialize: serializeOneNoteSemanticHtml,
|
||||
},
|
||||
};
|
||||
|
||||
function sectionSnapshot(
|
||||
source: OneNoteSectionDto,
|
||||
sessionPages: ReadonlyMap<string, OneNotePageDto>,
|
||||
sessionResources: ReadonlyMap<string, OneNoteResource>
|
||||
): OneNoteExportSnapshot {
|
||||
const collected = collectPages(source.pages, sessionPages);
|
||||
const context: ExportedSectionContext = {
|
||||
parserSection: source,
|
||||
exportSection: {
|
||||
id: 'section',
|
||||
name: source.sectionName ?? filenameStem(source.sourceName, 'Section'),
|
||||
sourcePath: source.sourceName,
|
||||
pages: collected.pages,
|
||||
diagnostics: subtractNestedDiagnostics(
|
||||
source.diagnostics,
|
||||
collected.pages
|
||||
),
|
||||
},
|
||||
};
|
||||
const supportNotes = [PARTIAL_SUPPORT_NOTE];
|
||||
if (collected.missingCount > 0) {
|
||||
supportNotes.push(missingPageSupportNote(collected.missingCount));
|
||||
}
|
||||
|
||||
return createOneNoteExportSnapshot({
|
||||
sourceName: source.sourceName,
|
||||
sourceFormat: 'one',
|
||||
notebookName:
|
||||
source.sectionName ?? filenameStem(source.sourceName, 'Section'),
|
||||
parser: parserMetadata([source]),
|
||||
supportLevel: 'partial',
|
||||
supportNotes,
|
||||
sections: [context.exportSection],
|
||||
resources: collectResources([context], sessionResources),
|
||||
diagnostics: collected.diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
function packageSnapshot(
|
||||
source: OneNotePackageDto,
|
||||
sessionPages: ReadonlyMap<string, OneNotePageDto>,
|
||||
sessionResources: ReadonlyMap<string, OneNoteResource>
|
||||
): OneNoteExportSnapshot {
|
||||
const contexts: ExportedSectionContext[] = [];
|
||||
const globalDiagnostics = [
|
||||
...source.diagnostics,
|
||||
...source.sections.flatMap((section) => section.diagnostics),
|
||||
];
|
||||
let missingPageCount = 0;
|
||||
|
||||
for (const packaged of source.sections) {
|
||||
if (packaged.parseStatus !== 'parsed' || !packaged.section) continue;
|
||||
const collected = collectPages(
|
||||
packaged.section.pages,
|
||||
sessionPages,
|
||||
packaged.id,
|
||||
packaged.path
|
||||
);
|
||||
globalDiagnostics.push(...collected.diagnostics);
|
||||
missingPageCount += collected.missingCount;
|
||||
contexts.push({
|
||||
sessionSectionId: packaged.id,
|
||||
parserSection: packaged.section,
|
||||
exportSection: {
|
||||
id: packaged.id,
|
||||
name: packaged.displayName,
|
||||
sourcePath: packaged.path,
|
||||
pages: collected.pages,
|
||||
diagnostics: subtractNestedDiagnostics(
|
||||
packaged.section.diagnostics,
|
||||
collected.pages
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const skippedCount = source.sections.length - contexts.length;
|
||||
const supportNotes = [PARTIAL_SUPPORT_NOTE];
|
||||
if (skippedCount > 0) {
|
||||
supportNotes.push(
|
||||
`${skippedCount} package ${plural(skippedCount, 'section was', 'sections were')} omitted because ${plural(skippedCount, 'it was', 'they were')} not successfully parsed; diagnostics from omitted sections remain included.`
|
||||
);
|
||||
}
|
||||
if (missingPageCount > 0) {
|
||||
supportNotes.push(missingPageSupportNote(missingPageCount));
|
||||
}
|
||||
|
||||
return createOneNoteExportSnapshot({
|
||||
sourceName: source.sourceName,
|
||||
sourceFormat: 'onepkg',
|
||||
notebookName: filenameStem(source.sourceName, 'Notebook'),
|
||||
parser: parserMetadata(
|
||||
contexts.map(({ parserSection }) => parserSection),
|
||||
true
|
||||
),
|
||||
supportLevel: 'partial',
|
||||
supportNotes,
|
||||
sections: contexts.map(({ exportSection }) => exportSection),
|
||||
resources: collectResources(contexts, sessionResources),
|
||||
diagnostics: globalDiagnostics,
|
||||
tree: source.tree,
|
||||
});
|
||||
}
|
||||
|
||||
const PARTIAL_SUPPORT_NOTE =
|
||||
'This export reflects the parser-supported read-only subset; unsupported OneNote structures may remain diagnostic-only.';
|
||||
|
||||
function parserMetadata(
|
||||
sections: readonly OneNoteSectionDto[],
|
||||
packageSource = false
|
||||
): Partial<OneNoteExportParserMetadata> {
|
||||
const variants = unique([
|
||||
...(packageSource ? ['onepkg-cabinet'] : []),
|
||||
...sections.map(({ parserInfo }) => parserInfo.supportedVariant),
|
||||
]);
|
||||
const revisions = unique(
|
||||
sections.map(({ parserInfo }) => parserInfo.referenceRevision)
|
||||
);
|
||||
return {
|
||||
name: 'onenote-tools TypeScript parser',
|
||||
implementation: 'typescript',
|
||||
supportedVariants: variants,
|
||||
...(revisions.length > 0
|
||||
? { referenceRevision: revisions.join('; ') }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function collectPages(
|
||||
summaries: readonly OneNotePageSummaryDto[],
|
||||
sessionPages: ReadonlyMap<string, OneNotePageDto>,
|
||||
sessionSectionId?: string,
|
||||
sectionPath?: string
|
||||
): CollectedPages {
|
||||
const fullPages: OneNotePageDto[] = [];
|
||||
const diagnostics: ParserDiagnostic[] = [];
|
||||
for (const summary of summaries) {
|
||||
const page = sessionPages.get(workerPageKey(summary.id, sessionSectionId));
|
||||
if (page) {
|
||||
fullPages.push(page);
|
||||
continue;
|
||||
}
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
code: 'export-page-unavailable',
|
||||
message: `Page ${summary.title || summary.id} was omitted because its full parsed content is no longer available in this session.`,
|
||||
structure: `${sectionPath ? `${sectionPath}/` : ''}page ${summary.id}`,
|
||||
recoverable: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
pages: fullPages,
|
||||
diagnostics,
|
||||
missingCount: summaries.length - fullPages.length,
|
||||
};
|
||||
}
|
||||
|
||||
function subtractNestedDiagnostics(
|
||||
sectionDiagnostics: readonly ParserDiagnostic[],
|
||||
pages: readonly OneNotePageDto[]
|
||||
): ParserDiagnostic[] {
|
||||
const nested = new Map<string, number>();
|
||||
for (const diagnostic of pages.flatMap((page) => page.diagnostics)) {
|
||||
const key = diagnosticKey(diagnostic);
|
||||
nested.set(key, (nested.get(key) ?? 0) + 1);
|
||||
}
|
||||
return sectionDiagnostics.filter((diagnostic) => {
|
||||
const key = diagnosticKey(diagnostic);
|
||||
const remaining = nested.get(key) ?? 0;
|
||||
if (remaining === 0) return true;
|
||||
nested.set(key, remaining - 1);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function diagnosticKey(diagnostic: ParserDiagnostic): string {
|
||||
return JSON.stringify([
|
||||
diagnostic.severity,
|
||||
diagnostic.code,
|
||||
diagnostic.message,
|
||||
diagnostic.offset ?? null,
|
||||
diagnostic.structure ?? null,
|
||||
diagnostic.propertyId ?? null,
|
||||
diagnostic.recoverable,
|
||||
]);
|
||||
}
|
||||
|
||||
function collectResources(
|
||||
sections: readonly ExportedSectionContext[],
|
||||
sessionResources: ReadonlyMap<string, OneNoteResource>
|
||||
): OneNoteExportResource[] {
|
||||
const result: OneNoteExportResource[] = [];
|
||||
const sectionBySessionId = new Map(
|
||||
sections.map((section) => [section.sessionSectionId, section] as const)
|
||||
);
|
||||
for (const [key, resource] of sessionResources) {
|
||||
const sessionSectionId = resourceSectionId(key, resource.id);
|
||||
if (sessionSectionId === INVALID_RESOURCE_SECTION) continue;
|
||||
const context = sectionBySessionId.get(sessionSectionId);
|
||||
if (!context) continue;
|
||||
const payload = resourcePayload(resource);
|
||||
if (!payload) continue;
|
||||
result.push({
|
||||
sectionId: context.exportSection.id,
|
||||
id: payload.id,
|
||||
kind: payload.kind,
|
||||
filename: payload.filename,
|
||||
extension: payload.extension,
|
||||
mediaType: payload.mediaType,
|
||||
browserRenderable: payload.browserRenderable,
|
||||
size: payload.size,
|
||||
bytes: payload.bytes,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const INVALID_RESOURCE_SECTION = Symbol('invalid-resource-section');
|
||||
|
||||
function resourceSectionId(
|
||||
key: string,
|
||||
resourceId: string
|
||||
): string | undefined | typeof INVALID_RESOURCE_SECTION {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(key);
|
||||
} catch {
|
||||
return INVALID_RESOURCE_SECTION;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(parsed) ||
|
||||
parsed.length !== 2 ||
|
||||
(parsed[0] !== null && typeof parsed[0] !== 'string') ||
|
||||
parsed[1] !== resourceId
|
||||
) {
|
||||
return INVALID_RESOURCE_SECTION;
|
||||
}
|
||||
const sectionId = parsed[0] ?? undefined;
|
||||
return workerResourceKey(resourceId, sectionId) === key
|
||||
? sectionId
|
||||
: INVALID_RESOURCE_SECTION;
|
||||
}
|
||||
|
||||
function filenameStem(value: string, fallback: string): string {
|
||||
const basename = value.split(/[\\/]/u).at(-1)?.split('\0').join('').trim();
|
||||
if (!basename) return fallback;
|
||||
return basename.replace(/\.(?:onepkg|one)$/iu, '') || fallback;
|
||||
}
|
||||
|
||||
function missingPageSupportNote(count: number): string {
|
||||
return `${count} ${plural(count, 'page was', 'pages were')} omitted because full parsed content was unavailable in this session.`;
|
||||
}
|
||||
|
||||
function plural(count: number, singular: string, pluralValue: string): string {
|
||||
return count === 1 ? singular : pluralValue;
|
||||
}
|
||||
|
||||
function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values.filter((value) => value.trim() !== ''))];
|
||||
}
|
||||
|
||||
function copyToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const result = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(result).set(bytes);
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user