Revised checkbox-tree functions and related

This commit is contained in:
2026-07-12 21:45:55 -05:00
parent af325782a8
commit 9e7fbe0865
15 changed files with 1063 additions and 721 deletions
+2 -1
View File
@@ -10,7 +10,8 @@ Known and addressed issues for newly released versions are easily added using th
- Copy the issue table's HTML from the webpage using devtools. - Copy the issue table's HTML from the webpage using devtools.
- Fill out the process.html page's fields and paste in the table HTML. - Fill out the process.html page's fields and paste in the table HTML.
- Download the Markdown file and put it into the correct folder. - Download the Markdown file and put it into the correct folder.
- Fix up the products.json file manually or run `update_products_from_issues.py`. - Run `npm run update:generated` to update `products.json`, append new stable
product-tree manifest slots, and regenerate rendered test fixtures.
- Submit a pull request. - Submit a pull request.
There is intentionally no automated scaping of Palo Alto's website, to avoid abuse of server resources. Also releases are not that frequent. A crawler to grab some data from the Common Crawl dataset was started but never really finished. There is intentionally no automated scaping of Palo Alto's website, to avoid abuse of server resources. Also releases are not that frequent. A crawler to grab some data from the Common Crawl dataset was started but never really finished.
+3
View File
@@ -4,6 +4,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "node --test", "test": "node --test",
"update:products": "node scripts/update-products-from-issues.mjs",
"update:fixtures": "node scripts/generate-rendered-fixtures.mjs",
"update:generated": "node scripts/refresh-generated-data.mjs",
"vendor:marked": "mkdir -p web/vendor && cp node_modules/marked/lib/marked.esm.js web/vendor/marked.esm.js" "vendor:marked": "mkdir -p web/vendor && cp node_modules/marked/lib/marked.esm.js web/vendor/marked.esm.js"
}, },
"devDependencies": { "devDependencies": {
+32 -25
View File
@@ -1,11 +1,11 @@
/** /**
* Generates expected-parsed.json for each fixture directory that contains expected.md. * Generates expected-parsed.json for each fixture directory that contains expected.md.
* Run manually: node test/generate-rendered-fixtures.mjs * Run manually: node scripts/generate-rendered-fixtures.mjs
*/ */
import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
const { window } = new JSDOM('<!doctype html><html><body></body></html>'); const { window } = new JSDOM('<!doctype html><html><body></body></html>');
@@ -20,31 +20,38 @@ globalThis.fetch = () => Promise.resolve({
const { parseMarkdownIssues, markdownSummaryToHtml } = await import('../web/js/issues.js'); const { parseMarkdownIssues, markdownSummaryToHtml } = await import('../web/js/issues.js');
const __dirname = fileURLToPath(new URL('.', import.meta.url)); const __dirname = fileURLToPath(new URL('.', import.meta.url));
const fixturesDir = join(__dirname, '../test/fixtures'); const defaultFixturesDir = join(__dirname, '../test/fixtures');
const fixtures = readdirSync(fixturesDir).filter(name => { export function generateRenderedFixtures(fixturesDir = defaultFixturesDir) {
const contents = readdirSync(join(fixturesDir, name)).map(d => d); const fixtures = readdirSync(fixturesDir).filter(name => {
return contents.includes('expected.md'); const contents = readdirSync(join(fixturesDir, name)).map(d => d);
}); return contents.includes('expected.md');
});
for (const fixtureId of fixtures) { for (const fixtureId of fixtures) {
const fixturePath = join(fixturesDir, fixtureId); const fixturePath = join(fixturesDir, fixtureId);
const markdownText = readFileSync(join(fixturePath, 'expected.md'), 'utf-8'); const markdownText = readFileSync(join(fixturePath, 'expected.md'), 'utf-8');
const parsed = parseMarkdownIssues(markdownText); const parsed = parseMarkdownIssues(markdownText);
const result = parsed.map(issue => ({ const result = parsed.map(issue => ({
id: issue.id, id: issue.id,
summary: issue.summary, summary: issue.summary,
resolved: issue.resolved, resolved: issue.resolved,
caveat: issue.caveat, caveat: issue.caveat,
renderedHtml: markdownSummaryToHtml(issue.summary, issue.resolved, issue.caveat) renderedHtml: markdownSummaryToHtml(issue.summary, issue.resolved, issue.caveat)
})); }));
writeFileSync( writeFileSync(
join(fixturePath, 'expected-parsed.json'), join(fixturePath, 'expected-parsed.json'),
JSON.stringify(result, null, 2) + '\n', JSON.stringify(result, null, 2) + '\n',
'utf-8' 'utf-8'
); );
console.log(`Written ${fixtureId}/expected-parsed.json (${result.length} issues)`); console.log(`Written ${fixtureId}/expected-parsed.json (${result.length} issues)`);
}
return { fixtures: fixtures.length };
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
generateRenderedFixtures();
} }
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env node
import { runUpdateProductsCli } from './update-products-from-issues.mjs';
import { generateRenderedFixtures } from './generate-rendered-fixtures.mjs';
await runUpdateProductsCli();
const fixtureResult = generateRenderedFixtures();
console.log(`Regenerated ${fixtureResult.fixtures} rendered fixture set(s).`);
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env node
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { buildProductTree, enumerateTreePaths } from '../web/js/product-tree-model.js';
const ISSUE_TYPES = new Set(['addressed', 'known']);
const VERSION_FILENAME_RE = /^(?<version>[^.]+(?:\.[^.]+)*)\.md$/;
const HOTFIX_RE = /^(?<base>\d+(?:\.\d+)*)(?:-h(?<hotfix>\d+))?$/;
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
async function walkFiles(root) {
const files = [];
async function visit(directory) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) await visit(path);
else if (entry.isFile()) files.push(path);
}
}
await visit(root);
return files.sort();
}
export async function collectIssueFiles(issuesDir) {
const records = [];
for (const filePath of await walkFiles(issuesDir)) {
const parts = relative(issuesDir, filePath).split(/[\\/]/);
if (parts.length !== 3) continue;
const [product, issueType, filename] = parts;
if (!ISSUE_TYPES.has(issueType)) continue;
const match = VERSION_FILENAME_RE.exec(filename);
if (!match) continue;
records.push({ product, issueType, version: match.groups.version, filename });
}
return records;
}
function clearLeafIssueArrays(node) {
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
if ('addressed' in node || 'known' in node) {
node.addressed = [];
node.known = [];
return;
}
Object.values(node).forEach(clearLeafIssueArrays);
}
function getPath(data, path) {
let current = data;
for (const key of path) {
if (!current || typeof current !== 'object' || !(key in current)) return null;
current = current[key];
}
return current;
}
function isIssueLeaf(node) {
return Boolean(node) && typeof node === 'object' && ('addressed' in node || 'known' in node);
}
function pickTargetLeafPath(products, product, version) {
const parts = version.split('.');
const major = parts[0];
const minor = parts.length > 1 ? `${parts[0]}.${parts[1]}` : major;
for (const path of [[product, major, minor], [product, major], [product]]) {
if (isIssueLeaf(getPath(products, path))) return path;
}
return null;
}
function parseReleasePrefix(prefix) {
const match = HOTFIX_RE.exec(prefix);
if (!match) return { base: [], hotfix: -1 };
return {
base: match.groups.base.split('.').map(Number),
hotfix: match.groups.hotfix === undefined ? -1 : Number(match.groups.hotfix)
};
}
function compareNumberArrays(left, right) {
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index++) {
const difference = (left[index] ?? -1) - (right[index] ?? -1);
if (difference) return difference;
}
return 0;
}
function compareIssueFilenames(left, right) {
const leftPrefix = left.replace(/\.[^.]+$/, '');
const rightPrefix = right.replace(/\.[^.]+$/, '');
const leftVersion = parseReleasePrefix(leftPrefix);
const rightVersion = parseReleasePrefix(rightPrefix);
return compareNumberArrays(leftVersion.base, rightVersion.base) ||
Number(leftVersion.hotfix >= 0) - Number(rightVersion.hotfix >= 0) ||
leftVersion.hotfix - rightVersion.hotfix ||
left.localeCompare(right);
}
export function rebuildProducts(products, records) {
clearLeafIssueArrays(products);
const unmatched = [];
records.sort((left, right) =>
left.product.localeCompare(right.product) ||
left.issueType.localeCompare(right.issueType) ||
left.version.localeCompare(right.version)
);
for (const record of records) {
const path = pickTargetLeafPath(products, record.product, record.version);
if (!path) {
unmatched.push(record);
continue;
}
const leaf = getPath(products, path);
const files = Array.isArray(leaf[record.issueType]) ? leaf[record.issueType] : [];
leaf[record.issueType] = Array.from(new Set([...files, record.filename])).sort(compareIssueFilenames);
}
return { products, unmatched };
}
export function updateManifest(manifest, currentPaths) {
if (!manifest || manifest.schema !== 1 || !Array.isArray(manifest.nodes)) {
throw new Error('Product-tree manifest must use schema 1 with a nodes array.');
}
const active = new Set();
manifest.nodes.forEach((path, slot) => {
if (path === null) return;
if (typeof path !== 'string' || !path) throw new Error(`Invalid manifest entry at slot ${slot}.`);
if (active.has(path)) throw new Error(`Duplicate manifest path: ${path}`);
active.add(path);
});
const current = new Set(currentPaths);
const appended = currentPaths.filter(path => !active.has(path));
manifest.nodes.push(...appended);
const missing = Array.from(active).filter(path => !current.has(path));
return { manifest, appended, missing };
}
export async function updateProductsFromIssues({ issuesDir, productsPath, manifestPath }) {
const [productsText, manifestText, records] = await Promise.all([
readFile(productsPath, 'utf8'),
readFile(manifestPath, 'utf8'),
collectIssueFiles(issuesDir)
]);
if (records.length === 0) {
throw new Error("No '<version>.md' issue files found; refusing to rewrite products.json");
}
const products = JSON.parse(productsText);
if (!products || typeof products !== 'object' || Array.isArray(products)) {
throw new Error('products.json must contain a top-level object.');
}
const rebuilt = rebuildProducts(products, records);
const paths = enumerateTreePaths(buildProductTree(rebuilt.products));
const manifestUpdate = updateManifest(JSON.parse(manifestText), paths);
await writeFile(productsPath, `${JSON.stringify(rebuilt.products, null, 2)}\n`, 'utf8');
if (manifestUpdate.appended.length > 0) {
await writeFile(manifestPath, `${JSON.stringify(manifestUpdate.manifest, null, 2)}\n`, 'utf8');
}
return {
records: records.length,
unmatched: rebuilt.unmatched,
appended: manifestUpdate.appended,
missing: manifestUpdate.missing
};
}
function parseArgs(argv) {
const options = {
issuesDir: join(REPO_ROOT, 'web/data/issues'),
productsPath: join(REPO_ROOT, 'web/data/products.json'),
manifestPath: join(REPO_ROOT, 'web/data/product-tree-manifest.json')
};
const flags = new Map([
['--issues-dir', 'issuesDir'],
['--products-json', 'productsPath'],
['--manifest-json', 'manifestPath']
]);
for (let index = 0; index < argv.length; index += 2) {
const property = flags.get(argv[index]);
if (!property || argv[index + 1] === undefined) throw new Error(`Invalid argument: ${argv[index] ?? ''}`);
options[property] = resolve(argv[index + 1]);
}
return options;
}
export async function runUpdateProductsCli(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
const result = await updateProductsFromIssues(options);
console.log(`Updated ${options.productsPath} from ${result.records} issue files.`);
console.log(`Appended ${result.appended.length} manifest path(s).`);
if (result.missing.length) console.warn(`${result.missing.length} manifest path(s) are no longer rendered; slots were preserved.`);
if (result.unmatched.length) console.warn(`${result.unmatched.length} issue file(s) did not match a products.json leaf.`);
return result;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
await runUpdateProductsCli();
}
-217
View File
@@ -1,217 +0,0 @@
#!/usr/bin/env python3
"""Update products.json entries from discovered issue data files."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Any
ISSUE_TYPES = {"addressed", "known"}
VERSION_FILENAME_RE = re.compile(r"^(?P<version>[^.]+(?:\.[^.]+)*)\.md$")
HOTFIX_RE = re.compile(r"^(?P<base>\d+(?:\.\d+)*)(?:-h(?P<hotfix>\d+))?$")
def parse_args() -> argparse.Namespace:
"""Parse command line arguments for input and output paths."""
parser = argparse.ArgumentParser(
description="Update products.json from issue files."
)
parser.add_argument(
"--issues-dir",
default="web/data/issues",
help="Directory containing product issue files.",
)
parser.add_argument(
"--products-json",
default="web/data/products.json",
help="Path to products.json.",
)
return parser.parse_args()
def collect_issue_files(issues_dir: Path) -> list[tuple[str, str, str, str]]:
"""Collect all issue files with '<version>.md' naming."""
records: list[tuple[str, str, str, str]] = []
for file_path in sorted(p for p in issues_dir.rglob("*") if p.is_file()):
relative = file_path.relative_to(issues_dir)
if len(relative.parts) != 3:
continue
product, issue_type, filename = relative.parts
if issue_type not in ISSUE_TYPES:
continue
version = parse_issue_filename(filename)
if version is None:
continue
records.append((product, issue_type, version, filename))
return records
def parse_issue_filename(filename: str) -> str | None:
"""Parse '<version>.md' issue filenames and return version."""
match = VERSION_FILENAME_RE.match(filename)
if not match:
return None
return match.group("version")
def clear_leaf_issue_arrays(node: Any) -> None:
"""Reset addressed/known arrays on every issue leaf in products.json."""
if isinstance(node, dict):
if "addressed" in node or "known" in node:
node["addressed"] = []
node["known"] = []
return
for value in node.values():
clear_leaf_issue_arrays(value)
def is_issue_leaf(node: Any) -> bool:
"""Return whether a node is a leaf containing issue arrays."""
return isinstance(node, dict) and ("addressed" in node or "known" in node)
def pick_target_leaf_path(products: dict[str, Any], product: str, version: str) -> list[str] | None:
"""Choose the best matching product tree path for a version."""
parts = version.split(".")
if not parts:
return None
major = parts[0]
minor = f"{parts[0]}.{parts[1]}" if len(parts) > 1 else parts[0]
candidates = [
[product, major, minor],
[product, major],
[product],
]
for path in candidates:
node = get_path(products, path)
if is_issue_leaf(node):
return path
return None
def get_path(data: Any, path: list[str]) -> Any:
"""Traverse nested dict keys and return the value at path, if present."""
current = data
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
return current
def parse_release_prefix(prefix: str) -> tuple[tuple[int, ...], int]:
"""Extract numeric base version parts and optional hotfix number."""
match = HOTFIX_RE.match(prefix)
if not match:
return tuple(), -1
base_parts = tuple(int(part) for part in match.group("base").split("."))
hotfix_group = match.group("hotfix")
hotfix = int(hotfix_group) if hotfix_group is not None else -1
return base_parts, hotfix
def filename_sort_key(filename: str) -> tuple[tuple[int, ...], int, int, str]:
"""Build a sort key that orders base versions before hotfixes."""
prefix = filename.rsplit(".", 1)[0]
base_parts, hotfix = parse_release_prefix(prefix)
is_hotfix = 1 if hotfix >= 0 else 0
return base_parts, is_hotfix, hotfix, filename
def add_file_to_leaf(
products: dict[str, Any],
path: list[str],
issue_type: str,
filename: str,
) -> None:
"""Insert an issue filename into the target leaf with stable sorting."""
leaf = get_path(products, path)
if not isinstance(leaf, dict):
return
files = leaf.get(issue_type)
if not isinstance(files, list):
files = []
files.append(filename)
leaf[issue_type] = sorted(set(files), key=filename_sort_key)
def update_products(issues_dir: Path, products_path: Path) -> None:
"""Rebuild product issue file lists from crawled issue files."""
if not issues_dir.is_dir():
raise FileNotFoundError(f"Issues directory not found: {issues_dir}")
if not products_path.is_file():
raise FileNotFoundError(f"products.json not found: {products_path}")
with products_path.open("r", encoding="utf-8") as f:
products = json.load(f)
if not isinstance(products, dict):
raise ValueError("products.json must contain a top-level object")
records = collect_issue_files(issues_dir)
if not records:
raise ValueError(
"No '<version>.md' issue files found; refusing to rewrite products.json"
)
clear_leaf_issue_arrays(products)
for product, issue_type, version, filename in sorted(
records,
key=lambda r: (r[0], r[1], r[2]),
):
path = pick_target_leaf_path(products, product, version)
if path is None:
continue
add_file_to_leaf(products, path, issue_type, filename)
with products_path.open("w", encoding="utf-8") as f:
json.dump(products, f, indent=2)
f.write("\n")
def main() -> int:
"""Entrypoint for command-line usage."""
args = parse_args()
script_dir = Path(__file__).resolve().parent
repo_root = script_dir.parent
issues_dir = (repo_root / args.issues_dir).resolve()
products_path = (repo_root / args.products_json).resolve()
update_products(issues_dir=issues_dir, products_path=products_path)
print(f"Updated {products_path} from {issues_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,30 @@
[
{
"id": "PAN-242777",
"summary": "Fixed an issue where users previously reported limitations due to session count caps when utilizing **Web Proxy** features on PA-5400 Series Firewalls. To address these performance complaints and support higher traffic volumes, we have increased the maximum session capacity on specific **PA-5400F** series platforms, leveraging available system memory. This update ensures greater capacity and stability for high-volume environments.The supported session limits are:| Platform | Max Sessions |\n| -------- | ------------ |\n| PA-5410 | 95K |\n| PA-5420 | 95K |\n| PA-5430 | 95K |\n| PA-5440 | 225K |\n| PA-5445 | 250K |\n| PA-5450 | 1.28M |",
"resolved": "",
"caveat": "",
"renderedHtml": "<p>Fixed an issue where users previously reported limitations due to session count caps when utilizing <strong>Web Proxy</strong> features on PA-5400 Series Firewalls. To address these performance complaints and support higher traffic volumes, we have increased the maximum session capacity on specific <strong>PA-5400F</strong> series platforms, leveraging available system memory. This update ensures greater capacity and stability for high-volume environments.The supported session limits are:| Platform | Max Sessions |\n| -------- | ------------ |\n| PA-5410 | 95K |\n| PA-5420 | 95K |\n| PA-5430 | 95K |\n| PA-5440 | 225K |\n| PA-5445 | 250K |\n| PA-5450 | 1.28M |</p>\n"
},
{
"id": "PAN-291499",
"summary": "Fixed an issue where newly deployed firewalls were unable to connect to the Strata Logging Service (SLS) until after a reboot, license fetch, or management server restart.",
"resolved": "",
"caveat": "VM-Series firewalls on Amazon Web Services (AWS) environments only",
"renderedHtml": "<div><em>Caveat: VM-Series firewalls on Amazon Web Services (AWS) environments only</em></div><p>Fixed an issue where newly deployed firewalls were unable to connect to the Strata Logging Service (SLS) until after a reboot, license fetch, or management server restart.</p>\n"
},
{
"id": "PAN-288726",
"summary": "Fixed an issue where the useridd process stopped responding due to a Security policy rule ID being set to 0, which caused the last configuration retrieval to fail.",
"resolved": "",
"caveat": "",
"renderedHtml": "<p>Fixed an issue where the useridd process stopped responding due to a Security policy rule ID being set to 0, which caused the last configuration retrieval to fail.</p>\n"
},
{
"id": "PAN-287133",
"summary": "Fixed an issue on the Panorama web interface where assigning a policy rule to a group at the top or bottom of the list changed the order of other policy rules.",
"resolved": "",
"caveat": "",
"renderedHtml": "<p>Fixed an issue on the Panorama web interface where assigning a policy rule to a group at the top or bottom of the list changed the order of other policy rules.</p>\n"
}
]
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import test from 'node:test';
import { buildProductTree, enumerateTreePaths } from '../web/js/product-tree-model.js';
import { updateProductsFromIssues } from '../scripts/update-products-from-issues.mjs';
test('product updater rebuilds products and append-only manifest idempotently', async t => {
const root = await mkdtemp(join(tmpdir(), 'firewallissues-update-'));
t.after(() => rm(root, { recursive: true, force: true }));
const issuesDir = join(root, 'issues');
const productsPath = join(root, 'products.json');
const manifestPath = join(root, 'manifest.json');
await mkdir(join(issuesDir, 'Product', 'addressed'), { recursive: true });
await mkdir(join(issuesDir, 'Product', 'known'), { recursive: true });
await writeFile(join(issuesDir, 'Product', 'addressed', '1.2.3-h2.md'), 'addressed');
await writeFile(join(issuesDir, 'Product', 'addressed', '1.2.3.md'), 'addressed');
await writeFile(join(issuesDir, 'Product', 'known', '1.2.4.md'), 'known');
const initialProducts = {
Product: {
1: {
'1.2': {
addressed: ['obsolete.md'],
known: []
}
}
}
};
const rootPath = JSON.stringify(['Product']);
await writeFile(productsPath, `${JSON.stringify(initialProducts, null, 2)}\n`);
await writeFile(manifestPath, `${JSON.stringify({ schema: 1, nodes: [rootPath, 'legacy-path', null] }, null, 2)}\n`);
const first = await updateProductsFromIssues({ issuesDir, productsPath, manifestPath });
assert.equal(first.records, 3);
assert.equal(first.unmatched.length, 0);
assert.ok(first.appended.length > 0);
assert.deepEqual(first.missing, ['legacy-path']);
const products = JSON.parse(await readFile(productsPath, 'utf8'));
assert.deepEqual(products.Product[1]['1.2'].addressed, ['1.2.3.md', '1.2.3-h2.md']);
assert.deepEqual(products.Product[1]['1.2'].known, ['1.2.4.md']);
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
assert.deepEqual(manifest.nodes.slice(0, 3), [rootPath, 'legacy-path', null]);
const renderedPaths = enumerateTreePaths(buildProductTree(products));
assert.ok(renderedPaths.every(path => manifest.nodes.includes(path)));
const second = await updateProductsFromIssues({ issuesDir, productsPath, manifestPath });
assert.deepEqual(second.appended, []);
assert.deepEqual(JSON.parse(await readFile(manifestPath, 'utf8')), manifest);
});
test('product updater refuses to rewrite when no issue files are found', async t => {
const root = await mkdtemp(join(tmpdir(), 'firewallissues-empty-update-'));
t.after(() => rm(root, { recursive: true, force: true }));
const issuesDir = join(root, 'issues');
const productsPath = join(root, 'products.json');
const manifestPath = join(root, 'manifest.json');
await mkdir(issuesDir);
await writeFile(productsPath, '{}\n');
await writeFile(manifestPath, '{"schema":1,"nodes":[]}\n');
await assert.rejects(
updateProductsFromIssues({ issuesDir, productsPath, manifestPath }),
/No '<version>\.md' issue files found/
);
assert.equal(await readFile(productsPath, 'utf8'), '{}\n');
});
+144
View File
@@ -0,0 +1,144 @@
import { getSortedKeys } from './sort.js';
export function expandFilePrefixLevel(root) {
if (!root || typeof root !== 'object') {
return root;
}
return transformNode(root);
}
function transformNode(node) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return node;
}
const hasIssueArrays = Object.prototype.hasOwnProperty.call(node, 'addressed') ||
Object.prototype.hasOwnProperty.call(node, 'known');
const childKeys = Object.keys(node).filter(key => key !== 'addressed' && key !== 'known');
const transformedChildren = Object.fromEntries(
childKeys.map(key => [key, transformNode(node[key])])
);
if (!hasIssueArrays) {
return transformedChildren;
}
const addressed = Array.isArray(node.addressed) ? node.addressed : [];
const known = Array.isArray(node.known) ? node.known : [];
if (addressed.length + known.length === 0) {
return childKeys.length > 0 ? transformedChildren : { addressed: [], known: [] };
}
const groupedByPrefix = {};
for (const [issueType, files] of [['addressed', addressed], ['known', known]]) {
for (const fileName of files) {
const prefix = getFilePrefix(fileName);
groupedByPrefix[prefix] ??= { addressed: [], known: [] };
groupedByPrefix[prefix][issueType].push(fileName);
}
}
const transformedIssueFiles = groupHotfixPrefixes(groupedByPrefix);
return childKeys.length === 0
? transformedIssueFiles
: mergeTreeNodes(transformedChildren, transformedIssueFiles);
}
function groupHotfixPrefixes(groupedByPrefix) {
const byFamily = {};
for (const prefix of Object.keys(groupedByPrefix)) {
const family = getReleaseFamily(prefix);
byFamily[family] ??= [];
byFamily[family].push(prefix);
}
const result = {};
for (const [family, members] of Object.entries(byFamily)) {
const hasHotfixes = members.length > 1 || members.some(member => member !== family);
if (!hasHotfixes && members[0] === family) {
result[family] = groupedByPrefix[family];
} else {
result[family] = Object.fromEntries(
members.map(member => [member, groupedByPrefix[member]])
);
}
}
return result;
}
function getReleaseFamily(prefix) {
return String(prefix || '').replace(/-h\d+$/i, '');
}
function getFilePrefix(fileName) {
const baseName = String(fileName || '');
const underscoreIndex = baseName.indexOf('_');
if (underscoreIndex <= 0) {
return baseName.replace(/\.json$/i, '') || 'unknown';
}
return baseName.slice(0, underscoreIndex);
}
function mergeTreeNodes(left, right) {
if (!isObjectNode(left)) return right;
if (!isObjectNode(right)) return left;
const merged = { ...left };
for (const [key, rightValue] of Object.entries(right)) {
const leftValue = merged[key];
if (key === 'addressed' || key === 'known') {
const leftFiles = Array.isArray(leftValue) ? leftValue : [];
const rightFiles = Array.isArray(rightValue) ? rightValue : [];
merged[key] = Array.from(new Set([...leftFiles, ...rightFiles]));
} else if (isObjectNode(leftValue) && isObjectNode(rightValue)) {
merged[key] = mergeTreeNodes(leftValue, rightValue);
} else {
merged[key] = rightValue;
}
}
return merged;
}
function isObjectNode(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function toTreeNodes(value, path = []) {
return getSortedKeys(value)
.filter(key => key !== 'addressed' && key !== 'known')
.map(key => {
const childValue = value[key];
const childPath = [...path, key];
const isObject = childValue && typeof childValue === 'object';
const hasFiles = isObject && ('addressed' in childValue || 'known' in childValue);
const children = isObject ? toTreeNodes(childValue, childPath) : [];
return {
id: JSON.stringify(childPath),
label: key,
selectable: hasFiles || children.length > 0,
metadata: {
path: childPath,
addressed: hasFiles && Array.isArray(childValue.addressed) ? childValue.addressed : [],
known: hasFiles && Array.isArray(childValue.known) ? childValue.known : []
},
...(children.length > 0 ? { children } : {})
};
});
}
export function enumerateTreePaths(nodes) {
const paths = [];
function visit(children, parentPath = '') {
for (const node of children) {
const path = parentPath ? `${parentPath}>${node.id}` : node.id;
paths.push(path);
visit(node.children ?? [], path);
}
}
visit(nodes);
return paths;
}
export function buildProductTree(products) {
return toTreeNodes(expandFilePrefixLevel(products));
}
+1 -148
View File
@@ -1,6 +1,7 @@
import { loadIssuesForCheckedPaths } from './issues.js'; import { loadIssuesForCheckedPaths } from './issues.js';
import { getCheckedFileRefs, initializeTree, renderProductTree, restoreTreeState } from './tree.js'; import { getCheckedFileRefs, initializeTree, renderProductTree, restoreTreeState } from './tree.js';
import { applyIssueSearchFilter, getIssueTypeFilters, initializeUI } from './ui.js'; import { applyIssueSearchFilter, getIssueTypeFilters, initializeUI } from './ui.js';
import { expandFilePrefixLevel } from './product-tree-model.js';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initializeUI({ onSelectionChange: refreshIssuesForCurrentSelection }); initializeUI({ onSelectionChange: refreshIssuesForCurrentSelection });
@@ -29,151 +30,3 @@ function loadProductTree() {
}) })
.catch(error => console.error('Error loading products:', error)); .catch(error => console.error('Error loading products:', error));
} }
function expandFilePrefixLevel(root) {
if (!root || typeof root !== 'object') {
return root;
}
return transformNode(root);
}
function transformNode(node) {
if (!node || typeof node !== 'object' || Array.isArray(node)) {
return node;
}
const hasIssueArrays = Object.prototype.hasOwnProperty.call(node, 'addressed') ||
Object.prototype.hasOwnProperty.call(node, 'known');
const childKeys = Object.keys(node).filter(key => key !== 'addressed' && key !== 'known');
const transformedChildren = {};
childKeys.forEach(key => {
transformedChildren[key] = transformNode(node[key]);
});
if (!hasIssueArrays) {
return transformedChildren;
}
const addressed = Array.isArray(node.addressed) ? node.addressed : [];
const known = Array.isArray(node.known) ? node.known : [];
const allFiles = [...addressed, ...known];
if (allFiles.length === 0) {
if (childKeys.length > 0) {
return transformedChildren;
}
return {
addressed: [],
known: []
};
}
const groupedByPrefix = {};
addressed.forEach(fileName => {
const prefix = getFilePrefix(fileName);
if (!groupedByPrefix[prefix]) {
groupedByPrefix[prefix] = { addressed: [], known: [] };
}
groupedByPrefix[prefix].addressed.push(fileName);
});
known.forEach(fileName => {
const prefix = getFilePrefix(fileName);
if (!groupedByPrefix[prefix]) {
groupedByPrefix[prefix] = { addressed: [], known: [] };
}
groupedByPrefix[prefix].known.push(fileName);
});
const transformedIssueFiles = groupHotfixPrefixes(groupedByPrefix);
if (childKeys.length === 0) {
return transformedIssueFiles;
}
return mergeTreeNodes(transformedChildren, transformedIssueFiles);
}
function groupHotfixPrefixes(groupedByPrefix) {
const byFamily = {};
Object.keys(groupedByPrefix).forEach(prefix => {
const family = getReleaseFamily(prefix);
if (!byFamily[family]) {
byFamily[family] = [];
}
byFamily[family].push(prefix);
});
const result = {};
Object.keys(byFamily).forEach(family => {
const members = byFamily[family];
const hasHotfixes = members.length > 1 || members.some(member => member !== family);
if (!hasHotfixes && members[0] === family) {
result[family] = groupedByPrefix[family];
return;
}
const branch = {};
members.forEach(member => {
branch[member] = groupedByPrefix[member];
});
result[family] = branch;
});
return result;
}
function getReleaseFamily(prefix) {
return String(prefix || '').replace(/-h\d+$/i, '');
}
function getFilePrefix(fileName) {
const baseName = String(fileName || '');
const underscoreIndex = baseName.indexOf('_');
if (underscoreIndex <= 0) {
return baseName.replace(/\.json$/i, '') || 'unknown';
}
return baseName.slice(0, underscoreIndex);
}
function mergeTreeNodes(left, right) {
if (!isObjectNode(left)) {
return right;
}
if (!isObjectNode(right)) {
return left;
}
const merged = { ...left };
Object.keys(right).forEach(key => {
const leftValue = merged[key];
const rightValue = right[key];
if (key === 'addressed' || key === 'known') {
const leftFiles = Array.isArray(leftValue) ? leftValue : [];
const rightFiles = Array.isArray(rightValue) ? rightValue : [];
merged[key] = Array.from(new Set([...leftFiles, ...rightFiles]));
return;
}
if (isObjectNode(leftValue) && isObjectNode(rightValue)) {
merged[key] = mergeTreeNodes(leftValue, rightValue);
return;
}
merged[key] = rightValue;
});
return merged;
}
function isObjectNode(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
+2 -25
View File
@@ -1,5 +1,5 @@
import { CheckboxTree, loadManifest } from '../vendor/checkbox-tree/checkbox-tree.js'; import { CheckboxTree, loadManifest } from '../vendor/checkbox-tree/checkbox-tree.js';
import { getSortedKeys } from './sort.js'; import { toTreeNodes } from './product-tree-model.js';
const STORAGE_KEY = 'tree-state-v2'; const STORAGE_KEY = 'tree-state-v2';
let productTree = null; let productTree = null;
@@ -14,6 +14,7 @@ export function initializeTree(containerId, onSelectionChange) {
productTree = new CheckboxTree(container, { productTree = new CheckboxTree(container, {
storageKey: STORAGE_KEY, storageKey: STORAGE_KEY,
initiallyCollapsed: true, initiallyCollapsed: true,
stateEncoding: 'tiered',
onSelectionChange: event => { onSelectionChange: event => {
onSelectionChange(event); onSelectionChange(event);
pushTreeState(); pushTreeState();
@@ -82,30 +83,6 @@ function pushTreeState() {
} }
} }
function toTreeNodes(value, path = []) {
return getSortedKeys(value)
.filter(key => key !== 'addressed' && key !== 'known')
.map(key => {
const childValue = value[key];
const childPath = [...path, key];
const isObject = childValue && typeof childValue === 'object';
const hasFiles = isObject && ('addressed' in childValue || 'known' in childValue);
const children = isObject ? toTreeNodes(childValue, childPath) : [];
return {
id: JSON.stringify(childPath),
label: key,
selectable: hasFiles || children.length > 0,
metadata: {
path: childPath,
addressed: hasFiles && Array.isArray(childValue.addressed) ? childValue.addressed : [],
known: hasFiles && Array.isArray(childValue.known) ? childValue.known : []
},
...(children.length > 0 ? { children } : {})
};
});
}
function dedupeFileRefs(fileRefs) { function dedupeFileRefs(fileRefs) {
const seen = new Set(); const seen = new Set();
return fileRefs.filter(({ productKey, fileName }) => { return fileRefs.filter(({ productKey, fileName }) => {
+14 -2
View File
@@ -43,8 +43,8 @@ tree.setData([
]); ]);
``` ```
For applications without a bundler, serve or copy the two files in `src/` and For applications without a bundler, serve or copy the `src/` directory and use
use relative URLs: relative URLs. The main module imports its state-codec modules alongside it:
```html ```html
<link rel="stylesheet" href="./checkbox-tree.css"> <link rel="stylesheet" href="./checkbox-tree.css">
@@ -78,6 +78,9 @@ Options:
- `onSelectionChange` receives `{ selectedIds, selectedNodes }`. - `onSelectionChange` receives `{ selectedIds, selectedNodes }`.
- `manifest` optionally supplies a parsed manifest and automatically restores - `manifest` optionally supplies a parsed manifest and automatically restores
state from `window.location.hash` after `setData()`. state from `window.location.hash` after `setData()`.
- `stateEncoding` selects `"adaptive"` (the default), `"dense"`, or `"tiered"`.
The website's configured value is the URL-format contract and is not embedded
in the fragment.
## Shareable URL state ## Shareable URL state
@@ -105,6 +108,15 @@ Use `validateManifestEvolution(previous, next)` in a build check to reject
removed slots and tombstone reuse; path changes are reported for deliberate removed slots and tombstone reuse; path changes are reported for deliberate
rename/move review. rename/move review.
With `stateEncoding: "tiered"`, `c1` stores root checked state and each deeper
`cN` field stores nodes whose checked state differs from their parent. Expansion
does not inherit: every `xN` stores the actual expanded branches at that depth,
with collapsed as the baseline. Each tier field uses the adaptive codec. The
`n` field records manifest coverage so nodes appended later still default to
false. A non-selectable parent supplies a false checked baseline. With
`stateEncoding: "dense"`, `c` and `x` contain trimmed
least-significant-bit-first bitsets.
Styling is namespaced under `.checkbox-tree`. Override the custom properties Styling is namespaced under `.checkbox-tree`. Override the custom properties
`--checkbox-tree-indent`, `--checkbox-tree-toggle-size`, and `--checkbox-tree-indent`, `--checkbox-tree-toggle-size`, and
`--checkbox-tree-hover-color` in the consuming application. `--checkbox-tree-hover-color` in the consuming application.
+40 -303
View File
@@ -1,311 +1,44 @@
// Dependency-free checkbox tree widget. State serialization lives in focused sibling modules.
import {
PATH_DELIMITER,
assertStateEncoding
} from './state-codec.js';
import {
restoreStateFromLocation,
serializeStateToFragment,
validateManifest
} from './tree-state.js';
export {
PATH_DELIMITER,
SERIALIZATION_VERSION,
base64UrlToBytes,
bytesToBase64Url,
decodeAdaptiveBitset,
encodeAdaptiveBitset,
getBit,
loadManifest,
setBit
} from './state-codec.js';
export {
applyTreeState,
decodeTreeState,
encodeTreeState,
restoreStateFromLocation,
serializeStateToFragment,
validateManifest,
validateManifestEvolution
} from './tree-state.js';
const DEFAULT_OPTIONS = { const DEFAULT_OPTIONS = {
initiallyCollapsed: true, initiallyCollapsed: true,
initiallySelected: false, initiallySelected: false,
storageKey: null, storageKey: null,
onSelectionChange: null, onSelectionChange: null,
manifest: null manifest: null,
stateEncoding: 'adaptive'
}; };
const SERIALIZATION_VERSION = 1;
const PATH_DELIMITER = '>';
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.');
}
function asManifest(manifest) {
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
}
export function validateManifest(manifest, tree) {
const normalized = asManifest(manifest);
const treePaths = new Set(tree.nodesByPath.keys());
return {
valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)),
unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)),
missingPaths: normalized.slots
.filter(slot => !slot.deleted && !treePaths.has(slot.path))
.map(slot => slot.path)
};
}
export function validateManifestEvolution(previous, next) {
const oldManifest = asManifest(previous);
const newManifest = asManifest(next);
const errors = [];
if (newManifest.slots.length < oldManifest.slots.length) {
errors.push('Manifest slots cannot be removed; use tombstones.');
}
oldManifest.slots.forEach((oldSlot, index) => {
const newSlot = newManifest.slots[index];
if (!newSlot) return;
if (oldSlot.deleted && !newSlot.deleted) {
errors.push(`Tombstoned slot ${index} cannot be reused.`);
}
if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) {
errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`);
}
});
return { valid: errors.length === 0, errors };
}
export function encodeTreeState(tree, manifest) {
const normalized = asManifest(manifest);
const checkedValues = new Map();
const expandedValues = new Map();
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
checkedValues.set(slot, Boolean(checkbox?.checked));
expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true');
}
const checkedValue = bytesToBase64Url(encodeAdaptiveBitset(checkedValues, normalized.slots.length));
const expandedValue = bytesToBase64Url(encodeAdaptiveBitset(expandedValues, normalized.slots.length));
return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null };
}
export function decodeTreeState(fragment, manifest) {
const normalized = asManifest(manifest);
const value = fragment.startsWith('#') ? fragment.slice(1) : fragment;
const params = new URLSearchParams(value);
if (params.get('v') !== String(SERIALIZATION_VERSION)) {
return { applied: false, version: params.get('v'), warnings: ['Missing or unsupported serialization version.'] };
}
const maximumLength = Math.max(
Math.ceil(normalized.slots.length / 8) + 1,
normalized.slots.length * 2 + 10
);
try {
const checked = base64UrlToBytes(params.get('c') ?? '', maximumLength);
const expanded = base64UrlToBytes(params.get('x') ?? '', maximumLength);
decodeAdaptiveBitset(checked, normalized.slots.length);
decodeAdaptiveBitset(expanded, normalized.slots.length);
return {
applied: true,
version: SERIALIZATION_VERSION,
checked,
expanded,
warnings: []
};
} catch (error) {
return { applied: false, version: SERIALIZATION_VERSION, warnings: [error.message] };
}
}
export function applyTreeState(tree, manifest, state) {
if (!state?.applied) return false;
const normalized = asManifest(manifest);
let isChecked;
let isExpanded;
try {
isChecked = decodeAdaptiveBitset(state.checked, normalized.slots.length);
isExpanded = decodeAdaptiveBitset(state.expanded, normalized.slots.length);
} catch {
return false;
}
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot);
tree.setExpanded(item, isExpanded(slot));
}
tree.updateAllParentCheckboxes();
return true;
}
export function serializeStateToFragment(tree, manifest) {
const state = encodeTreeState(tree, manifest);
const params = new URLSearchParams({ v: String(state.version) });
if (state.checked) params.set('c', state.checked);
if (state.expanded) params.set('x', state.expanded);
return `#${params}`;
}
export function restoreStateFromLocation(tree, manifest, location = window.location) {
const state = decodeTreeState(location.hash ?? '', manifest);
if (state.applied) applyTreeState(tree, manifest, state);
return state;
}
export class CheckboxTree { export class CheckboxTree {
constructor(container, options = {}) { constructor(container, options = {}) {
if (!(container instanceof Element)) { if (!(container instanceof Element)) {
@@ -314,8 +47,10 @@ export class CheckboxTree {
this.container = container; this.container = container;
this.options = { ...DEFAULT_OPTIONS, ...options }; this.options = { ...DEFAULT_OPTIONS, ...options };
assertStateEncoding(this.options.stateEncoding);
this.nodesById = new Map(); this.nodesById = new Map();
this.nodesByPath = new Map(); this.nodesByPath = new Map();
this.itemsById = new Map();
this.rootNodes = []; this.rootNodes = [];
this.handleChange = this.handleChange.bind(this); this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this); this.handleClick = this.handleClick.bind(this);
@@ -332,6 +67,7 @@ export class CheckboxTree {
this.nodesById.clear(); this.nodesById.clear();
this.nodesByPath.clear(); this.nodesByPath.clear();
this.itemsById.clear();
this.rootNodes = nodes; this.rootNodes = nodes;
this.indexNodes(nodes); this.indexNodes(nodes);
this.render(); this.render();
@@ -371,6 +107,7 @@ export class CheckboxTree {
this.container.replaceChildren(); this.container.replaceChildren();
this.nodesById.clear(); this.nodesById.clear();
this.nodesByPath.clear(); this.nodesByPath.clear();
this.itemsById.clear();
} }
indexNodes(nodes, parentPath = '') { indexNodes(nodes, parentPath = '') {
@@ -408,6 +145,7 @@ export class CheckboxTree {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'checkbox-tree__item'; item.className = 'checkbox-tree__item';
item.dataset.nodeId = node.id; item.dataset.nodeId = node.id;
this.itemsById.set(node.id, item);
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'checkbox-tree__row'; row.className = 'checkbox-tree__row';
@@ -524,8 +262,7 @@ export class CheckboxTree {
} }
itemForNode(id) { itemForNode(id) {
return Array.from(this.container.querySelectorAll('.checkbox-tree__item')) return this.itemsById.get(id) ?? null;
.find(item => item.dataset.nodeId === id) ?? null;
} }
updateAncestorCheckboxes(item) { updateAncestorCheckboxes(item) {
+206
View File
@@ -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);
}
+310
View File
@@ -0,0 +1,310 @@
// Adapts pure state codecs to CheckboxTree's rendered node model and URL format.
import {
PATH_DELIMITER,
SERIALIZATION_VERSION,
asManifestSource as asManifest,
assertStateEncoding,
base64UrlToBytes,
bytesToBase64Url,
decodeAdaptiveBitset,
decodeDenseBitset,
encodeAdaptiveBitset,
encodeDenseBitset,
maximumEncodedLength
} from './state-codec.js';
export function validateManifest(manifest, tree) {
const normalized = asManifest(manifest);
const treePaths = new Set(tree.nodesByPath.keys());
return {
valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)),
unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)),
missingPaths: normalized.slots
.filter(slot => !slot.deleted && !treePaths.has(slot.path))
.map(slot => slot.path)
};
}
export function validateManifestEvolution(previous, next) {
const oldManifest = asManifest(previous);
const newManifest = asManifest(next);
const errors = [];
if (newManifest.slots.length < oldManifest.slots.length) {
errors.push('Manifest slots cannot be removed; use tombstones.');
}
oldManifest.slots.forEach((oldSlot, index) => {
const newSlot = newManifest.slots[index];
if (!newSlot) return;
if (oldSlot.deleted && !newSlot.deleted) {
errors.push(`Tombstoned slot ${index} cannot be reused.`);
}
if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) {
errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`);
}
});
return { valid: errors.length === 0, errors };
}
function collectTreeValues(tree, manifest) {
const normalized = asManifest(manifest);
const checkedValues = new Map();
const expandedValues = new Map();
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
checkedValues.set(slot, Boolean(checkbox?.checked));
expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true');
}
return { normalized, checkedValues, expandedValues };
}
function nodeState(tree, node) {
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
return {
checked: Boolean(checkbox?.checked),
hasChecked: Boolean(checkbox),
expanded: toggle?.getAttribute('aria-expanded') === 'true',
hasExpanded: Boolean(toggle)
};
}
function encodeTieredState(tree, normalized) {
const checkedByDepth = new Map();
const expandedByDepth = new Map();
const statesByPath = new Map();
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const depth = path.split(PATH_DELIMITER).length;
const parentPath = path.includes(PATH_DELIMITER)
? path.slice(0, path.lastIndexOf(PATH_DELIMITER))
: null;
const parent = statesByPath.get(parentPath) ?? {
checked: false,
hasChecked: false,
expanded: false,
hasExpanded: false
};
const current = nodeState(tree, node);
if (current.hasChecked) {
checkedByDepth.set(depth, checkedByDepth.get(depth) ?? new Map());
checkedByDepth.get(depth).set(slot, current.checked !== (parent.hasChecked ? parent.checked : false));
}
if (current.hasExpanded) {
expandedByDepth.set(depth, expandedByDepth.get(depth) ?? new Map());
expandedByDepth.get(depth).set(slot, current.expanded);
}
statesByPath.set(path, current);
}
const fields = new Map([['n', String(normalized.slots.length)]]);
for (const [depth, values] of checkedByDepth) {
const encoded = bytesToBase64Url(encodeAdaptiveBitset(values, normalized.slots.length));
if (encoded) fields.set(`c${depth}`, encoded);
}
for (const [depth, values] of expandedByDepth) {
const encoded = bytesToBase64Url(encodeAdaptiveBitset(values, normalized.slots.length));
if (encoded) fields.set(`x${depth}`, encoded);
}
return fields;
}
export function encodeTreeState(tree, manifest, encoding = tree.options?.stateEncoding ?? 'adaptive') {
assertStateEncoding(encoding);
const { normalized, checkedValues, expandedValues } = collectTreeValues(tree, manifest);
if (encoding === 'tiered') {
return { version: SERIALIZATION_VERSION, encoding, fields: encodeTieredState(tree, normalized) };
}
const encode = encoding === 'dense' ? encodeDenseBitset : encodeAdaptiveBitset;
const checkedValue = bytesToBase64Url(encode(checkedValues, normalized.slots.length));
const expandedValue = bytesToBase64Url(encode(expandedValues, normalized.slots.length));
return {
version: SERIALIZATION_VERSION,
encoding,
checked: checkedValue || null,
expanded: expandedValue || null
};
}
function decodeField(params, name, normalized, encoding) {
const maximumLength = encoding === 'dense'
? Math.ceil(normalized.slots.length / 8)
: maximumEncodedLength(normalized.slots.length);
const bytes = base64UrlToBytes(params.get(name) ?? '', maximumLength);
const decode = encoding === 'dense' ? decodeDenseBitset : decodeAdaptiveBitset;
decode(bytes, normalized.slots.length);
return bytes;
}
export function decodeTreeState(fragment, manifest, encoding = 'adaptive') {
assertStateEncoding(encoding);
const normalized = asManifest(manifest);
const value = fragment.startsWith('#') ? fragment.slice(1) : fragment;
const params = new URLSearchParams(value);
if (params.get('v') !== String(SERIALIZATION_VERSION)) {
return { applied: false, version: params.get('v'), encoding, warnings: ['Missing or unsupported serialization version.'] };
}
try {
if (encoding === 'tiered') {
if (params.has('c') || params.has('x')) {
throw new Error('Tiered tree state contains non-tiered fields.');
}
const maximumDepth = normalized.slots.reduce((depth, slot) =>
slot.deleted ? depth : Math.max(depth, slot.path.split(PATH_DELIMITER).length)
, 0);
for (const key of params.keys()) {
const match = /^([cx])(\d+)$/.exec(key);
if (match && (Number(match[2]) < 1 || Number(match[2]) > maximumDepth)) {
throw new Error(`Tree state contains an invalid tier: ${key}`);
}
}
const coverage = Number(params.get('n'));
if (!Number.isSafeInteger(coverage) || coverage < 0 || coverage > normalized.slots.length) {
throw new Error('Tiered tree state has invalid manifest coverage.');
}
const checkedTiers = new Map();
const expandedTiers = new Map();
for (let depth = 1; depth <= maximumDepth; depth++) {
checkedTiers.set(depth, decodeField(params, `c${depth}`, normalized, 'adaptive'));
expandedTiers.set(depth, decodeField(params, `x${depth}`, normalized, 'adaptive'));
}
return {
applied: true,
version: SERIALIZATION_VERSION,
encoding,
coverage,
checkedTiers,
expandedTiers,
warnings: []
};
}
for (const key of params.keys()) {
if (key === 'n' || /^[cx]\d+$/.test(key)) {
throw new Error('Tree state contains tiered fields for a non-tiered encoding.');
}
}
const checked = decodeField(params, 'c', normalized, encoding);
const expanded = decodeField(params, 'x', normalized, encoding);
return {
applied: true,
version: SERIALIZATION_VERSION,
encoding,
checked,
expanded,
warnings: []
};
} catch (error) {
return { applied: false, version: SERIALIZATION_VERSION, encoding, warnings: [error.message] };
}
}
export function applyTreeState(tree, manifest, state) {
if (!state?.applied) return false;
const normalized = asManifest(manifest);
if (state.encoding === 'tiered') return applyTieredTreeState(tree, normalized, state);
let isChecked;
let isExpanded;
try {
const decode = state.encoding === 'dense' ? decodeDenseBitset : decodeAdaptiveBitset;
isChecked = decode(state.checked, normalized.slots.length);
isExpanded = decode(state.expanded, normalized.slots.length);
} catch {
return false;
}
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot);
tree.setExpanded(item, isExpanded(slot));
}
tree.updateAllParentCheckboxes();
return true;
}
function applyTieredTreeState(tree, normalized, state) {
const resolvedByPath = new Map();
const checkedDecoders = new Map();
const expandedDecoders = new Map();
try {
for (const [depth, bytes] of state.checkedTiers) {
checkedDecoders.set(depth, decodeAdaptiveBitset(bytes, normalized.slots.length));
}
for (const [depth, bytes] of state.expandedTiers) {
expandedDecoders.set(depth, decodeAdaptiveBitset(bytes, normalized.slots.length));
}
} catch {
return false;
}
for (const [path, node] of tree.nodesByPath) {
const slot = normalized.pathToSlot.get(path);
if (slot === undefined) continue;
const depth = path.split(PATH_DELIMITER).length;
const parentPath = path.includes(PATH_DELIMITER)
? path.slice(0, path.lastIndexOf(PATH_DELIMITER))
: null;
const parent = resolvedByPath.get(parentPath) ?? {
checked: false,
hasChecked: false,
expanded: false,
hasExpanded: false
};
const item = tree.itemForNode(node.id);
const checkbox = item && tree.directCheckbox(item);
const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle');
const isCovered = slot < state.coverage;
const checked = checkbox && isCovered
? (parent.hasChecked ? parent.checked : false) !== checkedDecoders.get(depth)(slot)
: false;
const expanded = Boolean(toggle && isCovered && expandedDecoders.get(depth)(slot));
if (checkbox && !checkbox.disabled) checkbox.checked = checked;
tree.setExpanded(item, expanded);
resolvedByPath.set(path, {
checked,
hasChecked: Boolean(checkbox),
expanded,
hasExpanded: Boolean(toggle)
});
}
tree.updateAllParentCheckboxes();
return true;
}
export function serializeStateToFragment(tree, manifest, encoding = tree.options?.stateEncoding ?? 'adaptive') {
const state = encodeTreeState(tree, manifest, encoding);
const params = new URLSearchParams({ v: String(state.version) });
if (state.encoding === 'tiered') {
for (const [name, value] of state.fields) params.set(name, value);
} else {
if (state.checked) params.set('c', state.checked);
if (state.expanded) params.set('x', state.expanded);
}
return `#${params}`;
}
export function restoreStateFromLocation(
tree,
manifest,
location = window.location,
encoding = tree.options?.stateEncoding ?? 'adaptive'
) {
const state = decodeTreeState(location.hash ?? '', manifest, encoding);
if (state.applied) applyTreeState(tree, manifest, state);
return state;
}