Files
onenote-tools/src/onenote/one/section-content.ts

294 lines
8.5 KiB
TypeScript

// 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 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 pageSpaceReference of pageSpaceIds) {
context.currentPage = undefined;
const pageSpace = store.objectSpaces.get(pageSpaceReference.key);
if (!pageSpace) {
addDiagnostic(
context,
'MISSING_PAGE_SPACE',
`Page object space ${pageSpaceReference.key} 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 ${pageSpaceReference.key}: ${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;
}