Revised checkbox-tree functions and related
This commit is contained in:
+206
@@ -0,0 +1,206 @@
|
||||
// Manifest parsing and binary codecs. This module has no DOM dependencies.
|
||||
export const SERIALIZATION_VERSION = 1;
|
||||
export const PATH_DELIMITER = '>';
|
||||
const STATE_ENCODINGS = new Set(['adaptive', 'dense', 'tiered']);
|
||||
|
||||
export function loadManifest(source) {
|
||||
let records;
|
||||
let schema = 1;
|
||||
|
||||
if (typeof source === 'string') {
|
||||
records = source.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#'))
|
||||
.map(line => line === '!deleted' ? null : line);
|
||||
} else if (source && typeof source === 'object' && Array.isArray(source.nodes)) {
|
||||
schema = source.schema;
|
||||
records = source.nodes;
|
||||
} else if (source && Array.isArray(source.slots)) {
|
||||
return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path));
|
||||
} else {
|
||||
throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.');
|
||||
}
|
||||
|
||||
return normalizeManifest(schema, records);
|
||||
}
|
||||
|
||||
function normalizeManifest(schema, records) {
|
||||
if (schema !== 1) {
|
||||
throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`);
|
||||
}
|
||||
|
||||
const pathToSlot = new Map();
|
||||
const slots = records.map((record, index) => {
|
||||
if (record === null || record === '!deleted') {
|
||||
return { path: null, deleted: true };
|
||||
}
|
||||
if (typeof record !== 'string' || !record || record.trim() !== record) {
|
||||
throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`);
|
||||
}
|
||||
if (pathToSlot.has(record)) {
|
||||
throw new Error(`Duplicate CheckboxTree manifest path: ${record}`);
|
||||
}
|
||||
pathToSlot.set(record, index);
|
||||
return { path: record, deleted: false };
|
||||
});
|
||||
|
||||
return { schema: 1, slots, pathToSlot };
|
||||
}
|
||||
|
||||
export function setBit(bytes, bitIndex, value) {
|
||||
const byteIndex = Math.floor(bitIndex / 8);
|
||||
const mask = 1 << (bitIndex % 8);
|
||||
if (value) bytes[byteIndex] |= mask;
|
||||
else bytes[byteIndex] &= ~mask;
|
||||
}
|
||||
|
||||
export function getBit(bytes, bitIndex) {
|
||||
const byteIndex = Math.floor(bitIndex / 8);
|
||||
return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0;
|
||||
}
|
||||
|
||||
export function bytesToBase64Url(bytes) {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function base64UrlToBytes(value, maximumLength = Infinity) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
|
||||
throw new Error('Invalid Base64URL value.');
|
||||
}
|
||||
if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) {
|
||||
throw new Error('Encoded tree state exceeds the manifest size.');
|
||||
}
|
||||
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
|
||||
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
function trimTrailingZeroBytes(bytes) {
|
||||
let end = bytes.length;
|
||||
while (end && bytes[end - 1] === 0) end--;
|
||||
return bytes.slice(0, end);
|
||||
}
|
||||
|
||||
function encodeUnsignedVarint(value) {
|
||||
const bytes = [];
|
||||
do {
|
||||
let byte = value & 0x7f;
|
||||
value = Math.floor(value / 128);
|
||||
if (value) byte |= 0x80;
|
||||
bytes.push(byte);
|
||||
} while (value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeUnsignedVarint(bytes, offset) {
|
||||
let value = 0;
|
||||
let multiplier = 1;
|
||||
for (let index = offset; index < bytes.length && index < offset + 8; index++) {
|
||||
const byte = bytes[index];
|
||||
value += (byte & 0x7f) * multiplier;
|
||||
if ((byte & 0x80) === 0) return { value, offset: index + 1 };
|
||||
multiplier *= 128;
|
||||
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break;
|
||||
}
|
||||
throw new Error('Invalid adaptive tree-state integer.');
|
||||
}
|
||||
|
||||
function encodeSparseIndexes(indexes) {
|
||||
const bytes = [];
|
||||
let previous = -1;
|
||||
for (const index of indexes) {
|
||||
bytes.push(...encodeUnsignedVarint(index - previous - 1));
|
||||
previous = index;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodeSparseIndexes(bytes, offset, limit) {
|
||||
const indexes = new Set();
|
||||
let previous = -1;
|
||||
while (offset < bytes.length) {
|
||||
const decoded = decodeUnsignedVarint(bytes, offset);
|
||||
const index = previous + decoded.value + 1;
|
||||
if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.');
|
||||
indexes.add(index);
|
||||
previous = index;
|
||||
offset = decoded.offset;
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
export function encodeAdaptiveBitset(values, slotCount) {
|
||||
const enabled = [];
|
||||
const disabled = [];
|
||||
const denseBytes = new Uint8Array(Math.ceil(slotCount / 8));
|
||||
for (const [slot, value] of values) {
|
||||
(value ? enabled : disabled).push(slot);
|
||||
setBit(denseBytes, slot, value);
|
||||
}
|
||||
enabled.sort((left, right) => left - right);
|
||||
disabled.sort((left, right) => left - right);
|
||||
if (enabled.length === 0) return new Uint8Array();
|
||||
|
||||
const candidates = [
|
||||
Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]),
|
||||
Uint8Array.from([1, ...encodeSparseIndexes(enabled)]),
|
||||
Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)])
|
||||
];
|
||||
return candidates.reduce((shortest, candidate) =>
|
||||
candidate.length < shortest.length ? candidate : shortest
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeAdaptiveBitset(bytes, slotCount) {
|
||||
if (bytes.length === 0) return () => false;
|
||||
const mode = bytes[0];
|
||||
if (mode === 0) {
|
||||
const dense = bytes.slice(1);
|
||||
if (dense.length > Math.ceil(slotCount / 8)) {
|
||||
throw new Error('Dense tree state exceeds the manifest size.');
|
||||
}
|
||||
return slot => getBit(dense, slot);
|
||||
}
|
||||
if (mode === 1) {
|
||||
const enabled = decodeSparseIndexes(bytes, 1, slotCount);
|
||||
return slot => enabled.has(slot);
|
||||
}
|
||||
if (mode === 2) {
|
||||
const coverage = decodeUnsignedVarint(bytes, 1);
|
||||
if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.');
|
||||
const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value);
|
||||
return slot => slot < coverage.value && !disabled.has(slot);
|
||||
}
|
||||
throw new Error('Unknown adaptive tree-state mode.');
|
||||
}
|
||||
|
||||
|
||||
export function encodeDenseBitset(values, slotCount) {
|
||||
const bytes = new Uint8Array(Math.ceil(slotCount / 8));
|
||||
for (const [slot, value] of values) setBit(bytes, slot, value);
|
||||
return trimTrailingZeroBytes(bytes);
|
||||
}
|
||||
|
||||
export function decodeDenseBitset(bytes, slotCount) {
|
||||
if (bytes.length > Math.ceil(slotCount / 8)) {
|
||||
throw new Error('Dense tree state exceeds the manifest size.');
|
||||
}
|
||||
return slot => getBit(bytes, slot);
|
||||
}
|
||||
|
||||
export function maximumEncodedLength(slotCount) {
|
||||
return Math.max(Math.ceil(slotCount / 8) + 1, slotCount * 2 + 10);
|
||||
}
|
||||
|
||||
export function assertStateEncoding(encoding) {
|
||||
if (!STATE_ENCODINGS.has(encoding)) {
|
||||
throw new Error(`Unsupported CheckboxTree state encoding: ${encoding}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function asManifestSource(manifest) {
|
||||
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
|
||||
}
|
||||
Reference in New Issue
Block a user