feat: parse rich OneNote content resources
This commit is contained in:
294
src/onenote/one/section-content.ts
Normal file
294
src/onenote/one/section-content.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
// 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
|
||||
//
|
||||
// Portions adapted from onenote.rs (MPL-2.0), revision
|
||||
// 5138a39a3f4e72b840932f9872fecde52fa9da60: `onenote/{section,
|
||||
// page_series,page}.rs`. Deviations: pages retain the internal content/resource
|
||||
// model needed for incremental worker integration.
|
||||
|
||||
import { filetimeToIso, oneNoteTimeToIso } from '../binary/time.js';
|
||||
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||
import type { DesktopOneStore } from '../onestore/desktop-store.js';
|
||||
import type { ObjectSpace } from '../onestore/types.js';
|
||||
import { exGuidKey } from '../onestore/types.js';
|
||||
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||
import {
|
||||
contentBlocksToPlainText,
|
||||
createContentBudget,
|
||||
parseContentObjects,
|
||||
} from './content.js';
|
||||
import type {
|
||||
OneNotePageContentModel,
|
||||
OneNoteResource,
|
||||
OneNoteSectionContentModel,
|
||||
} from './content-model.js';
|
||||
import { JCID, PROPERTY } from './constants.js';
|
||||
import {
|
||||
assertJcid,
|
||||
getObject,
|
||||
objectReferences,
|
||||
objectSpaceReferences,
|
||||
optionalString,
|
||||
optionalU32,
|
||||
optionalU64,
|
||||
requiredGuid,
|
||||
rootObject,
|
||||
} from './property-access.js';
|
||||
|
||||
interface SectionContentContext {
|
||||
store: DesktopOneStore;
|
||||
limits: OneNoteParserLimits;
|
||||
diagnostics: ParserDiagnostic[];
|
||||
resources: Map<string, OneNoteResource>;
|
||||
budget: ReturnType<typeof createContentBudget>;
|
||||
extractedChars: number;
|
||||
currentPage?: string;
|
||||
}
|
||||
|
||||
export function buildSectionContentModel(
|
||||
store: DesktopOneStore,
|
||||
sourceName: string,
|
||||
limits: OneNoteParserLimits,
|
||||
diagnostics: ParserDiagnostic[]
|
||||
): OneNoteSectionContentModel {
|
||||
if (store.kind !== 'section') {
|
||||
throw new Error('A table-of-contents store cannot be parsed as a section');
|
||||
}
|
||||
const context: SectionContentContext = {
|
||||
store,
|
||||
limits,
|
||||
diagnostics,
|
||||
resources: new Map(),
|
||||
budget: createContentBudget(),
|
||||
extractedChars: 0,
|
||||
};
|
||||
const root = store.rootObjectSpace;
|
||||
const metadata = rootObject(root, 2, 'section metadata root');
|
||||
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
|
||||
const displayName = cleanMetadataString(
|
||||
optionalString(metadata, PROPERTY.SectionDisplayName)
|
||||
);
|
||||
|
||||
const section = rootObject(root, 1, 'section content root');
|
||||
assertJcid(section, JCID.SectionNode, 'SectionNode');
|
||||
const pageSeriesIds = objectReferences(section, PROPERTY.ElementChildNodes);
|
||||
const pages: OneNotePageContentModel[] = [];
|
||||
|
||||
for (const seriesId of pageSeriesIds) {
|
||||
const series = getObject(root, seriesId, 'PageSeriesNode');
|
||||
assertJcid(series, JCID.PageSeriesNode, 'PageSeriesNode');
|
||||
const pageSpaceIds = objectSpaceReferences(
|
||||
series,
|
||||
PROPERTY.ChildGraphSpaceElementNodes
|
||||
);
|
||||
for (const pageSpaceId of pageSpaceIds) {
|
||||
context.currentPage = undefined;
|
||||
const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId));
|
||||
if (!pageSpace) {
|
||||
addDiagnostic(
|
||||
context,
|
||||
'MISSING_PAGE_SPACE',
|
||||
`Page object space ${exGuidKey(pageSpaceId)} is missing`,
|
||||
'PageSeriesNode'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (pages.length >= limits.maxPages) {
|
||||
addDiagnostic(
|
||||
context,
|
||||
'PAGE_LIMIT_REACHED',
|
||||
`Page count exceeds ${limits.maxPages}`,
|
||||
'PageSeriesNode',
|
||||
'error'
|
||||
);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
pages.push(parsePage(context, pageSpace));
|
||||
} catch (error) {
|
||||
addDiagnostic(
|
||||
context,
|
||||
'PAGE_PARSE_FAILED',
|
||||
`Could not parse page ${exGuidKey(pageSpaceId)}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'Page',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (pages.length >= limits.maxPages) break;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceName,
|
||||
sectionName: displayName ?? stripOneExtension(sourceName),
|
||||
pages,
|
||||
resources: context.resources,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePage(
|
||||
context: SectionContentContext,
|
||||
space: ObjectSpace
|
||||
): OneNotePageContentModel {
|
||||
const metadata = rootObject(space, 2, 'page metadata root');
|
||||
assertJcid(metadata, JCID.PageMetadata, 'PageMetadata');
|
||||
const pageGuid = requiredGuid(
|
||||
metadata,
|
||||
PROPERTY.NotebookManagementEntityGuid,
|
||||
'PageMetadata entity GUID'
|
||||
);
|
||||
context.currentPage = pageGuid;
|
||||
const created = optionalU64(metadata, PROPERTY.TopologyCreationTimeStamp);
|
||||
const level = optionalU32(metadata, PROPERTY.PageLevel);
|
||||
const cachedMetadataTitle = cleanMetadataString(
|
||||
optionalString(metadata, PROPERTY.CachedTitleString)
|
||||
);
|
||||
|
||||
const manifest = rootObject(space, 1, 'page content root');
|
||||
assertJcid(manifest, JCID.PageManifestNode, 'PageManifestNode');
|
||||
const pageId = objectReferences(manifest, PROPERTY.ContentChildNodes)[0];
|
||||
if (!pageId) throw new Error('PageManifestNode has no page reference');
|
||||
const page = getObject(space, pageId, 'PageNode');
|
||||
assertJcid(page, JCID.PageNode, 'PageNode');
|
||||
const updated = optionalU32(page, PROPERTY.LastModifiedTime);
|
||||
const cachedPageTitle = cleanMetadataString(
|
||||
optionalString(page, PROPERTY.CachedTitleStringFromPage)
|
||||
);
|
||||
|
||||
const contentOptions = {
|
||||
space,
|
||||
limits: context.limits,
|
||||
diagnostics: context.diagnostics,
|
||||
resources: context.resources,
|
||||
pageId: pageGuid,
|
||||
budget: context.budget,
|
||||
};
|
||||
const titleId = objectReferences(
|
||||
page,
|
||||
PROPERTY.StructureElementChildNodes
|
||||
)[0];
|
||||
let titleText: string | undefined;
|
||||
if (titleId) {
|
||||
try {
|
||||
const title = getObject(space, titleId, 'TitleNode');
|
||||
assertJcid(title, JCID.TitleNode, 'TitleNode');
|
||||
const titleBlocks = parseContentObjects(
|
||||
objectReferences(title, PROPERTY.ElementChildNodes),
|
||||
contentOptions
|
||||
);
|
||||
titleText = cleanTitle(contentBlocksToPlainText(titleBlocks));
|
||||
} catch (error) {
|
||||
addDiagnostic(
|
||||
context,
|
||||
'TITLE_PARSE_FAILED',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'TitleNode'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = parseContentObjects(
|
||||
objectReferences(page, PROPERTY.ElementChildNodes),
|
||||
contentOptions
|
||||
);
|
||||
let text = contentBlocksToPlainText(blocks);
|
||||
const remaining =
|
||||
context.limits.maxExtractedTextChars - context.extractedChars;
|
||||
if (text.length > remaining) {
|
||||
text = text.slice(0, Math.max(0, remaining));
|
||||
addDiagnostic(
|
||||
context,
|
||||
'TEXT_LIMIT_REACHED',
|
||||
`Extracted text was truncated at ${context.limits.maxExtractedTextChars} characters`,
|
||||
`Page ${pageGuid}`
|
||||
);
|
||||
}
|
||||
context.extractedChars += text.length;
|
||||
|
||||
const title =
|
||||
titleText ||
|
||||
firstNonemptyLine(text) ||
|
||||
cachedPageTitle ||
|
||||
cachedMetadataTitle ||
|
||||
'Untitled page';
|
||||
return compactObject({
|
||||
id: pageGuid,
|
||||
title,
|
||||
createdAt:
|
||||
created === undefined ? undefined : safeFiletime(context, created),
|
||||
updatedAt: updated === undefined ? undefined : oneNoteTimeToIso(updated),
|
||||
level,
|
||||
text,
|
||||
blocks,
|
||||
});
|
||||
}
|
||||
|
||||
function safeFiletime(
|
||||
context: SectionContentContext,
|
||||
value: bigint
|
||||
): string | undefined {
|
||||
try {
|
||||
return filetimeToIso(value, 'PageMetadata creation time');
|
||||
} catch (error) {
|
||||
addDiagnostic(
|
||||
context,
|
||||
'INVALID_PAGE_TIMESTAMP',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'PageMetadata'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanMetadataString(value: string | undefined): string | undefined {
|
||||
const result = value?.split('\0').join('').trim();
|
||||
return result || undefined;
|
||||
}
|
||||
|
||||
function cleanTitle(text: string): string | undefined {
|
||||
const result = text
|
||||
.split('\n')
|
||||
.map((value) => value.trim())
|
||||
.find(Boolean);
|
||||
return result || undefined;
|
||||
}
|
||||
|
||||
function firstNonemptyLine(text: string): string | undefined {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((value) => value.trim())
|
||||
.find(Boolean);
|
||||
}
|
||||
|
||||
function addDiagnostic(
|
||||
context: SectionContentContext,
|
||||
code: string,
|
||||
message: string,
|
||||
structure: string,
|
||||
severity: 'warning' | 'error' = 'warning'
|
||||
): void {
|
||||
if (context.diagnostics.length >= context.limits.maxDiagnostics) return;
|
||||
context.diagnostics.push({
|
||||
severity,
|
||||
code,
|
||||
message,
|
||||
structure: context.currentPage
|
||||
? `${structure} (page ${context.currentPage})`
|
||||
: structure,
|
||||
recoverable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function stripOneExtension(filename: string): string {
|
||||
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
|
||||
}
|
||||
|
||||
function compactObject<T extends object>(value: T): T {
|
||||
for (const key of Object.keys(value) as (keyof T)[]) {
|
||||
if (value[key] === undefined) delete value[key];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user