From 303bf7aade2009aa922ce80a0248a146029efc9c Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 1 Jun 2026 10:52:10 -0500 Subject: [PATCH] release: v0.0.26 lockstep --- zddc/internal/apps/embedded/archive.html | 2 +- zddc/internal/apps/embedded/browse.html | 291 +++++++++++++++++-- zddc/internal/apps/embedded/classifier.html | 2 +- zddc/internal/apps/embedded/index.html | 2 +- zddc/internal/apps/embedded/transmittal.html | 2 +- zddc/internal/apps/embedded/versions.txt | 14 +- zddc/internal/handler/tables.html | 2 +- 7 files changed, 279 insertions(+), 36 deletions(-) diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index df293d6..67ca152 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.25 + v0.0.26
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 70ba118..9818095 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -2476,7 +2476,7 @@ body {
ZDDC Browse - v0.0.25 + v0.0.26
@@ -8533,6 +8533,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr async function saveContent(node, content) { if (node.handle && typeof node.handle.createWritable === 'function') { + // Local folders are picked read-only; escalate to readwrite on + // first save (one FS-Access prompt, then granted for the session). + var up = window.app.modules.upload; + if (up && up.ensureWritable) await up.ensureWritable(); var writable = await node.handle.createWritable(); await writable.write(content); await writable.close(); @@ -10258,31 +10262,82 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } catch (_e) { /* refresh is best-effort */ } } + // ── Write-permission escalation (FS-API mode) ────────────────────────── + // The local folder is picked read-only (showDirectoryPicker mode:read) + // so browsing never prompts. The first mutation escalates to readwrite + // via the FS-Access permission prompt; granting on the picked root + // covers every descendant handle. Must run under a user gesture — every + // caller is reached from a click/menu action. No-op in server mode or + // on browsers without the permission API. + async function ensureWritable() { + if (state.source !== 'fs') return; + var root = state.rootHandle; + if (!root || typeof root.requestPermission !== 'function') return; + var opts = { mode: 'readwrite' }; + if ((await root.queryPermission(opts)) === 'granted') return; + if ((await root.requestPermission(opts)) === 'granted') return; + throw new Error('Write permission denied — grant edit access to the folder when prompted.'); + } + + // handleForDir resolves a directory PATH (FS-API mode) to its + // FileSystemDirectoryHandle: the picked root for the current scope, + // else the matching expanded node's handle. Returns null if unknown. + function handleForDir(dirPath) { + var tree = window.app.modules.tree; + if (!dirPath.endsWith('/')) dirPath += '/'; + if (dirPath === state.currentPath) return state.rootHandle; + var noSlash = dirPath.replace(/\/$/, ''); + var hit = null; + state.nodes.forEach(function (n) { + if (hit || !n.isDir || !n.handle) return; + if (tree && tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; + }); + return hit ? hit.handle : null; + } + // ── Create-new helpers ──────────────────────────────────────────────── - // Both go through the same server endpoints used by upload: PUT - // for files (with an empty/template body) and POST + X-ZDDC-Op: - // mkdir for directories. Client-side enforcement is best-effort; - // the server's ACL is the source of truth. + // Server mode: PUT for files (empty/template body) and POST + + // X-ZDDC-Op: mkdir for directories; the server's ACL is the source of + // truth. FS-API mode: create directly in the picked tree via + // getDirectoryHandle/getFileHandle({create:true}) + createWritable — + // limited only by the filesystem permission the user granted. async function makeDir(parentDir, name) { - var url = joinUrl(parentDir, name); - if (!url.endsWith('/')) url += '/'; - var resp = await fetch(url, { - method: 'POST', - credentials: 'same-origin', - headers: { 'X-ZDDC-Op': 'mkdir' } - }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); + if (state.source === 'server') { + var url = joinUrl(parentDir, name); + if (!url.endsWith('/')) url += '/'; + var resp = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-ZDDC-Op': 'mkdir' } + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return; + } + var parent = handleForDir(parentDir); + if (!parent) throw new Error('No directory handle for ' + parentDir); + await ensureWritable(); + await parent.getDirectoryHandle(name, { create: true }); } async function makeFile(parentDir, name, body, contentType) { - var resp = await fetch(joinUrl(parentDir, name), { - method: 'PUT', - credentials: 'same-origin', - headers: { 'Content-Type': contentType || 'application/octet-stream' }, - body: body == null ? '' : body - }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); + if (state.source === 'server') { + var resp = await fetch(joinUrl(parentDir, name), { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': contentType || 'application/octet-stream' }, + body: body == null ? '' : body + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return; + } + var parent = handleForDir(parentDir); + if (!parent) throw new Error('No directory handle for ' + parentDir); + await ensureWritable(); + var fh = await parent.getFileHandle(name, { create: true }); + var w = await fh.createWritable(); + await w.write(body == null ? '' : body); + await w.close(); } // ── Delete + rename ───────────────────────────────────────────────────── @@ -10347,6 +10402,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // legacy removeEntry() lives on the PARENT directory handle // and we don't retain ancestor handles). if (node.handle && typeof node.handle.remove === 'function') { + await ensureWritable(); await node.handle.remove({ recursive: !!node.isDir }); return; } @@ -10387,6 +10443,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr } // FS-API: handle.move(newName) is Chromium 110+. if (node.handle && typeof node.handle.move === 'function') { + await ensureWritable(); await node.handle.move(newName); return; } @@ -10501,6 +10558,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr removeNode: removeNode, renameNode: renameNode, canMutate: canMutate, + ensureWritable: ensureWritable, UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES }; })(); @@ -12828,9 +12886,187 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return parentDir; } + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; + }); + } + + // Valid party folder name — mirrors zddc.ValidPartyName server-side + // (^[A-Za-z0-9][A-Za-z0-9.-]*$). + function validPartyName(s) { return /^[A-Za-z0-9][A-Za-z0-9.-]*$/.test(s || ''); } + + // The project-level folder-nav aggregators. These have no physical + // presence: // lists the parties whose + // archive/// has content. Creating something here means + // creating it under a party — see createInAggregator. + var FOLDER_NAV_SLOTS = { working: 1, staging: 1, reviewing: 1 }; + + // aggregatorRoot returns { project, slot } when parentDir is a + // project-level folder-nav aggregator root (server mode only), else + // null. parentDir is a "///" URL. + function aggregatorRoot(parentDir) { + if (state.source !== 'server') return null; + var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); + if (segs.length !== 2 || !segs[0]) return null; + var slot = segs[1].toLowerCase(); + return FOLDER_NAV_SLOTS[slot] ? { project: segs[0], slot: slot } : null; + } + + // rewriteAggregatorPath maps a path UNDER a folder-nav aggregator + // (a party already chosen — ///[/]) to its + // canonical archive path //archive//[/], + // mirroring the server's folder-nav redirect. Returns null when + // 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) { + try { + var entries = await loader.fetchServerChildren('/' + project + '/archive/'); + return entries + .filter(function (e) { return e.isDir; }) + .map(function (e) { return e.name; }) + .sort(function (a, b) { return a.localeCompare(b); }); + } catch (_e) { return []; } + } + + // openPartyPicker resolves to { party, name } once the user picks a + // party (existing or new) and a name, or null on cancel. Mirrors the + // stage.js modal styling. New-party creation is offered but the server + // gates it to the document_controller (a 403 surfaces a clear message). + function openPartyPicker(opts) { + return new Promise(function (resolve) { + var kindWord = opts.kind === 'folder' ? 'folder' : 'file'; + 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 partyList = opts.parties.map(function (name) { + return ''; + }).join(''); + + box.innerHTML = + '

New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/

' + + '

' + + escapeHtml(opts.slot) + '/ aggregates each party’s work, so it has no folder of its own. ' + + 'Pick the party this ' + kindWord + ' belongs to — it lands under archive/<party>/' + escapeHtml(opts.slot) + '/.' + + '

' + + '
' + + (partyList || 'No parties yet — create one below.') + + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
'; + overlay.appendChild(box); + document.body.appendChild(overlay); + + var newRow = box.querySelector('#pp-newparty-row'); + var newInput = box.querySelector('#pp-newparty'); + box.querySelectorAll('input[name="pp-party"]').forEach(function (r) { + r.addEventListener('change', function () { + var isNew = (r.value === '__new__' && r.checked); + newRow.style.display = isNew ? '' : 'none'; + if (isNew) newInput.focus(); + }); + }); + + function close() { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); } + function cancel() { close(); resolve(null); } + box.querySelector('#pp-cancel').addEventListener('click', cancel); + overlay.addEventListener('click', function (e) { if (e.target === overlay) cancel(); }); + box.querySelector('#pp-submit').addEventListener('click', function () { + var sel = box.querySelector('input[name="pp-party"]:checked'); + if (!sel) { statusError('Pick a party.'); return; } + var party; + if (sel.value === '__new__') { + party = newInput.value.trim(); + if (!validPartyName(party)) { + statusError('Party name: a letter or digit, then letters/digits/dot/hyphen.'); + return; + } + } else { + party = sel.value; + } + var nv = validateName(box.querySelector('#pp-name').value); + if (!nv.ok) { statusError(nv.msg); return; } + close(); + resolve({ party: party, name: nv.name }); + }); + }); + } + + // createInAggregator routes a New folder/file in a virtual aggregator + // root to archive/// after prompting for the party. + async function createInAggregator(agg, kind) { + var up = window.app.modules.upload; + if (!up) return; + var parties = await fetchParties(agg.project); + var choice = await openPartyPicker({ project: agg.project, slot: agg.slot, kind: kind, parties: parties }); + if (!choice) return; + // Party names are validated to a URL-safe charset, so no encoding + // needed for the party segment; makeDir/makeFile encode the leaf. + var targetDir = '/' + agg.project + '/archive/' + choice.party + '/' + agg.slot + '/'; + try { + if (kind === 'folder') { + await up.makeDir(targetDir, choice.name); + statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + choice.name); + } else { + var name = /\.(md|markdown)$/i.test(choice.name) ? choice.name : choice.name + '.md'; + var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; + await up.makeFile(targetDir, name, template, 'text/markdown; charset=utf-8'); + statusInfo('Created ' + choice.party + '/' + agg.slot + '/' + name); + } + } catch (e) { + var msg = (e && e.message) || String(e); + if (/\b403\b/.test(msg)) { + statusError('Not allowed — creating a new party requires the document-controller role.'); + } else { + statusError('Create failed: ' + msg); + } + return; + } + // Refresh the aggregator view — the party now appears if it had no + // content before. + await reloadDir('/' + agg.project + '/' + agg.slot + '/'); + } + async function createInDir(parentDir, kind) { var up = window.app.modules.upload; if (!up) return; + // A project-level folder-nav aggregator (working/staging/reviewing) + // has no physical home — route through the party picker instead of + // erroring on an unplaceable mkdir/PUT. + var agg = aggregatorRoot(parentDir); + 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' ? 'New folder name (under ' + parentDir + '):' : 'New markdown filename (under ' + parentDir + '):'; @@ -12996,6 +13232,13 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // Items are kept VISIBLE but DISABLED when they don't apply, so // every menu has the same shape regardless of what the user // right-clicked. Predictable position = muscle memory. + // canCreateHere — whether New folder/file has a writable target: the + // server (ACL decides the rest) or a picked local folder (the + // filesystem permission decides, escalated on first write). + function canCreateHere() { + return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle); + } + function buildTreeRowMenu(ctx) { var serverMode = state.source === 'server'; var canMutate = function (c) { @@ -13055,12 +13298,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // ── Create new (in the row's parent folder) ── { label: 'New folder', - disabled: !serverMode, + disabled: !canCreateHere(), action: function (c) { createInside(c.node, 'folder'); } }, { label: 'New markdown file', - disabled: !serverMode, + disabled: !canCreateHere(), action: function (c) { createInside(c.node, 'markdown'); } }, { separator: true }, @@ -13269,12 +13512,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr return [ { label: 'New folder', - disabled: !serverMode, + disabled: !canCreateHere(), action: function () { createInDir(state.currentPath || '/', 'folder'); } }, { label: 'New markdown file', - disabled: !serverMode, + disabled: !canCreateHere(), action: function () { createInDir(state.currentPath || '/', 'markdown'); } }, // ── Create Transmittal folder (staging/ scope only) ── diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 4f404c7..d48a531 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1793,7 +1793,7 @@ body.is-elevated::after {
ZDDC Classifier - v0.0.25 + v0.0.26
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index ee4b685..04eea72 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -1536,7 +1536,7 @@ body {
ZDDC - v0.0.25 + v0.0.26
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index d75a805..13d1d4f 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2635,7 +2635,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.25 + v0.0.26
JavaScript not available