feat(browse,tables): flat-peer clients + dual-mode cross-party aggregate
browse: the party picker reads the ssr/ registry (the authoritative party list) and creates at physical peer paths <project>/<peer>/<party>/…; "register new party" writes ssr/<party>.yaml first (party_source: ssr). stage.js + accept-transmittal.js repointed to the top-level workspace peers (working/staging/incoming) — received/issued + plan-review stay under the WORM archive. tables: mdl/ and rsk/ render the cross-party aggregate by recursing ONE level into the party subdirs CLIENT-side (works online AND offline), with $party from the server-injected row content (or derived from the subdir offline). Rows carry the <party>/ prefix so reads/edits hit the real per-party path. The server just lists the peer root normally (party subdirs + synthetic table.yaml/form.yaml) — the fs/tree flattening + ListRollupRows are dropped in favour of this dual-mode client recursion. Full Go suite + all 256 Playwright tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bee36c2ee9
commit
f94defc8c1
10 changed files with 145 additions and 285 deletions
|
|
@ -255,10 +255,10 @@
|
||||||
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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -670,49 +670,36 @@
|
||||||
// (^[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 []; }
|
||||||
}
|
}
|
||||||
|
|
@ -738,8 +725,8 @@
|
||||||
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 party’s 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/<party>/' + escapeHtml(opts.slot) + '/</code>.' +
|
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>' + escapeHtml(opts.slot) + '/<party>/</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>') +
|
||||||
|
|
@ -790,13 +777,15 @@
|
||||||
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;
|
||||||
|
|
@ -805,42 +794,45 @@
|
||||||
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 + '):';
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
// 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
|
||||||
|
|
@ -36,19 +33,21 @@
|
||||||
|
|
||||||
// ── 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) {
|
||||||
|
|
@ -85,7 +84,7 @@
|
||||||
|
|
||||||
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; });
|
||||||
|
|
@ -273,11 +272,11 @@
|
||||||
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) {
|
||||||
|
|
@ -315,12 +314,11 @@
|
||||||
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 });
|
||||||
|
|
|
||||||
|
|
@ -180,34 +180,58 @@
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -156,12 +156,11 @@
|
||||||
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({
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,11 @@
|
||||||
// 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];
|
||||||
|
|
|
||||||
|
|
@ -207,48 +207,16 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
|
||||||
|
|
||||||
// Tables register peers (ssr/mdl/rsk) at the project root:
|
// Tables register peers (ssr/mdl/rsk) at the project root:
|
||||||
//
|
//
|
||||||
// mdl/ and rsk/ render an AGGREGATE table — one row per physical
|
// The peer root lists normally — mdl/ and rsk/ show their party
|
||||||
// <project>/<peer>/<party>/*.yaml across all parties. This REPLACES
|
// subdirs (folder-nav), ssr/ shows its flat ssr/<party>.yaml rows.
|
||||||
// the physical party-subdir listing built above so the peer root
|
// For mdl/rsk the tables tool aggregates the cross-party table by
|
||||||
// shows the combined table (not a folder-nav of parties). Each
|
// recursing one level into the party subdirs CLIENT-side (works in
|
||||||
// row's URL is its real path; ACL is evaluated against the row's
|
// both online + offline modes); the $party column comes from the
|
||||||
// own chain; the $party column is injected on read (see the rollup
|
// server-injected row content (see ServeInjectedRow). All three just
|
||||||
// row serve). <peer>/<party>/ lists its rows normally.
|
// advertise the synthetic table.yaml/form.yaml entries here so the
|
||||||
//
|
// tables tool's walkServer finds the spec without a 404 (spec bytes
|
||||||
// ssr/ keeps its flat real-file listing (one ssr/<party>.yaml per
|
// come from main.go's IsDefaultSpec fallback).
|
||||||
// party = one row); only the spec entries are added.
|
|
||||||
//
|
|
||||||
// All three advertise synthetic table.yaml/form.yaml entries so the
|
|
||||||
// tables tool's client-side walkServer finds the spec without a 404
|
|
||||||
// (spec bytes come from main.go's IsDefaultSpec fallback).
|
|
||||||
if segsURL := strings.Split(strings.Trim(baseURL, "/"), "/"); len(segsURL) == 2 && zddc.IsRowSlot(segsURL[1]) {
|
if segsURL := strings.Split(strings.Trim(baseURL, "/"), "/"); len(segsURL) == 2 && zddc.IsRowSlot(segsURL[1]) {
|
||||||
peer := segsURL[1]
|
|
||||||
projectAbs := filepath.Join(fsRoot, segsURL[0])
|
|
||||||
if peer == "mdl" || peer == "rsk" {
|
|
||||||
rows, _ := zddc.ListRollupRows(projectAbs, peer)
|
|
||||||
agg := make([]listing.FileInfo, 0, len(rows)+2)
|
|
||||||
rowChains := make(map[string]zddc.PolicyChain)
|
|
||||||
for _, row := range rows {
|
|
||||||
rowDir := filepath.Dir(row.Abs)
|
|
||||||
chain, ok := rowChains[rowDir]
|
|
||||||
if !ok {
|
|
||||||
chain, _ = zddc.EffectivePolicy(fsRoot, rowDir)
|
|
||||||
rowChains[rowDir] = chain
|
|
||||||
}
|
|
||||||
verbs := policy.EffectiveVerbsFromChainP(ctx, decider, chain, principal, row.RelURL)
|
|
||||||
if !verbs.Has(zddc.VerbR) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
agg = append(agg, listing.FileInfo{
|
|
||||||
Name: row.Filename,
|
|
||||||
URL: row.RelURL,
|
|
||||||
IsDir: false,
|
|
||||||
Writable: verbs.Has(zddc.VerbW),
|
|
||||||
Verbs: verbs.String(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
result = agg
|
|
||||||
}
|
|
||||||
// Advertise the tables specs (skip any already present on disk).
|
// Advertise the tables specs (skip any already present on disk).
|
||||||
have := make(map[string]bool, len(result))
|
have := make(map[string]bool, len(result))
|
||||||
for _, fi := range result {
|
for _, fi := range result {
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,10 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mdl/ peer root renders the cross-party AGGREGATE: one entry per
|
// The mdl/ peer root lists its party subdirs (folder-nav) plus the
|
||||||
// physical mdl/<party>/*.yaml (real URLs), not a folder-nav of party
|
// synthetic table.yaml/form.yaml spec entries. The tables tool builds
|
||||||
// dirs. Spec entries (table.yaml/form.yaml) are advertised too.
|
// the cross-party aggregate by recursing into the party subdirs
|
||||||
|
// client-side; the server just lists normally + advertises the spec.
|
||||||
func TestListDirectory_MdlAggregate(t *testing.T) {
|
func TestListDirectory_MdlAggregate(t *testing.T) {
|
||||||
root := setupTreeRoot(t)
|
root := setupTreeRoot(t)
|
||||||
mk := func(p string) {
|
mk := func(p string) {
|
||||||
|
|
@ -93,21 +94,13 @@ func TestListDirectory_MdlAggregate(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list: %v", err)
|
t.Fatalf("list: %v", err)
|
||||||
}
|
}
|
||||||
rowURLs := map[string]bool{}
|
names := map[string]bool{}
|
||||||
for _, fi := range got {
|
for _, fi := range got {
|
||||||
if !fi.IsDir {
|
names[fi.Name] = true
|
||||||
rowURLs[fi.URL] = true
|
|
||||||
}
|
}
|
||||||
}
|
for _, want := range []string{"Acme/", "Beta/", "table.yaml", "form.yaml"} {
|
||||||
for _, want := range []string{"/Proj/mdl/Acme/D-001.yaml", "/Proj/mdl/Beta/D-009.yaml"} {
|
if !names[want] {
|
||||||
if !rowURLs[want] {
|
t.Errorf("mdl/ listing missing %q; got %+v", want, got)
|
||||||
t.Errorf("aggregate listing missing row %q; got %+v", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// No party SUBDIR entries in the aggregate (rows, not folders).
|
|
||||||
for _, fi := range got {
|
|
||||||
if fi.IsDir {
|
|
||||||
t.Errorf("aggregate mdl/ should not list party dirs; got dir %q", fi.Name)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,68 +122,3 @@ func ListParties(projectAbs string) ([]string, error) {
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RollupRow describes one row in an mdl/ or rsk/ aggregate table,
|
|
||||||
// gathered from the physical <project>/<peer>/<party>/<file>.yaml.
|
|
||||||
type RollupRow struct {
|
|
||||||
Party string // source party (real subdir name → the $party column)
|
|
||||||
Filename string // e.g. "ACME-PRJ-EL-SPC-0001.yaml"
|
|
||||||
Abs string // underlying file on disk
|
|
||||||
RelURL string // /<project>/<peer>/<party>/<file>.yaml
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRollupRows walks <project>/<peer>/*/*.yaml (peer = mdl|rsk) and
|
|
||||||
// returns one row per *.yaml file, sorted by (party, filename). The
|
|
||||||
// $party column is the real subdir name. Skipped: table.yaml / form.yaml
|
|
||||||
// specs, non-*.yaml files, and party dirs with invalid names. Returns
|
|
||||||
// nil + nil when the peer dir doesn't exist on disk.
|
|
||||||
func ListRollupRows(projectAbs, peer string) ([]RollupRow, error) {
|
|
||||||
if peer != "mdl" && peer != "rsk" {
|
|
||||||
return nil, errors.New("ListRollupRows: peer must be mdl or rsk")
|
|
||||||
}
|
|
||||||
peerAbs := filepath.Join(projectAbs, peer)
|
|
||||||
partyEntries, err := os.ReadDir(peerAbs)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
project := filepath.Base(projectAbs)
|
|
||||||
var out []RollupRow
|
|
||||||
for _, pe := range partyEntries {
|
|
||||||
if !pe.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
party := pe.Name()
|
|
||||||
if !ValidPartyName(party) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
partyDir := filepath.Join(peerAbs, party)
|
|
||||||
rows, err := os.ReadDir(partyDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, e := range rows {
|
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if e.Name() == "table.yaml" || e.Name() == "form.yaml" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, RollupRow{
|
|
||||||
Party: party,
|
|
||||||
Filename: e.Name(),
|
|
||||||
Abs: filepath.Join(partyDir, e.Name()),
|
|
||||||
RelURL: "/" + project + "/" + peer + "/" + party + "/" + e.Name(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].Party != out[j].Party {
|
|
||||||
return out[i].Party < out[j].Party
|
|
||||||
}
|
|
||||||
return out[i].Filename < out[j].Filename
|
|
||||||
})
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -140,52 +140,3 @@ func TestListParties_NoRegistry(t *testing.T) {
|
||||||
t.Errorf("got %v, want empty", parties)
|
t.Errorf("got %v, want empty", parties)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRollupRows aggregates physical <project>/<peer>/<party>/*.yaml.
|
|
||||||
func TestListRollupRows(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
projectAbs := filepath.Join(root, "Project")
|
|
||||||
|
|
||||||
for _, party := range []string{"0330C1", "0440P2"} {
|
|
||||||
if err := os.MkdirAll(filepath.Join(projectAbs, "mdl", party), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mustWrite := func(p string) {
|
|
||||||
if err := os.WriteFile(p, []byte("id: x\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "D-001.yaml"))
|
|
||||||
mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "D-002.yaml"))
|
|
||||||
mustWrite(filepath.Join(projectAbs, "mdl", "0440P2", "D-010.yaml"))
|
|
||||||
// Skipped: table.yaml / form.yaml specs.
|
|
||||||
mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "table.yaml"))
|
|
||||||
mustWrite(filepath.Join(projectAbs, "mdl", "0330C1", "form.yaml"))
|
|
||||||
|
|
||||||
rows, err := ListRollupRows(projectAbs, "mdl")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(rows) != 3 {
|
|
||||||
t.Fatalf("got %d rows, want 3; rows=%+v", len(rows), rows)
|
|
||||||
}
|
|
||||||
// Sorted by (party, filename); $party is the real subdir.
|
|
||||||
want := []struct{ party, file, relURL string }{
|
|
||||||
{"0330C1", "D-001.yaml", "/Project/mdl/0330C1/D-001.yaml"},
|
|
||||||
{"0330C1", "D-002.yaml", "/Project/mdl/0330C1/D-002.yaml"},
|
|
||||||
{"0440P2", "D-010.yaml", "/Project/mdl/0440P2/D-010.yaml"},
|
|
||||||
}
|
|
||||||
for i, w := range want {
|
|
||||||
if rows[i].Party != w.party || rows[i].Filename != w.file || rows[i].RelURL != w.relURL {
|
|
||||||
t.Errorf("row[%d] = %+v want party=%q file=%q rel=%q", i, rows[i], w.party, w.file, w.relURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListRollupRows_BadPeer(t *testing.T) {
|
|
||||||
root := t.TempDir()
|
|
||||||
if _, err := ListRollupRows(filepath.Join(root, "Project"), "ssr"); err == nil {
|
|
||||||
t.Error("expected error for peer=ssr (only mdl/rsk valid)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue