// 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; budget: ReturnType; 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(value: T): T { for (const key of Object.keys(value) as (keyof T)[]) { if (value[key] === undefined) delete value[key]; } return value; }