ZDDC/tables/js/context.js
2026-06-11 13:32:31 -05:00

279 lines
11 KiB
JavaScript

(function (app) {
'use strict';
// load() resolves to the table context the rest of the app renders:
// { title?, description?, columns, rows, defaults? }
//
// Two paths:
//
// 1. Inline JSON (test seam, and also any host that wants to
// pre-render a context server-side): if #table-context parses
// to a non-empty object, return it as-is.
//
// 2. File-backed walk (the real-world path served by zddc-server):
// page is at /<dir>/table.html — fetch <dir>/table.yaml,
// list every other *.yaml in <dir> as a row file (filtering
// out table.yaml and form.yaml so they don't appear as rows),
// parse each, and assemble the same shape. The whole table
// lives in one directory.
//
// file:// mode without a directory handle is unsupported in v1 — the
// walk only runs against http(s). file:// users must either inject an
// inline context (tests) or open the page through zddc-server.
async function load() {
const inline = readInlineContext();
// A fully pre-assembled context (columns + rows) is used as-is — the
// test seam, or any host that renders the whole table server-side.
if (inline && Array.isArray(inline.columns)) {
return inline;
}
// Otherwise the inline context may still carry the server-injected
// SPEC ({spec, rowSchema}) sourced from <dir>/.zddc.d/ — pass it to
// walkServer, which uses it instead of fetching the spec and still
// walks the directory for row files.
if (typeof location !== 'undefined' &&
(location.protocol === 'http:' || location.protocol === 'https:')) {
try {
const walked = await walkServer(inline || {});
if (walked) {
return walked;
}
} catch (err) {
console.error('[tables] failed to load table from server', err);
showStatus('Could not load table: ' + (err && err.message ? err.message : err));
}
}
return {};
}
function readInlineContext() {
const el = document.getElementById('table-context');
if (!el) {
return null;
}
try {
return JSON.parse(el.textContent || '{}');
} catch (err) {
console.error('[tables] failed to parse #table-context', err);
return null;
}
}
function showStatus(msg) {
const el = document.getElementById('table-status');
if (!el) return;
el.textContent = msg;
el.hidden = false;
}
async function walkServer(injected) {
injected = injected || {};
const source = window.zddc && window.zddc.source;
if (!source) {
throw new Error('zddc.source not available');
}
const tableName = tableNameFromUrl(location.pathname);
if (!tableName) {
throw new Error('Unrecognized table URL: ' + location.pathname);
}
const probe = await source.detectServerRoot();
if (!probe.handle) {
throw new Error(probe.status === 403
? 'No permission to list this directory'
: 'Server unreachable');
}
const dir = probe.handle;
// Spec: prefer the server-injected #table-context.spec (sourced from
// <dir>/.zddc.d/table.yaml). Falling back, read the spec from the
// supporting-files reserve, then the legacy directory root — the
// FS-Access path, where there's no server to inject.
let spec = (injected.spec && Array.isArray(injected.spec.columns))
? injected.spec : null;
if (!spec) {
spec = await readYamlFirst(dir, ['.zddc.d/table.yaml', 'table.yaml']);
}
if (!spec || !Array.isArray(spec.columns)) {
throw new Error('Spec table.yaml missing columns[]');
}
// Row schema: prefer the injected #table-context.rowSchema, else read
// <dir>/.zddc.d/form.yaml (then legacy root). Best-effort — a table
// with no row schema still renders with plain-text cells.
let rowSchema = injected.rowSchema || null;
if (!rowSchema) {
try {
const formSpec = await readYamlFirst(dir, ['.zddc.d/form.yaml', 'form.yaml']);
if (formSpec && formSpec.schema) {
rowSchema = formSpec.schema;
}
} catch (_) {
// form.yaml missing or unreadable; carry on without it.
}
}
// Rows are every *.yaml in <currentdir> EXCEPT the spec
// (table.yaml) and the row-edit form (form.yaml). They live
// in the same directory by design — copying the directory
// copies the whole table.
const rows = await readRows(dir, '', tableName);
return {
title: spec.title,
description: spec.description,
columns: spec.columns,
defaults: spec.defaults,
// addable defaults to true; tables can opt out with
// `addable: false` (used by project-rollup MDL/RSK where the
// party affiliation of a new row is ambiguous — add at the
// per-party path instead).
addable: spec.addable !== false,
rowSchema: rowSchema,
rows: rows
};
}
function tableNameFromUrl(pathname) {
// Two URL shapes resolve to a table page:
// Form A — /<…>/<rowsdir>/table.html (legacy/explicit
// entry-point; the tool was opened via the
// literal file URL).
// Form B — /<…>/<rowsdir> or /<…>/<rowsdir>/ (served
// by the cascade's `default_tool: tables` at
// archive/<party>/mdl; the URL is the directory
// itself, no trailing filename).
// In both cases the table name is the rows-directory basename.
const a = String(pathname || '').match(/\/([^\/]+)\/table\.html$/);
if (a) return a[1];
const trimmed = String(pathname || '').replace(/\/$/, '');
const b = trimmed.match(/\/([^\/]+)$/);
return b ? b[1] : null;
}
function stripDotSlash(p) {
let out = String(p || '');
if (out.startsWith('./')) out = out.slice(2);
if (out.startsWith('/')) out = out.slice(1);
if (out.endsWith('/')) out = out.slice(0, -1);
return out;
}
async function readYaml(dir, relPath) {
const fileHandle = await resolveFile(dir, relPath);
const file = await fileHandle.getFile();
const text = await file.text();
if (!window.jsyaml) {
throw new Error('js-yaml not loaded');
}
return window.jsyaml.load(text);
}
// readYamlFirst tries each relPath in order, returning the first that
// resolves + parses. Used to read a spec from the supporting-files
// reserve (.zddc.d/<name>) with a fallback to the legacy directory root.
async function readYamlFirst(dir, relPaths) {
let lastErr = null;
for (var i = 0; i < relPaths.length; i++) {
try {
return await readYaml(dir, relPaths[i]);
} catch (err) {
lastErr = err;
}
}
if (lastErr) throw lastErr;
return null;
}
// Walk a "/"-separated relative path under dir, returning the
// FileSystemFileHandle (or HttpFileHandle) at the leaf.
async function resolveFile(dir, relPath) {
const parts = relPath.split('/').filter(Boolean);
if (parts.length === 0) {
throw new Error('Empty file path');
}
const fileName = parts.pop();
let cur = dir;
for (let i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i]);
}
return cur.getFileHandle(fileName);
}
async function resolveDirectory(dir, relPath) {
const parts = relPath.split('/').filter(Boolean);
let cur = dir;
for (let i = 0; i < parts.length; i++) {
cur = await cur.getDirectoryHandle(parts[i]);
}
return cur;
}
function isRowFile(name) {
return name.endsWith('.yaml') && name !== 'table.yaml' && name !== 'form.yaml';
}
// readRows reads a table's rows from rowsDir. A flat directory
// (a per-party register like mdl/<party>/ or the ssr/ registry)
// yields one row per *.yaml file. An aggregate peer root (mdl/ ,
// rsk/) instead contains party SUBDIRS — we recurse ONE level so the
// peer root renders the cross-party table. relName carries the
// <party>/ prefix for those rows so reads + edit URLs hit the real
// per-party path; $party is derived from that prefix (and matches the
// server-injected value online). Works in both online + offline modes.
async function readRows(rowsDir, _rowsRel, _tableName) {
const rows = [];
async function pushRow(handle, relName) {
try {
const file = await handle.getFile();
const data = window.jsyaml.load(await file.text()) || {};
const slash = relName.indexOf('/');
if (slash > 0 && typeof data === 'object' && data.$party === undefined) {
data['$party'] = relName.slice(0, slash);
}
rows.push({
url: rowEditUrl(relName),
// Underlying YAML URL — strip the trailing .html from
// the form-mode re-edit URL. PUTs go here with
// If-Match: <etag> for optimistic concurrency.
yamlUrl: rowEditUrl(relName).replace(/\.html$/, ''),
data: data,
etag: handle._etag || null,
editable: true
});
} catch (err) {
console.warn('[tables] skipping unparseable row', relName, err);
}
}
for await (const entry of rowsDir.values()) {
if (entry.kind === 'file') {
if (!isRowFile(entry.name)) continue;
await pushRow(await rowsDir.getFileHandle(entry.name), entry.name);
continue;
}
if (entry.kind === 'directory') {
let sub;
try { sub = await rowsDir.getDirectoryHandle(entry.name); }
catch (_e) { continue; }
for await (const child of sub.values()) {
if (child.kind !== 'file' || !isRowFile(child.name)) continue;
await pushRow(await sub.getFileHandle(child.name), entry.name + '/' + child.name);
}
}
}
return rows;
}
// Re-edit URL for one row. The page directory is the same
// directory the rows live in, regardless of which URL shape
// (Form A `…/table.html` vs Form B bare `…/<rowsdir>`) we were
// opened with — see tableNameFromUrl.
function rowEditUrl(rowFileName) {
let pageDir = location.pathname.replace(/\/table\.html$/, '/');
if (!pageDir.endsWith('/')) pageDir += '/';
return pageDir + rowFileName + '.html';
}
app.modules.context = { load: load };
})(window.tablesApp);