Two layers shipped together since the second builds on the first. LAYER 1 — reviewing/ + Plan Review scaffolding - reviewing/ is now a real folder under each project, populated by the Plan Review composite endpoint. The old reviewing/ virtual aggregator handler is retired. - POST /<project>/archive/<party>/received/<tracking>/ with X-ZDDC-Op: plan-review scaffolds physical workflow folders under reviewing_root and staging_root, each carrying .zddc.received_path pointing back at the canonical submittal. Idempotent re-runs match by received_path and re-converge the ACL. - Virtual received window: when listing or writing under <workflow>/received/, the server resolves through the canonical archive/<party>/received/<tracking>/ via the workflow's .zddc.received_path. Writes get rewritten to <workflow>/<base>+C<n><suffix> so review comments land in the workflow folder and never touch the WORM archive. - Cascade defaults declare on_plan_review per project so the reviewing_root and staging_root are configurable. LAYER 2 — browse context-menu workflows - Accept Transmittal: right-click a transmittal folder in archive/<party>/incoming/ → validates ZDDC folder + filename conformance, atomic-renames the folder to archive/<party>/received/<tracking>/ (WORM zone), and optionally chains into Plan Review in the same composite request. Re-acceptance with a different revision merges file-by-file; WORM forbids overwrite of an existing filename. - Stage / Unstage: right-click files in working/<…>/ → "Stage to…" with picker of existing staging transmittal folders + inline "New transmittal folder…" create; right-click files in staging/<…>/ → "Unstage to working/" defaulting to the user's working/<email>/ home. Reuses the file-API move primitive. - Create Transmittal folder: right-click the staging/ pane → prompts for a ZDDC-conforming folder name with live validation; mkdir, then navigate to the new folder URL where the transmittal tool serves the editor. - Supporting infrastructure: new CanonicalFolderAt cascade lookup + X-ZDDC-Canonical-Folder response header so the browse SPA can scope-gate menu items without re-implementing the cascade client-side. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
329 lines
16 KiB
JavaScript
329 lines
16 KiB
JavaScript
// stage.js — Stage and Unstage workflow modals.
|
|
//
|
|
// Stage: move a file from working/<…>/ into a transmittal folder under
|
|
// staging/<…>/. Modal lists existing transmittal folders in staging/
|
|
// plus a "New transmittal folder…" option that prompts for a ZDDC-
|
|
// conforming name and mkdirs it before the move.
|
|
//
|
|
// Unstage: move a file from staging/<transmittal>/ back to the user's
|
|
// working/<email>/ home (overridable).
|
|
//
|
|
// Both reuse the existing X-ZDDC-Op: move primitive — no new composite
|
|
// endpoint is needed; the client just orchestrates one POST per file
|
|
// (a multi-file selection iterates and reports aggregate status).
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
function status(msg, level) {
|
|
var t = window.zddc && window.zddc.toast;
|
|
if (t) t(msg, level || 'info');
|
|
}
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, function (c) {
|
|
return ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[c];
|
|
});
|
|
}
|
|
|
|
// ── Scope detection: path-shape, not cascade-content ──────────────
|
|
// A file is stageable if its containing folder lives under
|
|
// /<project>/working/<…>. Unstageable if it lives under
|
|
// /<project>/staging/<transmittal>/<…>. Both are path-shape
|
|
// queries — content/ACL is enforced server-side.
|
|
|
|
function projectAndSubtree(path) {
|
|
var rel = path.replace(/^\/+|\/+$/g, '').split('/');
|
|
if (rel.length < 2) return null;
|
|
return { project: rel[0], subtree: rel[1], rest: rel.slice(2) };
|
|
}
|
|
|
|
function isStageableFile(node) {
|
|
if (!node || node.isDir || node.virtual) return false;
|
|
var tree = window.app.modules.tree;
|
|
if (!tree) return false;
|
|
var p = projectAndSubtree(tree.pathFor(node));
|
|
return !!(p && p.subtree === 'working' && p.rest.length >= 1);
|
|
}
|
|
function isUnstageableFile(node) {
|
|
if (!node || node.isDir || node.virtual) return false;
|
|
var tree = window.app.modules.tree;
|
|
if (!tree) return false;
|
|
var p = projectAndSubtree(tree.pathFor(node));
|
|
// staging/<transmittal-folder>/<file> — at least one folder
|
|
// segment between staging/ and the file.
|
|
return !!(p && p.subtree === 'staging' && p.rest.length >= 2);
|
|
}
|
|
|
|
// ── Server helpers ─────────────────────────────────────────────────
|
|
|
|
// Fetch directory listing JSON. Returns [] on 404.
|
|
async function listDir(absUrl) {
|
|
if (!absUrl.endsWith('/')) absUrl += '/';
|
|
var resp = await fetch(absUrl, {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (resp.status === 404) return [];
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' fetching ' + absUrl);
|
|
var data = await resp.json();
|
|
return Array.isArray(data) ? data : [];
|
|
}
|
|
|
|
async function fetchStagingFolders(project) {
|
|
var entries = await listDir('/' + project + '/staging/');
|
|
return entries
|
|
.filter(function (e) { return e && e.isDir; })
|
|
.map(function (e) { return e.name; });
|
|
}
|
|
|
|
async function fetchSelfEmail() {
|
|
try {
|
|
var r = await fetch('/.profile/access', {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!r.ok) return '';
|
|
var d = await r.json();
|
|
return (d && d.email) || '';
|
|
} catch (_e) { return ''; }
|
|
}
|
|
|
|
// POST X-ZDDC-Op: mkdir to create a new directory. Idempotent.
|
|
async function mkdir(absUrl) {
|
|
var resp = await fetch(absUrl, {
|
|
method: 'POST',
|
|
headers: { 'X-ZDDC-Op': 'mkdir' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
var text = ''; try { text = await resp.text(); } catch (_e) {}
|
|
throw new Error('mkdir ' + absUrl + ' failed (' + resp.status + '): ' + text);
|
|
}
|
|
}
|
|
|
|
// POST X-ZDDC-Op: move + X-ZDDC-Destination header. Reuses the
|
|
// file-API move primitive (atomic os.Rename, dual ACL gates).
|
|
async function moveFile(srcUrl, dstUrl) {
|
|
var resp = await fetch(srcUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-ZDDC-Op': 'move',
|
|
'X-ZDDC-Destination': dstUrl
|
|
},
|
|
credentials: 'same-origin'
|
|
});
|
|
if (!resp.ok) {
|
|
var text = ''; try { text = await resp.text(); } catch (_e) {}
|
|
throw new Error('move ' + srcUrl + ' → ' + dstUrl + ' failed (' + resp.status + '): ' + text);
|
|
}
|
|
}
|
|
|
|
// ── Stage picker modal ─────────────────────────────────────────────
|
|
|
|
function openStagePicker(initial) {
|
|
return new Promise(function (resolve, reject) {
|
|
var overlay = document.createElement('div');
|
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
|
|
var box = document.createElement('div');
|
|
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
|
|
|
|
var folderList = initial.folders.map(function (name) {
|
|
return '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;">' +
|
|
'<input type="radio" name="stage-target" value="' + escapeHtml(name) + '">' +
|
|
'<span style="font-family:var(--code,monospace);">' + escapeHtml(name) + '</span>' +
|
|
'</label>';
|
|
}).join('');
|
|
|
|
box.innerHTML =
|
|
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Stage ' +
|
|
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' to…</h2>' +
|
|
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
|
'Pick the transmittal folder in <code>staging/</code> these files should join. ' +
|
|
'You can move them back to <code>working/</code> later if they need correction.' +
|
|
'</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;">' +
|
|
(folderList || '<em style="color:#888;">No existing transmittal folders in staging/.</em>') +
|
|
'<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;border-top:1px solid rgba(0,0,0,0.05);margin-top:0.3rem;padding-top:0.5rem;">' +
|
|
'<input type="radio" name="stage-target" value="__new__">' +
|
|
'<span><strong>New transmittal folder…</strong></span>' +
|
|
'</label>' +
|
|
'</div>' +
|
|
'<div id="stage-newname-row" style="display:none;font-size:0.9rem;">' +
|
|
'<label for="stage-newname">Folder name (ZDDC convention)</label><br>' +
|
|
'<input id="stage-newname" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" ' +
|
|
'placeholder="YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT">' +
|
|
'<div id="stage-newname-feedback" style="font-size:0.8rem;color:#888;margin-top:0.2rem;"></div>' +
|
|
'</div>' +
|
|
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
|
'<button type="button" id="stage-cancel">Cancel</button>' +
|
|
'<button type="button" id="stage-submit" class="btn-primary">Stage</button>' +
|
|
'</div>';
|
|
overlay.appendChild(box);
|
|
document.body.appendChild(overlay);
|
|
|
|
var newRow = box.querySelector('#stage-newname-row');
|
|
var newInput = box.querySelector('#stage-newname');
|
|
var feedback = box.querySelector('#stage-newname-feedback');
|
|
box.querySelectorAll('input[name="stage-target"]').forEach(function (r) {
|
|
r.addEventListener('change', function () {
|
|
newRow.style.display = (r.value === '__new__' && r.checked) ? '' : 'none';
|
|
if (r.value === '__new__' && r.checked) newInput.focus();
|
|
});
|
|
});
|
|
newInput.addEventListener('input', function () {
|
|
var v = newInput.value.trim();
|
|
if (!v) { feedback.textContent = ''; return; }
|
|
var parsed = window.zddc.parseFolder(v);
|
|
if (parsed && parsed.valid) {
|
|
feedback.style.color = '#2a8';
|
|
feedback.textContent = '✓ tracking=' + parsed.trackingNumber +
|
|
', status=' + parsed.status + ', title=' + parsed.title;
|
|
} else {
|
|
feedback.style.color = '#c33';
|
|
feedback.textContent = '✗ does not match YYYY-MM-DD_TRACKING (PURPOSE) - SUBJECT';
|
|
}
|
|
});
|
|
|
|
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
|
box.querySelector('#stage-cancel').addEventListener('click', function () {
|
|
close(); reject(new Error('cancelled'));
|
|
});
|
|
overlay.addEventListener('click', function (e) {
|
|
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
|
});
|
|
box.querySelector('#stage-submit').addEventListener('click', function () {
|
|
var sel = box.querySelector('input[name="stage-target"]:checked');
|
|
if (!sel) { status('Pick a destination folder.', 'error'); return; }
|
|
if (sel.value === '__new__') {
|
|
var name = newInput.value.trim();
|
|
var parsed = window.zddc.parseFolder(name);
|
|
if (!parsed || !parsed.valid) {
|
|
status('Folder name must conform to ZDDC convention.', 'error');
|
|
return;
|
|
}
|
|
close(); resolve({ create: true, folderName: name });
|
|
} else {
|
|
close(); resolve({ create: false, folderName: sel.value });
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Unstage picker modal ───────────────────────────────────────────
|
|
|
|
function openUnstagePicker(initial) {
|
|
return new Promise(function (resolve, reject) {
|
|
var overlay = document.createElement('div');
|
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;';
|
|
var box = document.createElement('div');
|
|
box.style.cssText = 'background:var(--bg,#fff);color:var(--fg,#111);padding:1.25rem 1.5rem;border-radius:6px;min-width:28rem;max-width:36rem;box-shadow:0 4px 20px rgba(0,0,0,0.25);';
|
|
box.innerHTML =
|
|
'<h2 style="margin:0 0 0.5rem 0;font-size:1.1rem;">Unstage ' +
|
|
initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '</h2>' +
|
|
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
|
|
'Move these files back into your drafting workspace under <code>working/</code> ' +
|
|
'so they can be corrected. Stage them again when ready.' +
|
|
'</p>' +
|
|
'<label for="unstage-target" style="font-size:0.9rem;">Destination folder</label>' +
|
|
'<input id="unstage-target" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" value="' +
|
|
escapeHtml(initial.defaultTarget) + '">' +
|
|
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
|
|
'<button type="button" id="unstage-cancel">Cancel</button>' +
|
|
'<button type="button" id="unstage-submit" class="btn-primary">Unstage</button>' +
|
|
'</div>';
|
|
overlay.appendChild(box);
|
|
document.body.appendChild(overlay);
|
|
|
|
var input = box.querySelector('#unstage-target');
|
|
function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); }
|
|
box.querySelector('#unstage-cancel').addEventListener('click', function () {
|
|
close(); reject(new Error('cancelled'));
|
|
});
|
|
overlay.addEventListener('click', function (e) {
|
|
if (e.target === overlay) { close(); reject(new Error('cancelled')); }
|
|
});
|
|
box.querySelector('#unstage-submit').addEventListener('click', function () {
|
|
var target = input.value.trim();
|
|
if (!target) { status('Destination is required.', 'error'); return; }
|
|
close(); resolve({ target: target });
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── Action drivers ─────────────────────────────────────────────────
|
|
|
|
async function invokeStage(node) {
|
|
var tree = window.app.modules.tree;
|
|
if (!tree) return;
|
|
var srcUrl = tree.pathFor(node);
|
|
var info = projectAndSubtree(srcUrl);
|
|
if (!info || info.subtree !== 'working') {
|
|
status('Stage applies only to files under working/.', 'error');
|
|
return;
|
|
}
|
|
var stagingBase = '/' + info.project + '/staging/';
|
|
var folders;
|
|
try { folders = await fetchStagingFolders(info.project); }
|
|
catch (e) {
|
|
status('Could not list staging/: ' + (e && e.message ? e.message : e), 'error');
|
|
return;
|
|
}
|
|
|
|
var choice;
|
|
try {
|
|
choice = await openStagePicker({ fileCount: 1, folders: folders });
|
|
} catch (_e) { return; }
|
|
|
|
if (choice.create) {
|
|
try {
|
|
await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/');
|
|
} catch (e) {
|
|
status((e && e.message) || 'mkdir failed', 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
var dstUrl = stagingBase + encodeURIComponent(choice.folderName) + '/' + encodeURIComponent(node.name);
|
|
try {
|
|
await moveFile(srcUrl, dstUrl);
|
|
} catch (e) {
|
|
status((e && e.message) || 'move failed', 'error');
|
|
return;
|
|
}
|
|
status('Staged ' + node.name + ' → staging/' + choice.folderName + '/ — reload to see the move.', 'success');
|
|
}
|
|
|
|
async function invokeUnstage(node) {
|
|
var tree = window.app.modules.tree;
|
|
if (!tree) return;
|
|
var srcUrl = tree.pathFor(node);
|
|
var info = projectAndSubtree(srcUrl);
|
|
if (!info || info.subtree !== 'staging') {
|
|
status('Unstage applies only to files under staging/.', 'error');
|
|
return;
|
|
}
|
|
var email = await fetchSelfEmail();
|
|
var defaultTarget = '/' + info.project + '/working/' + (email || '') + '/';
|
|
var choice;
|
|
try {
|
|
choice = await openUnstagePicker({ fileCount: 1, defaultTarget: defaultTarget });
|
|
} catch (_e) { return; }
|
|
var target = choice.target;
|
|
if (!target.endsWith('/')) target += '/';
|
|
var dstUrl = target + encodeURIComponent(node.name);
|
|
try {
|
|
await moveFile(srcUrl, dstUrl);
|
|
} catch (e) {
|
|
status((e && e.message) || 'move failed', 'error');
|
|
return;
|
|
}
|
|
status('Unstaged ' + node.name + ' → ' + target + ' — reload to see the move.', 'success');
|
|
}
|
|
|
|
window.app.modules.stage = {
|
|
isStageableFile: isStageableFile,
|
|
isUnstageableFile: isUnstageableFile,
|
|
invokeStage: invokeStage,
|
|
invokeUnstage: invokeUnstage
|
|
};
|
|
})();
|