chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 8s

This commit is contained in:
ZDDC 2026-06-03 12:46:12 -05:00
parent 29182480c2
commit b59a7f6100
7 changed files with 140 additions and 127 deletions

View file

@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2484,7 +2484,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -11549,10 +11549,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return; return;
} }
// Derive the party from the path: archive/<party>/incoming/<folder>/. // Derive the party from the path: incoming/<party>/<folder>/.
var parts = url.replace(/^\/+|\/+$/g, '').split('/'); var parts = url.replace(/^\/+|\/+$/g, '').split('/');
var partyIdx = parts.indexOf('archive'); var incIdx = parts.indexOf('incoming');
var party = (partyIdx >= 0 && parts[partyIdx + 1]) ? parts[partyIdx + 1] : ''; var party = (incIdx >= 0 && parts[incIdx + 1]) ? parts[incIdx + 1] : '';
var classification = classifyChildren(node, parsedFolder.trackingNumber); var classification = classifyChildren(node, parsedFolder.trackingNumber);
@ -11607,22 +11607,19 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// stage.js — Stage and Unstage workflow modals. // stage.js — Stage and Unstage workflow modals.
// //
// After the layout reshape, working/ and staging/ live INSIDE each // In the flat-peer layout working/ and staging/ are top-level peers,
// party folder: archive/<party>/working/<email>/<file> and // each partitioned by party: working/<party>/<file> and
// archive/<party>/staging/<batch>/<file>. Stage and Unstage are now // staging/<party>/<batch>/<file>. Stage and Unstage are per-party — the
// per-party — the destination batch is always inside the SAME // destination batch is always inside the SAME party's staging peer. The
// party's staging slot. The party context is read from the source // party context is read from the source file's path.
// file's path.
// //
// Stage: move a file from archive/<party>/working/<…> into a // Stage: move a file from working/<party>/<…> into a transmittal folder
// transmittal folder under archive/<party>/staging/<…>. Modal lists // under staging/<party>/<…>. Modal lists existing transmittal folders in
// existing transmittal folders in the party's staging/ plus a "New // the party's staging/ plus a "New transmittal folder…" option that
// transmittal folder…" option that prompts for a ZDDC-conforming // prompts for a ZDDC-conforming name and mkdirs it before the move.
// name and mkdirs it before the move.
// //
// Unstage: move a file from archive/<party>/staging/<transmittal>/ // Unstage: move a file from staging/<party>/<transmittal>/ back to
// back to the user's archive/<party>/working/<email>/ home // working/<party>/ (overridable).
// (overridable).
// //
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite // Both reuse the existing X-ZDDC-Op: move primitive — no new composite
// endpoint is needed; the client just orchestrates one POST per file // endpoint is needed; the client just orchestrates one POST per file
@ -11643,19 +11640,21 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// ── Scope detection: path-shape, not cascade-content ────────────── // ── Scope detection: path-shape, not cascade-content ──────────────
// A file is stageable if its path matches // A file is stageable if its path matches
// /<project>/archive/<party>/working/<…>. Unstageable if it // /<project>/working/<party>/<…>. Unstageable if it matches
// matches /<project>/archive/<party>/staging/<transmittal>/<…>. // /<project>/staging/<party>/<transmittal>/<…>. Both are path-shape
// Both are path-shape queries — content/ACL is enforced server- // queries — content/ACL is enforced server-side.
// side.
// projectPartySlot returns { project, party, slot, rest } when var WORKSPACE_PEERS = { working: 1, staging: 1, reviewing: 1, incoming: 1 };
// path matches /<project>/archive/<party>/<slot>/<rest>, or
// projectPartySlot returns { project, party, slot, rest } when path
// matches /<project>/<slot>/<party>/<rest> for a workspace peer, or
// null on non-match. // null on non-match.
function projectPartySlot(path) { function projectPartySlot(path) {
var rel = path.replace(/^\/+|\/+$/g, '').split('/'); var rel = path.replace(/^\/+|\/+$/g, '').split('/');
if (rel.length < 4) return null; if (rel.length < 3) return null;
if (rel[1].toLowerCase() !== 'archive') return null; var slot = rel[1].toLowerCase();
return { project: rel[0], party: rel[2], slot: rel[3], rest: rel.slice(4) }; if (!WORKSPACE_PEERS[slot]) return null;
return { project: rel[0], slot: slot, party: rel[2], rest: rel.slice(3) };
} }
function isStageableFile(node) { function isStageableFile(node) {
@ -11692,7 +11691,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
async function fetchStagingFolders(project, party) { async function fetchStagingFolders(project, party) {
var entries = await listDir( var entries = await listDir(
'/' + project + '/archive/' + encodeURIComponent(party) + '/staging/'); '/' + project + '/staging/' + encodeURIComponent(party) + '/');
return entries return entries
.filter(function (e) { return e && e.isDir; }) .filter(function (e) { return e && e.isDir; })
.map(function (e) { return e.name; }); .map(function (e) { return e.name; });
@ -11880,11 +11879,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
var srcUrl = tree.pathFor(node); var srcUrl = tree.pathFor(node);
var info = projectPartySlot(srcUrl); var info = projectPartySlot(srcUrl);
if (!info || info.slot !== 'working') { if (!info || info.slot !== 'working') {
status('Stage applies only to files under archive/<party>/working/.', 'error'); status('Stage applies only to files under working/<party>/.', 'error');
return; return;
} }
var stagingBase = '/' + info.project + '/archive/' + var stagingBase = '/' + info.project + '/staging/' +
encodeURIComponent(info.party) + '/staging/'; encodeURIComponent(info.party) + '/';
var folders; var folders;
try { folders = await fetchStagingFolders(info.project, info.party); } try { folders = await fetchStagingFolders(info.project, info.party); }
catch (e) { catch (e) {
@ -11922,12 +11921,11 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
var srcUrl = tree.pathFor(node); var srcUrl = tree.pathFor(node);
var info = projectPartySlot(srcUrl); var info = projectPartySlot(srcUrl);
if (!info || info.slot !== 'staging') { if (!info || info.slot !== 'staging') {
status('Unstage applies only to files under archive/<party>/staging/.', 'error'); status('Unstage applies only to files under staging/<party>/.', 'error');
return; return;
} }
var email = await fetchSelfEmail(); var defaultTarget = '/' + info.project + '/working/' +
var defaultTarget = '/' + info.project + '/archive/' + encodeURIComponent(info.party) + '/';
encodeURIComponent(info.party) + '/working/' + (email || '') + '/';
var choice; var choice;
try { try {
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget }); choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
@ -13168,49 +13166,36 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
// (^[A-Za-z0-9][A-Za-z0-9.-]*$). // (^[A-Za-z0-9][A-Za-z0-9.-]*$).
function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); } function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); }
// The project-level folder-nav aggregators. These have no physical // The party-partitioned workspace peers. Each is a physical top-level
// presence: <project>/<slot>/ lists the parties whose // directory <project>/<peer>/ whose children are <party>/ folders.
// archive/<party>/<slot>/ has content. Creating something here means // Creating something at a peer root means choosing a party — see
// creating it under a party — see createInAggregator. // createInAggregator. (mdl/rsk rows are created via the tables tool;
var FOLDER_NAV_SLOTS = { working: 1, staging: 1, reviewing: 1 }; // archive is the WORM record; ssr is the flat registry — none of those
// use this picker.)
var PARTY_PEERS = { incoming: 1, working: 1, staging: 1, reviewing: 1 };
// aggregatorRoot returns { project, slot } when parentDir is a // aggregatorRoot returns { project, slot } when parentDir is a party-
// project-level folder-nav aggregator root (server mode only), else // partitioned peer root (server mode only), else null. parentDir is a
// null. parentDir is a "/<project>/<slot>/" URL. // "/<project>/<peer>/" URL.
function aggregatorRoot(parentDir) { function aggregatorRoot(parentDir) {
if (state.source !== 'server') return null; if (state.source !== 'server') return null;
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
if (segs.length !== 2 || !segs[0]) return null; if (segs.length !== 2 || !segs[0]) return null;
var slot = segs[1].toLowerCase(); var peer = segs[1].toLowerCase();
return FOLDER_NAV_SLOTS[slot] ? { project: segs[0], slot: slot } : null; return PARTY_PEERS[peer] ? { project: segs[0], slot: peer } : null;
} }
// rewriteAggregatorPath maps a path UNDER a folder-nav aggregator // List the registered parties for a project — one ssr/<party>.yaml per
// (a party already chosen — /<project>/<slot>/<party>[/<rest>]) to its // party (the authoritative registry). A party "exists" iff its ssr row
// canonical archive path /<project>/archive/<party>/<slot>[/<rest>], // exists, so this is the canonical source for the picker. Returns []
// mirroring the server's folder-nav redirect. Returns null when // on error.
// parentDir isn't under such an aggregator (root case is handled by
// aggregatorRoot + the picker). Covers right-clicking a party row
// shown in an aggregator listing so "New folder" doesn't 409.
function rewriteAggregatorPath(parentDir) {
if (state.source !== 'server') return null;
var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/');
if (segs.length < 3 || !segs[0]) return null;
var slot = segs[1].toLowerCase();
if (!FOLDER_NAV_SLOTS[slot]) return null;
var p = '/' + segs[0] + '/archive/' + segs[2] + '/' + slot + '/';
var rest = segs.slice(3);
if (rest.length) p += rest.join('/') + '/';
return p;
}
// List the parties under a project's archive/ (folder names), sorted.
async function fetchParties(project) { async function fetchParties(project) {
try { try {
var entries = await loader.fetchServerChildren('/' + project + '/archive/'); var entries = await loader.fetchServerChildren('/' + project + '/ssr/');
return entries return entries
.filter(function (e) { return e.isDir; }) .filter(function (e) { return !e.isDir && /\.yaml$/i.test(e.name); })
.map(function (e) { return e.name; }) .map(function (e) { return e.name.replace(/\.yaml$/i, ''); })
.filter(function (n) { return n !== 'table' && n !== 'form'; })
.sort(function (a, b) { return a.localeCompare(b); }); .sort(function (a, b) { return a.localeCompare(b); });
} catch (_e) { return []; } } catch (_e) { return []; }
} }
@ -13236,8 +13221,8 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
box.innerHTML = box.innerHTML =
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/</h2>' + '<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' + '<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
escapeHtml(opts.slot) + '/ aggregates each partys work, so it has no folder of its own. ' + escapeHtml(opts.slot) + '/ is partitioned by party. ' +
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>archive/&lt;party&gt;/' + escapeHtml(opts.slot) + '/</code>.' + 'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/&lt;party&gt;/</code>.' +
'</p>' + '</p>' +
'<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' + '<div style="max-height:14rem;overflow-y:auto;border:1px solid rgba(0,0,0,0.1);padding:0.3rem 0.6rem;margin-bottom:0.5rem;">' +
(partyList || '<em style="color:#888;">No parties yet — create one below.</em>') + (partyList || '<em style="color:#888;">No parties yet — create one below.</em>') +
@ -13288,13 +13273,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
var nv = validateName(box.querySelector('#pp-name').value); var nv = validateName(box.querySelector('#pp-name').value);
if (!nv.ok) { statusError(nv.msg); return; } if (!nv.ok) { statusError(nv.msg); return; }
close(); close();
resolve({ party: party, name: nv.name }); resolve({ party: party, name: nv.name, isNew: sel.value === '__new__' });
}); });
}); });
} }
// createInAggregator routes a New folder/file in a virtual aggregator // createInAggregator routes a New folder/file at a party-peer root to
// root to archive/<party>/<slot>/<name> after prompting for the party. // the physical <project>/<peer>/<party>/<name> after prompting for the
// party. A brand-new party is registered first by creating its
// ssr/<party>.yaml row (the authoritative registry; party_source: ssr).
async function createInAggregator(agg, kind) { async function createInAggregator(agg, kind) {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (!up) return; if (!up) return;
@ -13303,42 +13290,45 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
if (!choice) return; if (!choice) return;
// Party names are validated to a URL-safe charset, so no encoding // Party names are validated to a URL-safe charset, so no encoding
// needed for the party segment; makeDir/makeFile encode the leaf. // needed for the party segment; makeDir/makeFile encode the leaf.
var targetDir = '/' + agg.project + '/archive/' + choice.party + '/' + agg.slot + '/'; var targetDir = '/' + agg.project + '/' + agg.slot + '/' + choice.party + '/';
try { try {
if (choice.isNew) {
// Register the party: its existence is ssr/<party>.yaml.
await up.makeFile('/' + agg.project + '/ssr/', choice.party + '.yaml',
'kind: SSR\n', 'application/yaml; charset=utf-8');
}
if (kind === 'folder') { if (kind === 'folder') {
await up.makeDir(targetDir, choice.name); await up.makeDir(targetDir, choice.name);
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + choice.name); statusInfo('Created ' + choice.party + '/' + choice.name + ' in ' + agg.slot + '/');
} else { } else {
var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md'; var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md';
var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n';
await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8'); await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8');
statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + name); statusInfo('Created ' + choice.party + '/' + name + ' in ' + agg.slot + '/');
} }
} catch (e) { } catch (e) {
var msg = (e && e.message) || String(e); var msg = (e && e.message) || String(e);
if (/\b403\b/.test(msg)) { if (/\b403\b/.test(msg)) {
statusError('Not allowed — creating a new party requires the document-controller role.'); statusError('Not allowed — registering a new party requires the document-controller role.');
} else if (/\b409\b/.test(msg)) {
statusError('Unknown party — register it first (document controller).');
} else { } else {
statusError('Create failed: ' + msg); statusError('Create failed: ' + msg);
} }
return; return;
} }
// Refresh the aggregator view — the party now appears if it had no
// content before.
await reloadDir('/' + agg.project + '/' + agg.slot + '/'); await reloadDir('/' + agg.project + '/' + agg.slot + '/');
} }
async function createInDir(parentDir, kind) { async function createInDir(parentDir, kind) {
var up = window.app.modules.upload; var up = window.app.modules.upload;
if (!up) return; if (!up) return;
// A project-level folder-nav aggregator (working/staging/reviewing) // At a party-peer root (incoming/working/staging/reviewing) the
// has no physical home — route through the party picker instead of // create needs a party — route through the picker. Deeper paths
// erroring on an unplaceable mkdir/PUT. // (a party already chosen, e.g. working/<party>/…) are physical and
// created directly.
var agg = aggregatorRoot(parentDir); var agg = aggregatorRoot(parentDir);
if (agg) return createInAggregator(agg, kind); if (agg) return createInAggregator(agg, kind);
// A party already chosen inside an aggregator view → canonical path.
var rewritten = rewriteAggregatorPath(parentDir);
if (rewritten) parentDir = rewritten;
var promptMsg = kind === 'folder' var promptMsg = kind === 'folder'
? 'New folder name (under ' + parentDir + '):' ? 'New folder name (under ' + parentDir + '):'
: 'New markdown filename (under ' + parentDir + '):'; : 'New markdown filename (under ' + parentDir + '):';

View file

@ -1793,7 +1793,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1536,7 +1536,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -2635,7 +2635,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b archive=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
transmittal=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b transmittal=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
classifier=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b classifier=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
landing=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b landing=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
form=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b form=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
tables=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b tables=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248
browse=v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b browse=v0.0.27-beta · 2026-06-03 17:46:04 · 2918248

View file

@ -1534,7 +1534,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 13:55:32 · 3e8737b</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-03 17:46:04 · 2918248</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
@ -3849,34 +3849,58 @@ body.is-elevated::after {
return cur; 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) { async function readRows(rowsDir, _rowsRel, _tableName) {
const rows = []; const rows = [];
for await (const entry of rowsDir.values()) {
if (entry.kind !== 'file') continue; async function pushRow(handle, relName) {
if (!entry.name.endsWith('.yaml')) continue;
// Skip the spec and the row-edit form — they live alongside
// the rows but aren't rows themselves.
if (entry.name === 'table.yaml' || entry.name === 'form.yaml') continue;
try { try {
const handle = await rowsDir.getFileHandle(entry.name);
const file = await handle.getFile(); const file = await handle.getFile();
const data = window.jsyaml.load(await file.text()); 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({ rows.push({
url: rowEditUrl(entry.name), url: rowEditUrl(relName),
// Underlying YAML URL — strip the trailing .html // Underlying YAML URL — strip the trailing .html from
// from the form-mode re-edit URL. Phase 3 PUTs to // the form-mode re-edit URL. PUTs go here with
// this URL with If-Match: <etag> for optimistic // If-Match: <etag> for optimistic concurrency.
// concurrency. yamlUrl: rowEditUrl(relName).replace(/\.html$/, ''),
yamlUrl: rowEditUrl(entry.name).replace(/\.html$/, ''), data: data,
data: data || {},
// ETag captured by HttpFileHandle.getFile from the
// server's response header. null in offline / file://
// mode (no HTTP roundtrip happened).
etag: handle._etag || null, etag: handle._etag || null,
editable: true editable: true
}); });
} catch (err) { } catch (err) {
console.warn('[tables] skipping unparseable row', entry.name, 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; return rows;
@ -5418,11 +5442,11 @@ body.is-elevated::after {
// contain primitive / string-array values that are safe to // contain primitive / string-array values that are safe to
// overwrite the corresponding top-level field. // overwrite the corresponding top-level field.
// //
// $-prefixed keys are system-synthesised on read (e.g. `$party` // $-prefixed keys are system-synthesised on read (e.g. `$party`,
// injected by the server's virtual-view handler on project- // injected by the server on mdl/rsk rows or derived from the
// rollup MDL/RSK rows). They are not part of the row's stored // party subdir in the aggregate view). They are not part of the
// YAML and would be rejected by the schema's additionalProperties // row's stored YAML and would be rejected by the schema's
// rule. Strip them before sending the write. // additionalProperties rule. Strip them before sending the write.
const merged = Object.assign({}, data || {}, drafts || {}); const merged = Object.assign({}, data || {}, drafts || {});
for (const k of Object.keys(merged)) { for (const k of Object.keys(merged)) {
if (k.charAt(0) === '$') delete merged[k]; if (k.charAt(0) === '$') delete merged[k];
@ -6152,12 +6176,11 @@ body.is-elevated::after {
const targets = inRange ? rangeRows : [ctx.rowId]; const targets = inRange ? rangeRows : [ctx.rowId];
const items = []; const items = [];
// Edit row — opens the schema-driven form-mode editor for // Edit row — opens the schema-driven form-mode editor for this
// this row. row.url is already the <id>.yaml.html form URL // row. row.url is the real <…>/<id>.yaml.html form URL (it carries
// (the form handler unwraps virtual-view URLs server-side, so // the <party>/ prefix for aggregate rows, so it hits the real
// SSR + rollup rows route to their per-party canonical paths // per-party path). Disabled on multi-row range and unsaved draft
// automatically). Disabled on multi-row range and unsaved // rows (no backing file yet).
// draft rows (no backing file yet).
const singleRow = targets.length === 1 ? ctx.row : null; const singleRow = targets.length === 1 ? ctx.row : null;
const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null; const editUrl = singleRow && !singleRow.isNew && singleRow.url ? singleRow.url : null;
items.push({ items.push({