(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 //table.html — fetch /table.yaml, // list every other *.yaml in 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(); if (inline && Object.keys(inline).length > 0) { return inline; } if (typeof location !== 'undefined' && (location.protocol === 'http:' || location.protocol === 'https:')) { try { const walked = await walkServer(); 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() { 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 lives at /table.yaml — the page URL is // /table.html, so the spec is right next door. const spec = await readYaml(dir, 'table.yaml'); if (!spec || !Array.isArray(spec.columns)) { throw new Error('Spec table.yaml missing columns[]'); } // Optional row schema from /form.yaml — same JSON Schema // the form-mode renderer uses. Phase 2 derives per-cell editor // widgets from it (text/number/date/select/checkbox). // Best-effort: a directory with only table.yaml still renders // as a sortable/filterable table; cells fall back to plain // text inputs without per-property hints. let rowSchema = null; try { const formSpec = await readYaml(dir, 'form.yaml'); if (formSpec && formSpec.schema) { rowSchema = formSpec.schema; } } catch (_) { // form.yaml missing or unreadable; carry on without it. } // Rows are every *.yaml in 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 — /<…>//table.html (legacy/explicit // entry-point; the tool was opened via the // literal file URL). // Form B — /<…>/ or /<…>// (served // by the cascade's `default_tool: tables` at // archive//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); } // 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// 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 // / 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: 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 `…/`) 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);