feat: open FSSHTTP OneNote sections

This commit is contained in:
2026-07-22 19:26:08 +02:00
parent a7b1cd7d16
commit eb15fecb3f
12 changed files with 877 additions and 21 deletions

View File

@@ -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 = {

View File

@@ -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';

View File

@@ -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}`);
} }

View File

@@ -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'
); );

View File

@@ -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;
}

View File

@@ -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,

View 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');
});
});

View 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,
});
}

View File

@@ -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[] = [];

View File

@@ -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;
} }

View File

@@ -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;
}

View File

@@ -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',