// stage.js — Stage and Unstage workflow modals. // // In the flat-peer layout working/ and staging/ are top-level peers, // each partitioned by party: working// and // staging///. Stage and Unstage are per-party — the // destination batch is always inside the SAME party's staging peer. The // party context is read from the source file's path. // // Stage: move a file from working//<…> into a transmittal folder // under staging//<…>. Modal lists existing transmittal folders in // the party's 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/// back to // working// (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'); } // Re-fetch the current listing so the moved file appears/disappears // without a manual reload. Best-effort: absent on older builds. function refreshListing() { var ev = window.app.modules.events; if (ev && typeof ev.refreshListing === 'function') ev.refreshListing(); } // Guard against a second invocation while a move is mid-flight (e.g. a // double menu click). The picker modal also blocks re-entry while open. var busy = false; var escapeHtml = window.app.modules.util.escapeHtml; // ── Scope detection: path-shape, not cascade-content ────────────── // A file is stageable if its path matches // //working//<…>. Unstageable if it matches // //staging///<…>. Both are path-shape // queries — content/ACL is enforced server-side. var WORKSPACE_PEERS = { working: 1, staging: 1, reviewing: 1, incoming: 1 }; // projectPartySlot returns { project, party, slot, rest } when path // matches //// for a workspace peer, or // null on non-match. function projectPartySlot(path) { var rel = path.replace(/^\/+|\/+$/g, '').split('/'); if (rel.length < 3) return null; var slot = rel[1].toLowerCase(); if (!WORKSPACE_PEERS[slot]) return null; return { project: rel[0], slot: slot, party: rel[2], rest: rel.slice(3) }; } function isStageableFile(node) { if (!node || node.isDir || node.virtual) return false; var tree = window.app.modules.tree; if (!tree) return false; var p = projectPartySlot(tree.pathFor(node)); return !!(p && p.slot === '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 = projectPartySlot(tree.pathFor(node)); // archive//staging// — at // least one folder segment between staging/ and the file. return !!(p && p.slot === '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, party) { var entries = await listDir( '/' + project + '/staging/' + encodeURIComponent(party) + '/'); return entries .filter(function (e) { return e && e.isDir; }) .map(function (e) { return e.name; }); } // 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 ''; }).join(''); box.innerHTML = '

Stage ' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + ' to…

' + '

' + 'Pick the transmittal folder in staging/ these files should join. ' + 'You can move them back to working/ later if they need correction.' + '

' + '
' + (folderList || 'No existing transmittal folders in staging/.') + '' + '
' + '' + '
' + '' + '' + '
'; 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')); }); var pressedBackdrop = false; overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); }); overlay.addEventListener('click', function (e) { if (e.target === overlay && pressedBackdrop) { 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 = '

Unstage ' + initial.fileCount + ' file' + (initial.fileCount === 1 ? '' : 's') + '

' + '

' + 'Move these files back into your drafting workspace under working/ ' + 'so they can be corrected. Stage them again when ready.' + '

' + '' + '' + '
' + '' + '' + '
'; 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')); }); var pressedBackdrop = false; overlay.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === overlay); }); overlay.addEventListener('click', function (e) { if (e.target === overlay && pressedBackdrop) { 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) { if (busy) return; var tree = window.app.modules.tree; if (!tree) return; var srcUrl = tree.pathFor(node); var info = projectPartySlot(srcUrl); if (!info || info.slot !== 'working') { status('Stage applies only to files under working//.', 'error'); return; } var stagingBase = '/' + info.project + '/staging/' + encodeURIComponent(info.party) + '/'; var folders; try { folders = await fetchStagingFolders(info.project, info.party); } 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; } busy = true; try { // Stage is a non-atomic mkdir-then-move (no single composite op). // Track whether the folder was freshly created so that, if the // move then fails, we can tell the user the folder exists but the // file didn't make it — otherwise an empty folder appears with a // generic "move failed" and no explanation. var createdFolder = false; if (choice.create) { try { await mkdir(stagingBase + encodeURIComponent(choice.folderName) + '/'); createdFolder = true; } 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) { var msg = (e && e.message) || 'move failed'; if (createdFolder) { msg += ' — the new folder "' + choice.folderName + '" was created but ' + node.name + ' was not moved into it.'; } status(msg, 'error'); refreshListing(); // surface the (possibly empty) new folder return; } status('Staged ' + node.name + ' → ' + info.party + '/staging/' + choice.folderName + '/', 'success'); refreshListing(); } finally { busy = false; } } async function invokeUnstage(node) { if (busy) return; var tree = window.app.modules.tree; if (!tree) return; var srcUrl = tree.pathFor(node); var info = projectPartySlot(srcUrl); if (!info || info.slot !== 'staging') { status('Unstage applies only to files under staging//.', 'error'); return; } var defaultTarget = '/' + info.project + '/working/' + encodeURIComponent(info.party) + '/'; 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); busy = true; try { try { await moveFile(srcUrl, dstUrl); } catch (e) { status((e && e.message) || 'move failed', 'error'); return; } status('Unstaged ' + node.name + ' → ' + target, 'success'); refreshListing(); } finally { busy = false; } } window.app.modules.stage = { isStageableFile: isStageableFile, isUnstageableFile: isUnstageableFile, invokeStage: invokeStage, invokeUnstage: invokeUnstage }; })();