// 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// back to the user's // working// 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 // //working/<…>. Unstageable if it lives under // //staging//<…>. 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// — 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 ''; }).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')); }); 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 = '

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')); }); 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 }; })();