feat: open FSSHTTP OneNote sections
This commit is contained in:
@@ -553,11 +553,14 @@ function parseObjectGroupData(
|
|||||||
context.limits.maxVectorBytes,
|
context.limits.maxVectorBytes,
|
||||||
'Object binary item size'
|
'Object binary item size'
|
||||||
);
|
);
|
||||||
|
const offset = body.offset;
|
||||||
|
const data = body.read(size);
|
||||||
result = {
|
result = {
|
||||||
kind: 'object',
|
kind: 'object',
|
||||||
objectReferences,
|
objectReferences,
|
||||||
cellReferences,
|
cellReferences,
|
||||||
data: body.read(size),
|
data,
|
||||||
|
dataReference: { source: body.bytes, offset, size },
|
||||||
};
|
};
|
||||||
} else if (header.type === FSSHTTP_OBJECT.objectGroupBlobReference) {
|
} else if (header.type === FSSHTTP_OBJECT.objectGroupBlobReference) {
|
||||||
result = {
|
result = {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export type FssHttpObjectGroupData =
|
|||||||
objectReferences: ExGuid[];
|
objectReferences: ExGuid[];
|
||||||
cellReferences: FssHttpCellId[];
|
cellReferences: FssHttpCellId[];
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
|
dataReference: FileDataBlobReference;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'excluded';
|
kind: 'excluded';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
ObjectSpace,
|
ObjectSpace,
|
||||||
PropertyEntry,
|
PropertyEntry,
|
||||||
StoreObject,
|
StoreObject,
|
||||||
|
StoreObjectSpaceReference,
|
||||||
} from '../onestore/types.js';
|
} from '../onestore/types.js';
|
||||||
import { exGuidKey } from '../onestore/types.js';
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
|
|
||||||
@@ -40,13 +41,16 @@ export function objectReferences(
|
|||||||
propertyId,
|
propertyId,
|
||||||
countObjectReferences
|
countObjectReferences
|
||||||
);
|
);
|
||||||
|
if (object.resolvedObjectIds) {
|
||||||
|
return resolvedReferenceSlice(object.resolvedObjectIds, offset, count);
|
||||||
|
}
|
||||||
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
|
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function objectSpaceReferences(
|
export function objectSpaceReferences(
|
||||||
object: StoreObject,
|
object: StoreObject,
|
||||||
propertyId: number
|
propertyId: number
|
||||||
): ExGuid[] {
|
): StoreObjectSpaceReference[] {
|
||||||
const property = findProperty(object.props, propertyId);
|
const property = findProperty(object.props, propertyId);
|
||||||
if (!property) return [];
|
if (!property) return [];
|
||||||
let count: number;
|
let count: number;
|
||||||
@@ -60,12 +64,15 @@ export function objectSpaceReferences(
|
|||||||
propertyId,
|
propertyId,
|
||||||
countObjectSpaceReferences
|
countObjectSpaceReferences
|
||||||
);
|
);
|
||||||
|
if (object.resolvedObjectSpaceIds) {
|
||||||
|
return resolvedReferenceSlice(object.resolvedObjectSpaceIds, offset, count);
|
||||||
|
}
|
||||||
return resolveReferenceSlice(
|
return resolveReferenceSlice(
|
||||||
object,
|
object,
|
||||||
object.props.objectSpaceIds,
|
object.props.objectSpaceIds,
|
||||||
offset,
|
offset,
|
||||||
count
|
count
|
||||||
);
|
).map((id) => ({ id, key: exGuidKey(id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function rootObject(
|
export function rootObject(
|
||||||
@@ -257,6 +264,19 @@ function resolveReferenceSlice(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvedReferenceSlice<T>(
|
||||||
|
ids: readonly T[],
|
||||||
|
offset: number,
|
||||||
|
count: number
|
||||||
|
): T[] {
|
||||||
|
if (offset + count > ids.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Reference range ${offset}+${count} exceeds resolved stream length ${ids.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ids.slice(offset, offset + count);
|
||||||
|
}
|
||||||
|
|
||||||
function propertyTypeError(propertyId: number, expected: string): Error {
|
function propertyTypeError(propertyId: number, expected: string): Error {
|
||||||
return new Error(`Property 0x${propertyId.toString(16)} is not ${expected}`);
|
return new Error(`Property 0x${propertyId.toString(16)} is not ${expected}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,14 +83,14 @@ export function buildSectionContentModel(
|
|||||||
series,
|
series,
|
||||||
PROPERTY.ChildGraphSpaceElementNodes
|
PROPERTY.ChildGraphSpaceElementNodes
|
||||||
);
|
);
|
||||||
for (const pageSpaceId of pageSpaceIds) {
|
for (const pageSpaceReference of pageSpaceIds) {
|
||||||
context.currentPage = undefined;
|
context.currentPage = undefined;
|
||||||
const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId));
|
const pageSpace = store.objectSpaces.get(pageSpaceReference.key);
|
||||||
if (!pageSpace) {
|
if (!pageSpace) {
|
||||||
addDiagnostic(
|
addDiagnostic(
|
||||||
context,
|
context,
|
||||||
'MISSING_PAGE_SPACE',
|
'MISSING_PAGE_SPACE',
|
||||||
`Page object space ${exGuidKey(pageSpaceId)} is missing`,
|
`Page object space ${pageSpaceReference.key} is missing`,
|
||||||
'PageSeriesNode'
|
'PageSeriesNode'
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -111,7 +111,7 @@ export function buildSectionContentModel(
|
|||||||
addDiagnostic(
|
addDiagnostic(
|
||||||
context,
|
context,
|
||||||
'PAGE_PARSE_FAILED',
|
'PAGE_PARSE_FAILED',
|
||||||
`Could not parse page ${exGuidKey(pageSpaceId)}: ${error instanceof Error ? error.message : String(error)}`,
|
`Could not parse page ${pageSpaceReference.key}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
'Page',
|
'Page',
|
||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
ObjectSpace,
|
ObjectSpace,
|
||||||
PropertyEntry,
|
PropertyEntry,
|
||||||
StoreObject,
|
StoreObject,
|
||||||
|
StoreObjectSpaceReference,
|
||||||
} from '../onestore/types.js';
|
} from '../onestore/types.js';
|
||||||
import { exGuidKey } from '../onestore/types.js';
|
import { exGuidKey } from '../onestore/types.js';
|
||||||
import { JCID, PROPERTY } from './constants.js';
|
import { JCID, PROPERTY } from './constants.js';
|
||||||
@@ -55,7 +56,9 @@ export function buildSectionDto(
|
|||||||
const root = store.rootObjectSpace;
|
const root = store.rootObjectSpace;
|
||||||
const metadata = rootObject(root, 2, 'section metadata root');
|
const metadata = rootObject(root, 2, 'section metadata root');
|
||||||
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
|
assertJcid(metadata, JCID.SectionMetadata, 'SectionMetadata');
|
||||||
const displayName = optionalString(metadata, PROPERTY.SectionDisplayName);
|
const displayName = cleanMetadataString(
|
||||||
|
optionalString(metadata, PROPERTY.SectionDisplayName)
|
||||||
|
);
|
||||||
|
|
||||||
const section = rootObject(root, 1, 'section content root');
|
const section = rootObject(root, 1, 'section content root');
|
||||||
assertJcid(section, JCID.SectionNode, 'SectionNode');
|
assertJcid(section, JCID.SectionNode, 'SectionNode');
|
||||||
@@ -69,14 +72,14 @@ export function buildSectionDto(
|
|||||||
series,
|
series,
|
||||||
PROPERTY.ChildGraphSpaceElementNodes
|
PROPERTY.ChildGraphSpaceElementNodes
|
||||||
);
|
);
|
||||||
for (const pageSpaceId of pageSpaceIds) {
|
for (const pageSpaceReference of pageSpaceIds) {
|
||||||
context.currentPage = undefined;
|
context.currentPage = undefined;
|
||||||
const pageSpace = store.objectSpaces.get(exGuidKey(pageSpaceId));
|
const pageSpace = store.objectSpaces.get(pageSpaceReference.key);
|
||||||
if (!pageSpace) {
|
if (!pageSpace) {
|
||||||
addDiagnostic(
|
addDiagnostic(
|
||||||
context,
|
context,
|
||||||
'MISSING_PAGE_SPACE',
|
'MISSING_PAGE_SPACE',
|
||||||
`Page object space ${exGuidKey(pageSpaceId)} is missing`,
|
`Page object space ${pageSpaceReference.key} is missing`,
|
||||||
'PageSeriesNode'
|
'PageSeriesNode'
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -99,7 +102,7 @@ export function buildSectionDto(
|
|||||||
addDiagnostic(
|
addDiagnostic(
|
||||||
context,
|
context,
|
||||||
'PAGE_PARSE_FAILED',
|
'PAGE_PARSE_FAILED',
|
||||||
`Could not parse page ${exGuidKey(pageSpaceId)}: ${message}`,
|
`Could not parse page ${pageSpaceReference.key}: ${message}`,
|
||||||
'Page',
|
'Page',
|
||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
@@ -116,7 +119,7 @@ export function buildSectionDto(
|
|||||||
diagnostics,
|
diagnostics,
|
||||||
parserInfo: {
|
parserInfo: {
|
||||||
implementation: 'typescript',
|
implementation: 'typescript',
|
||||||
supportedVariant: 'desktop-revision-store',
|
supportedVariant: store.variant,
|
||||||
referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60',
|
referenceRevision: 'onenote.rs@5138a39a3f4e72b840932f9872fecde52fa9da60',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -398,13 +401,16 @@ function objectReferences(object: StoreObject, propertyId: number): ExGuid[] {
|
|||||||
propertyId,
|
propertyId,
|
||||||
countObjectReferences
|
countObjectReferences
|
||||||
);
|
);
|
||||||
|
if (object.resolvedObjectIds) {
|
||||||
|
return resolvedReferenceSlice(object.resolvedObjectIds, offset, count);
|
||||||
|
}
|
||||||
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
|
return resolveReferenceSlice(object, object.props.objectIds, offset, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function objectSpaceReferences(
|
function objectSpaceReferences(
|
||||||
object: StoreObject,
|
object: StoreObject,
|
||||||
propertyId: number
|
propertyId: number
|
||||||
): ExGuid[] {
|
): StoreObjectSpaceReference[] {
|
||||||
const property = findProperty(object.props, propertyId);
|
const property = findProperty(object.props, propertyId);
|
||||||
if (!property) return [];
|
if (!property) return [];
|
||||||
let count: number;
|
let count: number;
|
||||||
@@ -421,12 +427,15 @@ function objectSpaceReferences(
|
|||||||
propertyId,
|
propertyId,
|
||||||
countObjectSpaceReferences
|
countObjectSpaceReferences
|
||||||
);
|
);
|
||||||
|
if (object.resolvedObjectSpaceIds) {
|
||||||
|
return resolvedReferenceSlice(object.resolvedObjectSpaceIds, offset, count);
|
||||||
|
}
|
||||||
return resolveReferenceSlice(
|
return resolveReferenceSlice(
|
||||||
object,
|
object,
|
||||||
object.props.objectSpaceIds,
|
object.props.objectSpaceIds,
|
||||||
offset,
|
offset,
|
||||||
count
|
count
|
||||||
);
|
).map((id) => ({ id, key: exGuidKey(id) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveReferenceSlice(
|
function resolveReferenceSlice(
|
||||||
@@ -447,6 +456,19 @@ function resolveReferenceSlice(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvedReferenceSlice<T>(
|
||||||
|
ids: readonly T[],
|
||||||
|
offset: number,
|
||||||
|
count: number
|
||||||
|
): T[] {
|
||||||
|
if (offset + count > ids.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Reference range ${offset}+${count} exceeds resolved stream length ${ids.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ids.slice(offset, offset + count);
|
||||||
|
}
|
||||||
|
|
||||||
function rootObject(
|
function rootObject(
|
||||||
space: ObjectSpace,
|
space: ObjectSpace,
|
||||||
role: number,
|
role: number,
|
||||||
@@ -659,3 +681,8 @@ function addDiagnostic(
|
|||||||
function stripOneExtension(filename: string): string {
|
function stripOneExtension(filename: string): string {
|
||||||
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
|
return (filename.split(/[\\/]/).at(-1) ?? filename).replace(/\.one$/i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanMetadataString(value: string | undefined): string | undefined {
|
||||||
|
const result = value?.split('\0').join('').trim();
|
||||||
|
return result || undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { exGuidKey } from './types.js';
|
|||||||
|
|
||||||
export interface DesktopOneStore {
|
export interface DesktopOneStore {
|
||||||
kind: 'section' | 'table-of-contents';
|
kind: 'section' | 'table-of-contents';
|
||||||
|
variant: 'desktop-revision-store' | 'fsshttpb-package-store';
|
||||||
fileIdentity: string;
|
fileIdentity: string;
|
||||||
rootObjectSpace: ObjectSpace;
|
rootObjectSpace: ObjectSpace;
|
||||||
objectSpaces: Map<string, ObjectSpace>;
|
objectSpaces: Map<string, ObjectSpace>;
|
||||||
@@ -185,6 +186,7 @@ function parseDesktopRevisionStore(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
kind: expectedKind === 'desktop-one' ? 'section' : 'table-of-contents',
|
kind: expectedKind === 'desktop-one' ? 'section' : 'table-of-contents',
|
||||||
|
variant: 'desktop-revision-store',
|
||||||
fileIdentity,
|
fileIdentity,
|
||||||
rootObjectSpace,
|
rootObjectSpace,
|
||||||
objectSpaces,
|
objectSpaces,
|
||||||
|
|||||||
48
src/onenote/onestore/fsshttp-store.test.ts
Normal file
48
src/onenote/onestore/fsshttp-store.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseOneNoteSection,
|
||||||
|
parseOneNoteSectionContent,
|
||||||
|
} from '../parser/parse-section.js';
|
||||||
|
|
||||||
|
function fixtureBytes(): Uint8Array {
|
||||||
|
const encoded = readFileSync(
|
||||||
|
resolve(process.cwd(), 'tests/fixtures/joplin-quick-notes-fsshttp.one.b64'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return Uint8Array.from(Buffer.from(encoded.replaceAll(/\s/gu, ''), 'base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FSSHTTPB OneStore adapter', () => {
|
||||||
|
it('opens the pinned Joplin section through the public parser', () => {
|
||||||
|
const parsed = parseOneNoteSection(fixtureBytes(), {
|
||||||
|
sourceName: 'Quick Notes.one',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
format: 'one',
|
||||||
|
sourceName: 'Quick Notes.one',
|
||||||
|
sectionName: 'Section title.one',
|
||||||
|
parserInfo: { supportedVariant: 'fsshttpb-package-store' },
|
||||||
|
});
|
||||||
|
expect(parsed.pages).toHaveLength(1);
|
||||||
|
expect(parsed.pages[0]).toMatchObject({
|
||||||
|
title: 'Page title',
|
||||||
|
text: expect.stringContaining('Page content'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feeds FSSHTTPB objects into the rich-content model', () => {
|
||||||
|
const parsed = parseOneNoteSectionContent(fixtureBytes(), {
|
||||||
|
sourceName: 'Quick Notes.one',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.sectionName).toBe('Section title.one');
|
||||||
|
expect(parsed.pages).toHaveLength(1);
|
||||||
|
expect(parsed.pages[0]).toMatchObject({ title: 'Page title' });
|
||||||
|
expect(parsed.pages[0]?.text).toContain('Page content');
|
||||||
|
});
|
||||||
|
});
|
||||||
694
src/onenote/onestore/fsshttp-store.ts
Normal file
694
src/onenote/onestore/fsshttp-store.ts
Normal file
@@ -0,0 +1,694 @@
|
|||||||
|
// 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: `onestore/fsshttpb/*`.
|
||||||
|
// Deviations: immutable browser-native records, explicit cycle/count bounds,
|
||||||
|
// newest-revision precedence, and store-specific resolved reference arrays.
|
||||||
|
|
||||||
|
import { BinaryReader } from '../binary/BinaryReader.js';
|
||||||
|
import { NIL_GUID, readGuid } from '../binary/guid.js';
|
||||||
|
import {
|
||||||
|
fssHttpCellKey,
|
||||||
|
type FssHttpCellId,
|
||||||
|
type FssHttpObjectDeclaration,
|
||||||
|
type FssHttpObjectGroup,
|
||||||
|
type FssHttpObjectGroupData,
|
||||||
|
type FssHttpOneStorePackage,
|
||||||
|
type FssHttpRevisionManifest,
|
||||||
|
type FssHttpSerialNumber,
|
||||||
|
type FssHttpStorageIndex,
|
||||||
|
type FssHttpStorageIndexCellMapping,
|
||||||
|
type FssHttpStorageManifest,
|
||||||
|
} from '../fsshttpb/types.js';
|
||||||
|
import type { ParserDiagnostic } from '../model/diagnostics.js';
|
||||||
|
import { ONE_SECTION_FILE_TYPE } from '../parser/detect-format.js';
|
||||||
|
import { OneNoteParserError } from '../parser/error.js';
|
||||||
|
import type { OneNoteParserLimits } from '../parser/limits.js';
|
||||||
|
import { parseFssHttpOneStorePackage } from '../fsshttpb/package.js';
|
||||||
|
import type { DesktopOneStore } from './desktop-store.js';
|
||||||
|
import { findProperty, parseObjectPropSetRange } from './property-set.js';
|
||||||
|
import type {
|
||||||
|
ExGuid,
|
||||||
|
ObjectSpace,
|
||||||
|
PropertyEntry,
|
||||||
|
StoreFileData,
|
||||||
|
StoreObject,
|
||||||
|
} from './types.js';
|
||||||
|
import { exGuidKey } from './types.js';
|
||||||
|
|
||||||
|
const HEADER_ROOT = exGuidKey({
|
||||||
|
guid: '{1A5A319C-C26B-41AA-B9C5-9BD8C44E07D4}',
|
||||||
|
value: 1,
|
||||||
|
});
|
||||||
|
const DATA_ROOT = exGuidKey({
|
||||||
|
guid: '{84DEFAB9-AAA3-4A0D-A3A8-520C77AC7073}',
|
||||||
|
value: 2,
|
||||||
|
});
|
||||||
|
const REVISION_ROLE_GUID = '{4A3717F8-1C14-49E7-9526-81D942DE1741}';
|
||||||
|
const PROPERTY_FILE_IDENTITY_GUID = 0x1c001d94;
|
||||||
|
const PROPERTY_FILE_ANCESTOR_IDENTITY_GUID = 0x1c001d95;
|
||||||
|
const PROPERTY_FILE_NAME_CRC = 0x14001d93;
|
||||||
|
|
||||||
|
interface StoreContext {
|
||||||
|
package: FssHttpOneStorePackage;
|
||||||
|
index: FssHttpStorageIndex;
|
||||||
|
limits: OneNoteParserLimits;
|
||||||
|
diagnostics: ParserDiagnostic[];
|
||||||
|
objectCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupEntry {
|
||||||
|
declaration: FssHttpObjectDeclaration;
|
||||||
|
data: FssHttpObjectGroupData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an unfragmented FSSHTTPB package into the shared OneStore model. */
|
||||||
|
export function parseFssHttpOneStore(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: ParserDiagnostic[]
|
||||||
|
): DesktopOneStore {
|
||||||
|
const packageStore = parseFssHttpOneStorePackage(bytes, limits);
|
||||||
|
if (packageStore.dataElements.fragments.size > 0) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'UNSUPPORTED_FORMAT',
|
||||||
|
'FSSHTTPB Data Element Fragment reconstruction is not supported',
|
||||||
|
{ structure: 'Data Element Fragment' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = selectStorageIndex(packageStore, limits, diagnostics);
|
||||||
|
const context: StoreContext = {
|
||||||
|
package: packageStore,
|
||||||
|
index,
|
||||||
|
limits,
|
||||||
|
diagnostics,
|
||||||
|
objectCount: 0,
|
||||||
|
};
|
||||||
|
const manifest = selectStorageManifest(context);
|
||||||
|
if (manifest.schema !== packageStore.cellSchema) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Storage Manifest schema ${manifest.schema} does not match package schema ${packageStore.cellSchema}`,
|
||||||
|
{ structure: 'Storage Manifest' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerCell = requiredManifestRoot(manifest, HEADER_ROOT, 'header');
|
||||||
|
const dataRootCell = requiredManifestRoot(manifest, DATA_ROOT, 'data');
|
||||||
|
validateHeaderCell(context, headerCell);
|
||||||
|
|
||||||
|
const rootObjectSpace = parseObjectSpace(context, dataRootCell);
|
||||||
|
const objectSpaces = new Map<string, ObjectSpace>();
|
||||||
|
objectSpaces.set(fssHttpCellKey(dataRootCell), rootObjectSpace);
|
||||||
|
|
||||||
|
const skippedCells = new Set([
|
||||||
|
fssHttpCellKey(headerCell),
|
||||||
|
fssHttpCellKey(dataRootCell),
|
||||||
|
]);
|
||||||
|
for (const mapping of index.cellMappings.values()) {
|
||||||
|
const key = fssHttpCellKey(mapping.cellId);
|
||||||
|
if (skippedCells.has(key) || isNilExGuid(mapping.id)) continue;
|
||||||
|
objectSpaces.set(key, parseObjectSpace(context, mapping.cellId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind:
|
||||||
|
packageStore.fileType === ONE_SECTION_FILE_TYPE
|
||||||
|
? 'section'
|
||||||
|
: 'table-of-contents',
|
||||||
|
variant: 'fsshttpb-package-store',
|
||||||
|
fileIdentity: packageStore.fileIdentity,
|
||||||
|
rootObjectSpace,
|
||||||
|
objectSpaces,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStorageIndex(
|
||||||
|
packageStore: FssHttpOneStorePackage,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: ParserDiagnostic[]
|
||||||
|
): FssHttpStorageIndex {
|
||||||
|
const indexes = packageStore.dataElements.storageIndexes;
|
||||||
|
const requested = indexes.get(exGuidKey(packageStore.storageIndexId));
|
||||||
|
if (requested) return requested;
|
||||||
|
if (indexes.size !== 1) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Storage Index ${exGuidKey(packageStore.storageIndexId)} is absent`,
|
||||||
|
{ structure: 'Storage Index' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addDiagnostic(
|
||||||
|
diagnostics,
|
||||||
|
limits,
|
||||||
|
'FSSHTTP_STORAGE_INDEX_FALLBACK',
|
||||||
|
`Storage Index ${exGuidKey(packageStore.storageIndexId)} was absent; used the package's only Storage Index`,
|
||||||
|
'Storage Index'
|
||||||
|
);
|
||||||
|
return indexes.values().next().value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStorageManifest(context: StoreContext): FssHttpStorageManifest {
|
||||||
|
const manifests = context.package.dataElements.storageManifests;
|
||||||
|
for (const mapping of context.index.manifestMappings) {
|
||||||
|
const manifest = manifests.get(exGuidKey(mapping.id));
|
||||||
|
if (manifest) return manifest;
|
||||||
|
}
|
||||||
|
if (manifests.size !== 1) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
context.index.manifestMappings.length === 0
|
||||||
|
? 'Storage Index has no resolvable Storage Manifest mapping'
|
||||||
|
: 'Storage Index manifest mappings do not resolve uniquely',
|
||||||
|
{ structure: 'Storage Manifest' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addDiagnostic(
|
||||||
|
context.diagnostics,
|
||||||
|
context.limits,
|
||||||
|
'FSSHTTP_STORAGE_MANIFEST_FALLBACK',
|
||||||
|
"Storage Index manifest mappings did not resolve; used the package's only Storage Manifest",
|
||||||
|
'Storage Manifest'
|
||||||
|
);
|
||||||
|
return manifests.values().next().value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredManifestRoot(
|
||||||
|
manifest: FssHttpStorageManifest,
|
||||||
|
key: string,
|
||||||
|
name: string
|
||||||
|
): FssHttpCellId {
|
||||||
|
const cell = manifest.roots.get(key);
|
||||||
|
if (!cell) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Storage Manifest has no ${name} cell root`,
|
||||||
|
{ structure: 'Storage Manifest root' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHeaderCell(context: StoreContext, cell: FssHttpCellId): void {
|
||||||
|
const mapping = requiredCellMapping(context, cell);
|
||||||
|
const revisionId = resolveCurrentRevisionId(context, mapping);
|
||||||
|
if (!revisionId) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'Header cell has no current revision',
|
||||||
|
{ structure: 'OneStore package header cell' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const revision = requiredRevisionManifest(context, revisionId);
|
||||||
|
const entries = revision.objectGroupIds.flatMap((groupId) =>
|
||||||
|
pairedGroupEntries(context, groupId)
|
||||||
|
);
|
||||||
|
const headerEntry = entries.find(
|
||||||
|
({ declaration }) => declaration.partitionId === 1
|
||||||
|
);
|
||||||
|
if (!headerEntry || headerEntry.data.kind !== 'object') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Header cell has no object-data property set',
|
||||||
|
{ structure: 'OneStore package header cell' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const props = parseObjectPropSetRange(
|
||||||
|
headerEntry.data.dataReference.source,
|
||||||
|
headerEntry.data.dataReference.offset,
|
||||||
|
headerEntry.data.dataReference.size,
|
||||||
|
context.limits
|
||||||
|
);
|
||||||
|
requiredBytesProperty(
|
||||||
|
props.properties.entries,
|
||||||
|
PROPERTY_FILE_IDENTITY_GUID,
|
||||||
|
16
|
||||||
|
);
|
||||||
|
requiredBytesProperty(
|
||||||
|
props.properties.entries,
|
||||||
|
PROPERTY_FILE_ANCESTOR_IDENTITY_GUID,
|
||||||
|
16
|
||||||
|
);
|
||||||
|
const crc = findProperty(props, PROPERTY_FILE_NAME_CRC);
|
||||||
|
if (!crc || crc.value.kind !== 'u32') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Header cell FileNameCrc property is absent or malformed',
|
||||||
|
{ structure: 'OneStore package header cell' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredBytesProperty(
|
||||||
|
entries: PropertyEntry[],
|
||||||
|
propertyId: number,
|
||||||
|
length: number
|
||||||
|
): Uint8Array {
|
||||||
|
const entry = entries.find(
|
||||||
|
(candidate) => candidate.id === (propertyId & 0x03ffffff)
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!entry ||
|
||||||
|
entry.value.kind !== 'bytes' ||
|
||||||
|
entry.value.value.length !== length
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Header property 0x${propertyId.toString(16)} is absent or malformed`,
|
||||||
|
{ structure: 'OneStore package header cell' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Exercise the exact mixed-endian GUID range while preserving bounded reads.
|
||||||
|
readGuid(new BinaryReader(entry.value.value, 0, length, 'Header GUID'));
|
||||||
|
return entry.value.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectSpace(
|
||||||
|
context: StoreContext,
|
||||||
|
cell: FssHttpCellId
|
||||||
|
): ObjectSpace {
|
||||||
|
const mapping = requiredCellMapping(context, cell);
|
||||||
|
const revisionId = resolveCurrentRevisionId(context, mapping);
|
||||||
|
if (!revisionId) {
|
||||||
|
return {
|
||||||
|
id: cell.objectSpace,
|
||||||
|
roots: new Map(),
|
||||||
|
objects: new Map(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roots = new Map<number, ExGuid>();
|
||||||
|
const objects = new Map<string, StoreObject>();
|
||||||
|
for (const revision of revisionChain(context, revisionId)) {
|
||||||
|
for (const root of revision.roots) {
|
||||||
|
const role = parseRevisionRole(root.role);
|
||||||
|
if (!roots.has(role)) roots.set(role, root.objectId);
|
||||||
|
}
|
||||||
|
for (const groupId of revision.objectGroupIds) {
|
||||||
|
parseObjectGroup(context, cell, groupId, objects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { id: cell.objectSpace, roots, objects };
|
||||||
|
}
|
||||||
|
|
||||||
|
function revisionChain(
|
||||||
|
context: StoreContext,
|
||||||
|
firstRevisionId: ExGuid
|
||||||
|
): FssHttpRevisionManifest[] {
|
||||||
|
const result: FssHttpRevisionManifest[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let revisionId: ExGuid | undefined = firstRevisionId;
|
||||||
|
while (revisionId) {
|
||||||
|
if (result.length >= context.limits.maxTransactionFragments) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Revision chain exceeds ${context.limits.maxTransactionFragments} entries`,
|
||||||
|
{ structure: 'FSSHTTPB revision chain' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const key = exGuidKey(revisionId);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Revision chain contains a cycle at ${key}`,
|
||||||
|
{ structure: 'FSSHTTPB revision chain' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
const revision = requiredRevisionManifest(context, revisionId);
|
||||||
|
result.push(revision);
|
||||||
|
if (isNilExGuid(revision.baseRevisionId)) break;
|
||||||
|
revisionId = resolveRevisionId(context, revision.baseRevisionId);
|
||||||
|
if (!revisionId) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Base revision ${exGuidKey(revision.baseRevisionId)} is unresolved`,
|
||||||
|
{ structure: 'Revision Manifest' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectGroup(
|
||||||
|
context: StoreContext,
|
||||||
|
cell: FssHttpCellId,
|
||||||
|
groupId: ExGuid,
|
||||||
|
objects: Map<string, StoreObject>
|
||||||
|
): void {
|
||||||
|
const entries = pairedGroupEntries(context, groupId);
|
||||||
|
const partitions = new Map<string, Map<number, GroupEntry>>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const objectKey = exGuidKey(entry.declaration.objectId);
|
||||||
|
let objectPartitions = partitions.get(objectKey);
|
||||||
|
if (!objectPartitions) {
|
||||||
|
objectPartitions = new Map();
|
||||||
|
partitions.set(objectKey, objectPartitions);
|
||||||
|
}
|
||||||
|
if (objectPartitions.has(entry.declaration.partitionId)) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} repeats partition ${entry.declaration.partitionId}`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
objectPartitions.set(entry.declaration.partitionId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [objectKey, objectPartitions] of partitions) {
|
||||||
|
if (objects.has(objectKey)) continue;
|
||||||
|
context.objectCount += 1;
|
||||||
|
if (context.objectCount > context.limits.maxObjects) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'LIMIT_EXCEEDED',
|
||||||
|
`Resolved object count exceeds ${context.limits.maxObjects}`,
|
||||||
|
{ structure: 'FSSHTTPB Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
objects.set(
|
||||||
|
objectKey,
|
||||||
|
parseStoreObject(context, cell, objectPartitions, objectKey)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pairedGroupEntries(
|
||||||
|
context: StoreContext,
|
||||||
|
groupId: ExGuid
|
||||||
|
): GroupEntry[] {
|
||||||
|
const group = context.package.dataElements.objectGroups.get(
|
||||||
|
exGuidKey(groupId)
|
||||||
|
);
|
||||||
|
if (!group) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Object Group ${exGuidKey(groupId)} is absent`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validateGroupCounts(group, groupId);
|
||||||
|
return group.declarations.map((declaration, index) => {
|
||||||
|
const data = group.data[index]!;
|
||||||
|
validateDeclarationData(declaration, data);
|
||||||
|
return { declaration, data };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateGroupCounts(group: FssHttpObjectGroup, groupId: ExGuid): void {
|
||||||
|
if (group.declarations.length !== group.data.length) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object Group ${exGuidKey(groupId)} has ${group.declarations.length} declarations and ${group.data.length} data entries`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
group.changeFrequencies.length !== 0 &&
|
||||||
|
group.changeFrequencies.length !== group.declarations.length
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object Group ${exGuidKey(groupId)} metadata count does not match its declarations`,
|
||||||
|
{ structure: 'Object Group Metadata Block' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDeclarationData(
|
||||||
|
declaration: FssHttpObjectDeclaration,
|
||||||
|
data: FssHttpObjectGroupData
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
declaration.objectReferenceCount !== data.objectReferences.length ||
|
||||||
|
declaration.cellReferenceCount !== data.cellReferences.length
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${exGuidKey(declaration.objectId)} declaration/reference counts do not match its data`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (declaration.kind === 'object') {
|
||||||
|
const actualSize =
|
||||||
|
data.kind === 'object'
|
||||||
|
? data.data.byteLength
|
||||||
|
: data.kind === 'excluded'
|
||||||
|
? data.dataSize
|
||||||
|
: undefined;
|
||||||
|
if (actualSize === undefined || declaration.dataSize !== actualSize) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${exGuidKey(declaration.objectId)} declaration/data sizes do not match`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoreObject(
|
||||||
|
context: StoreContext,
|
||||||
|
cell: FssHttpCellId,
|
||||||
|
partitions: ReadonlyMap<number, GroupEntry>,
|
||||||
|
objectKey: string
|
||||||
|
): StoreObject {
|
||||||
|
const metadata = requiredObjectPartition(
|
||||||
|
partitions,
|
||||||
|
4,
|
||||||
|
objectKey,
|
||||||
|
'metadata'
|
||||||
|
);
|
||||||
|
const objectData = requiredObjectPartition(partitions, 1, objectKey, 'data');
|
||||||
|
const metadataReader = new BinaryReader(
|
||||||
|
metadata.dataReference.source,
|
||||||
|
metadata.dataReference.offset,
|
||||||
|
metadata.dataReference.size,
|
||||||
|
'FSSHTTPB object metadata'
|
||||||
|
);
|
||||||
|
const jcid = metadataReader.u32();
|
||||||
|
const props = parseObjectPropSetRange(
|
||||||
|
objectData.dataReference.source,
|
||||||
|
objectData.dataReference.offset,
|
||||||
|
objectData.dataReference.size,
|
||||||
|
context.limits
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextCells = objectData.cellReferences.filter(
|
||||||
|
(reference) =>
|
||||||
|
exGuidKey(reference.objectSpace) === exGuidKey(cell.objectSpace)
|
||||||
|
);
|
||||||
|
const objectSpaceCells = objectData.cellReferences.filter(
|
||||||
|
(reference) =>
|
||||||
|
exGuidKey(reference.objectSpace) !== exGuidKey(cell.objectSpace)
|
||||||
|
);
|
||||||
|
if (props.objectIds.length < objectData.objectReferences.length) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} has fewer property-set object IDs than resolved references`,
|
||||||
|
{ structure: 'ObjectPropSet mapping table' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
props.contextIds.length !== contextCells.length ||
|
||||||
|
props.objectSpaceIds.length !== objectSpaceCells.length
|
||||||
|
) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} property-set cell reference counts do not match`,
|
||||||
|
{ structure: 'ObjectPropSet mapping table' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectId = partitions.values().next().value!.declaration.objectId;
|
||||||
|
return {
|
||||||
|
id: objectId,
|
||||||
|
jcid,
|
||||||
|
props,
|
||||||
|
mapping: new Map(),
|
||||||
|
contextId: cell.context,
|
||||||
|
resolvedObjectIds: objectData.objectReferences,
|
||||||
|
resolvedObjectSpaceIds: objectSpaceCells.map((reference) => ({
|
||||||
|
id: reference.objectSpace,
|
||||||
|
key: fssHttpCellKey(reference),
|
||||||
|
})),
|
||||||
|
resolvedContextIds: contextCells.map((reference) => reference.context),
|
||||||
|
fileData: parseFileData(context, partitions, objectKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredObjectPartition(
|
||||||
|
partitions: ReadonlyMap<number, GroupEntry>,
|
||||||
|
partitionId: number,
|
||||||
|
objectKey: string,
|
||||||
|
name: string
|
||||||
|
): Extract<FssHttpObjectGroupData, { kind: 'object' }> {
|
||||||
|
const entry = partitions.get(partitionId);
|
||||||
|
if (!entry) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Object ${objectKey} ${name} partition is absent`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.data.kind === 'excluded') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'UNSUPPORTED_FORMAT',
|
||||||
|
`Object ${objectKey} ${name} partition is excluded from this package`,
|
||||||
|
{ structure: 'Object Group Data Excluded' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.data.kind !== 'object') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} ${name} partition is not object data`,
|
||||||
|
{ structure: 'Object Group' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFileData(
|
||||||
|
context: StoreContext,
|
||||||
|
partitions: ReadonlyMap<number, GroupEntry>,
|
||||||
|
objectKey: string
|
||||||
|
): StoreFileData | undefined {
|
||||||
|
const entry = partitions.get(2);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (entry.data.kind !== 'blob-reference') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
entry.data.kind === 'excluded'
|
||||||
|
? 'UNSUPPORTED_FORMAT'
|
||||||
|
: 'INVALID_STRUCTURE',
|
||||||
|
`Object ${objectKey} file-data partition is not an available BLOB reference`,
|
||||||
|
{ structure: 'Object Group BLOB reference' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blob = context.package.dataElements.blobs.get(
|
||||||
|
exGuidKey(entry.data.blobId)
|
||||||
|
);
|
||||||
|
if (!blob) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Object Data BLOB ${exGuidKey(entry.data.blobId)} is absent`,
|
||||||
|
{ structure: 'Object Data BLOB' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
referenceKind: 'embedded',
|
||||||
|
dataReference: `<fsshttpb>${exGuidKey(entry.data.blobId)}`,
|
||||||
|
extension: '',
|
||||||
|
storageGuid: entry.data.blobId.guid,
|
||||||
|
blob,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredCellMapping(
|
||||||
|
context: StoreContext,
|
||||||
|
cell: FssHttpCellId
|
||||||
|
): FssHttpStorageIndexCellMapping {
|
||||||
|
const mapping = context.index.cellMappings.get(fssHttpCellKey(cell));
|
||||||
|
if (!mapping) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Cell mapping ${fssHttpCellKey(cell)} is absent`,
|
||||||
|
{ structure: 'Storage Index Cell Mapping' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCurrentRevisionId(
|
||||||
|
context: StoreContext,
|
||||||
|
mapping: FssHttpStorageIndexCellMapping
|
||||||
|
): ExGuid | undefined {
|
||||||
|
const cellRevision = context.package.dataElements.cellManifests.get(
|
||||||
|
exGuidKey(mapping.id)
|
||||||
|
);
|
||||||
|
if (cellRevision && isNilExGuid(cellRevision)) return undefined;
|
||||||
|
const resolved = cellRevision
|
||||||
|
? resolveRevisionId(context, cellRevision)
|
||||||
|
: resolveRevisionId(context, mapping.id);
|
||||||
|
if (resolved) return resolved;
|
||||||
|
|
||||||
|
const serialMatches = [...context.index.revisionMappings.values()].filter(
|
||||||
|
(candidate) => sameSerial(candidate.serial, mapping.serial)
|
||||||
|
);
|
||||||
|
if (serialMatches.length > 1) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
'Cell mapping serial number resolves to multiple revision mappings',
|
||||||
|
{ structure: 'Storage Index Revision Mapping' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return serialMatches[0]?.revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRevisionId(
|
||||||
|
context: StoreContext,
|
||||||
|
id: ExGuid
|
||||||
|
): ExGuid | undefined {
|
||||||
|
const mapped = context.index.revisionMappings.get(exGuidKey(id));
|
||||||
|
if (mapped) return mapped.revision;
|
||||||
|
return context.package.dataElements.revisionManifests.has(exGuidKey(id))
|
||||||
|
? id
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredRevisionManifest(
|
||||||
|
context: StoreContext,
|
||||||
|
id: ExGuid
|
||||||
|
): FssHttpRevisionManifest {
|
||||||
|
const revision = context.package.dataElements.revisionManifests.get(
|
||||||
|
exGuidKey(id)
|
||||||
|
);
|
||||||
|
if (!revision) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
`Revision Manifest ${exGuidKey(id)} is absent`,
|
||||||
|
{ structure: 'Revision Manifest' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRevisionRole(role: ExGuid): number {
|
||||||
|
if (role.guid !== REVISION_ROLE_GUID || role.value < 1 || role.value > 4) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_STRUCTURE',
|
||||||
|
`Invalid revision root role ${exGuidKey(role)}`,
|
||||||
|
{ structure: 'Revision Manifest root declaration' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return role.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNilExGuid(id: ExGuid): boolean {
|
||||||
|
return id.guid === NIL_GUID && id.value === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSerial(
|
||||||
|
left: FssHttpSerialNumber,
|
||||||
|
right: FssHttpSerialNumber
|
||||||
|
): boolean {
|
||||||
|
return left.guid === right.guid && left.value === right.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDiagnostic(
|
||||||
|
diagnostics: ParserDiagnostic[],
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
structure: string
|
||||||
|
): void {
|
||||||
|
if (diagnostics.length >= limits.maxDiagnostics) return;
|
||||||
|
diagnostics.push({
|
||||||
|
severity: 'warning',
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
structure,
|
||||||
|
recoverable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -39,13 +39,30 @@ export function parseObjectPropSet(
|
|||||||
{ offset: reference.offset, structure: 'ObjectPropSet' }
|
{ offset: reference.offset, structure: 'ObjectPropSet' }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const reader = new BinaryReader(
|
return parseObjectPropSetRange(
|
||||||
bytes,
|
bytes,
|
||||||
reference.offset,
|
reference.offset,
|
||||||
reference.size,
|
reference.size,
|
||||||
'ObjectPropSet'
|
limits
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a property set from an exact bounded range in the source file. */
|
||||||
|
export function parseObjectPropSetRange(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
size: number,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): ObjectPropSet {
|
||||||
|
const reader = new BinaryReader(bytes, offset, size, 'ObjectPropSet');
|
||||||
|
|
||||||
|
return parseObjectPropSetReader(reader, limits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectPropSetReader(
|
||||||
|
reader: BinaryReader,
|
||||||
|
limits: OneNoteParserLimits
|
||||||
|
): ObjectPropSet {
|
||||||
const oidHeader = readStreamHeader(reader, limits);
|
const oidHeader = readStreamHeader(reader, limits);
|
||||||
const objectIds = readCompactIds(reader, oidHeader.count);
|
const objectIds = readCompactIds(reader, oidHeader.count);
|
||||||
let objectSpaceIds: CompactId[] = [];
|
let objectSpaceIds: CompactId[] = [];
|
||||||
|
|||||||
@@ -92,12 +92,24 @@ export interface StoreFileData {
|
|||||||
blob?: FileDataBlobReference;
|
blob?: FileDataBlobReference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A resolved object-space reference plus the store-specific lookup key. */
|
||||||
|
export interface StoreObjectSpaceReference {
|
||||||
|
id: ExGuid;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreObject {
|
export interface StoreObject {
|
||||||
id: ExGuid;
|
id: ExGuid;
|
||||||
jcid: number;
|
jcid: number;
|
||||||
props: ObjectPropSet;
|
props: ObjectPropSet;
|
||||||
mapping: ReadonlyMap<number, string>;
|
mapping: ReadonlyMap<number, string>;
|
||||||
contextId: ExGuid;
|
contextId: ExGuid;
|
||||||
|
/** Pre-resolved FSSHTTPB object stream, indexed like `props.objectIds`. */
|
||||||
|
resolvedObjectIds?: readonly ExGuid[];
|
||||||
|
/** Pre-resolved FSSHTTPB object-space stream, indexed like `props.objectSpaceIds`. */
|
||||||
|
resolvedObjectSpaceIds?: readonly StoreObjectSpaceReference[];
|
||||||
|
/** Pre-resolved FSSHTTPB context stream, indexed like `props.contextIds`. */
|
||||||
|
resolvedContextIds?: readonly ExGuid[];
|
||||||
fileData?: StoreFileData;
|
fileData?: StoreFileData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import type { OneNoteSectionContentModel } from '../one/content-model.js';
|
|||||||
import { buildSectionDto } from '../one/section.js';
|
import { buildSectionDto } from '../one/section.js';
|
||||||
import { buildSectionContentModel } from '../one/section-content.js';
|
import { buildSectionContentModel } from '../one/section-content.js';
|
||||||
import { parseDesktopOneStore } from '../onestore/desktop-store.js';
|
import { parseDesktopOneStore } from '../onestore/desktop-store.js';
|
||||||
import { toBytes } from './detect-format.js';
|
import type { DesktopOneStore } from '../onestore/desktop-store.js';
|
||||||
|
import { parseFssHttpOneStore } from '../onestore/fsshttp-store.js';
|
||||||
|
import { detectOneNoteFormat, toBytes } from './detect-format.js';
|
||||||
|
import { OneNoteParserError } from './error.js';
|
||||||
import type { OneNoteParserLimits } from './limits.js';
|
import type { OneNoteParserLimits } from './limits.js';
|
||||||
import { resolveParserLimits } from './limits.js';
|
import { resolveParserLimits } from './limits.js';
|
||||||
|
|
||||||
@@ -16,7 +19,7 @@ export interface ParseOneNoteSectionOptions {
|
|||||||
limits?: Partial<OneNoteParserLimits>;
|
limits?: Partial<OneNoteParserLimits>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a desktop revision-store `.one` section without network access. */
|
/** Parse a supported desktop or FSSHTTPB `.one` section without network access. */
|
||||||
export function parseOneNoteSection(
|
export function parseOneNoteSection(
|
||||||
input: ArrayBuffer | Uint8Array,
|
input: ArrayBuffer | Uint8Array,
|
||||||
options: ParseOneNoteSectionOptions = {}
|
options: ParseOneNoteSectionOptions = {}
|
||||||
@@ -24,7 +27,7 @@ export function parseOneNoteSection(
|
|||||||
const bytes = toBytes(input);
|
const bytes = toBytes(input);
|
||||||
const limits = resolveParserLimits(options.limits);
|
const limits = resolveParserLimits(options.limits);
|
||||||
const diagnostics: OneNoteSectionDto['diagnostics'] = [];
|
const diagnostics: OneNoteSectionDto['diagnostics'] = [];
|
||||||
const store = parseDesktopOneStore(bytes, limits, diagnostics);
|
const store = parseSectionStore(bytes, limits, diagnostics);
|
||||||
return buildSectionDto(
|
return buildSectionDto(
|
||||||
store,
|
store,
|
||||||
options.sourceName ?? 'Untitled.one',
|
options.sourceName ?? 'Untitled.one',
|
||||||
@@ -41,7 +44,7 @@ export function parseOneNoteSectionContent(
|
|||||||
const bytes = toBytes(input);
|
const bytes = toBytes(input);
|
||||||
const limits = resolveParserLimits(options.limits);
|
const limits = resolveParserLimits(options.limits);
|
||||||
const diagnostics: OneNoteSectionContentModel['diagnostics'] = [];
|
const diagnostics: OneNoteSectionContentModel['diagnostics'] = [];
|
||||||
const store = parseDesktopOneStore(bytes, limits, diagnostics);
|
const store = parseSectionStore(bytes, limits, diagnostics);
|
||||||
return buildSectionContentModel(
|
return buildSectionContentModel(
|
||||||
store,
|
store,
|
||||||
options.sourceName ?? 'Untitled.one',
|
options.sourceName ?? 'Untitled.one',
|
||||||
@@ -49,3 +52,22 @@ export function parseOneNoteSectionContent(
|
|||||||
diagnostics
|
diagnostics
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSectionStore(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
limits: OneNoteParserLimits,
|
||||||
|
diagnostics: OneNoteSectionDto['diagnostics']
|
||||||
|
): DesktopOneStore {
|
||||||
|
if (detectOneNoteFormat(bytes).kind !== 'fsshttp-one') {
|
||||||
|
return parseDesktopOneStore(bytes, limits, diagnostics);
|
||||||
|
}
|
||||||
|
const store = parseFssHttpOneStore(bytes, limits, diagnostics);
|
||||||
|
if (store.kind !== 'section') {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'INVALID_FORMAT',
|
||||||
|
'Expected an FSSHTTPB OneNote section, found a table of contents',
|
||||||
|
{ structure: 'OneStore Packaging Structure' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,6 +187,16 @@ function objectReferences(object: StoreObject, propertyId: number): ExGuid[] {
|
|||||||
propertyId,
|
propertyId,
|
||||||
countObjectReferences
|
countObjectReferences
|
||||||
);
|
);
|
||||||
|
if (object.resolvedObjectIds) {
|
||||||
|
if (offset + count > object.resolvedObjectIds.length) {
|
||||||
|
throw new OneNoteParserError(
|
||||||
|
'MISSING_REFERENCE',
|
||||||
|
'TOC object-reference range exceeds its resolved OID stream',
|
||||||
|
{ structure: 'TOCEntryIndex_OidIndex' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return object.resolvedObjectIds.slice(offset, offset + count);
|
||||||
|
}
|
||||||
if (offset + count > object.props.objectIds.length) {
|
if (offset + count > object.props.objectIds.length) {
|
||||||
throw new OneNoteParserError(
|
throw new OneNoteParserError(
|
||||||
'MISSING_REFERENCE',
|
'MISSING_REFERENCE',
|
||||||
|
|||||||
Reference in New Issue
Block a user