release: v0.0.26 lockstep
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 7s
Build + deploy releases / build-and-deploy (push) Successful in 22s
Build + deploy releases / notify-chart-prod (push) Failing after 7s

This commit is contained in:
ZDDC 2026-06-01 10:52:10 -05:00
parent 23f4edffdc
commit 303bf7aade
7 changed files with 279 additions and 36 deletions

View file

@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.25</span> <span class="build-timestamp">v0.0.26</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2476,7 +2476,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">v0.0.25</span> <span class="build-timestamp">v0.0.26</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
@ -8533,6 +8533,10 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
async function saveContent(node, content) { async function saveContent(node, content) {
if (node.handle && typeof node.handle.createWritable === 'function') { 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(); var writable = await node.handle.createWritable();
await writable.write(content); await writable.write(content);
await writable.close(); await writable.close();
@ -10258,13 +10262,48 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
} catch (_e) { /* refresh is best-effort */ } } 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 ──────────────────────────────────────────────── // ── Create-new helpers ────────────────────────────────────────────────
// Both go through the same server endpoints used by upload: PUT // Server mode: PUT for files (empty/template body) and POST +
// for files (with an empty/template body) and POST + X-ZDDC-Op: // X-ZDDC-Op: mkdir for directories; the server's ACL is the source of
// mkdir for directories. Client-side enforcement is best-effort; // truth. FS-API mode: create directly in the picked tree via
// the server's ACL is the source of truth. // getDirectoryHandle/getFileHandle({create:true}) + createWritable —
// limited only by the filesystem permission the user granted.
async function makeDir(parentDir, name) { async function makeDir(parentDir, name) {
if (state.source === 'server') {
var url = joinUrl(parentDir, name); var url = joinUrl(parentDir, name);
if (!url.endsWith('/')) url += '/'; if (!url.endsWith('/')) url += '/';
var resp = await fetch(url, { var resp = await fetch(url, {
@ -10273,9 +10312,16 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
headers: { 'X-ZDDC-Op': 'mkdir' } headers: { 'X-ZDDC-Op': 'mkdir' }
}); });
if (!resp.ok) throw new Error('HTTP ' + resp.status); 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) { async function makeFile(parentDir, name, body, contentType) {
if (state.source === 'server') {
var resp = await fetch(joinUrl(parentDir, name), { var resp = await fetch(joinUrl(parentDir, name), {
method: 'PUT', method: 'PUT',
credentials: 'same-origin', credentials: 'same-origin',
@ -10283,6 +10329,15 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
body: body == null ? '' : body body: body == null ? '' : body
}); });
if (!resp.ok) throw new Error('HTTP ' + resp.status); 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 ───────────────────────────────────────────────────── // ── 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 // legacy removeEntry() lives on the PARENT directory handle
// and we don't retain ancestor handles). // and we don't retain ancestor handles).
if (node.handle && typeof node.handle.remove === 'function') { if (node.handle && typeof node.handle.remove === 'function') {
await ensureWritable();
await node.handle.remove({ recursive: !!node.isDir }); await node.handle.remove({ recursive: !!node.isDir });
return; 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+. // FS-API: handle.move(newName) is Chromium 110+.
if (node.handle && typeof node.handle.move === 'function') { if (node.handle && typeof node.handle.move === 'function') {
await ensureWritable();
await node.handle.move(newName); await node.handle.move(newName);
return; return;
} }
@ -10501,6 +10558,7 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
removeNode: removeNode, removeNode: removeNode,
renameNode: renameNode, renameNode: renameNode,
canMutate: canMutate, canMutate: canMutate,
ensureWritable: ensureWritable,
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
}; };
})(); })();
@ -12828,9 +12886,187 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return parentDir; return parentDir;
} }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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: <project>/<slot>/ lists the parties whose
// archive/<party>/<slot>/ 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 "/<project>/<slot>/" 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 — /<project>/<slot>/<party>[/<rest>]) to its
// canonical archive path /<project>/archive/<party>/<slot>[/<rest>],
// 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 '<label style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0;cursor:pointer;">' +
'<input type="radio" name="pp-party" 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;">New ' + kindWord + ' in ' + escapeHtml(opts.slot) + '/</h2>' +
'<p style="margin:0 0 0.5rem 0;font-size:0.85rem;color:#666;">' +
escapeHtml(opts.slot) + '/ aggregates each partys work, so it has no folder of its own. ' +
'Pick the party this ' + kindWord + ' belongs to — it lands under <code>archive/&lt;party&gt;/' + escapeHtml(opts.slot) + '/</code>.' +
'</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;">' +
(partyList || '<em style="color:#888;">No parties yet — create one below.</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="pp-party" value="__new__">' +
'<span><strong>+ New party…</strong> <span style="color:#888;font-size:0.8rem;">(document controller only)</span></span></label>' +
'</div>' +
'<div id="pp-newparty-row" style="display:none;margin-bottom:0.5rem;font-size:0.9rem;">' +
'<label for="pp-newparty">New party name</label><br>' +
'<input id="pp-newparty" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" placeholder="Acme">' +
'</div>' +
'<label for="pp-name" style="font-size:0.9rem;">' + (opts.kind === 'folder' ? 'Folder' : 'File') + ' name</label>' +
'<input id="pp-name" type="text" style="width:100%;padding:0.3rem;font-family:var(--code,monospace);" value="' + (opts.kind === 'folder' ? 'new-folder' : 'new.md') + '">' +
'<div style="display:flex;justify-content:flex-end;gap:0.5rem;margin-top:1rem;">' +
'<button type="button" id="pp-cancel">Cancel</button>' +
'<button type="button" id="pp-submit" class="btn-primary">Create</button>' +
'</div>';
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/<party>/<slot>/<name> 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) { 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)
// 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' var promptMsg = kind === 'folder'
? 'New folder name (under ' + parentDir + '):' ? 'New folder name (under ' + parentDir + '):'
: 'New markdown filename (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 // Items are kept VISIBLE but DISABLED when they don't apply, so
// every menu has the same shape regardless of what the user // every menu has the same shape regardless of what the user
// right-clicked. Predictable position = muscle memory. // 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) { function buildTreeRowMenu(ctx) {
var serverMode = state.source === 'server'; var serverMode = state.source === 'server';
var canMutate = function (c) { 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) ── // ── Create new (in the row's parent folder) ──
{ {
label: 'New folder', label: 'New folder',
disabled: !serverMode, disabled: !canCreateHere(),
action: function (c) { createInside(c.node, 'folder'); } action: function (c) { createInside(c.node, 'folder'); }
}, },
{ {
label: 'New markdown file', label: 'New markdown file',
disabled: !serverMode, disabled: !canCreateHere(),
action: function (c) { createInside(c.node, 'markdown'); } action: function (c) { createInside(c.node, 'markdown'); }
}, },
{ separator: true }, { separator: true },
@ -13269,12 +13512,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
return [ return [
{ {
label: 'New folder', label: 'New folder',
disabled: !serverMode, disabled: !canCreateHere(),
action: function () { createInDir(state.currentPath || '/', 'folder'); } action: function () { createInDir(state.currentPath || '/', 'folder'); }
}, },
{ {
label: 'New markdown file', label: 'New markdown file',
disabled: !serverMode, disabled: !canCreateHere(),
action: function () { createInDir(state.currentPath || '/', 'markdown'); } action: function () { createInDir(state.currentPath || '/', 'markdown'); }
}, },
// ── Create Transmittal folder (staging/ scope only) ── // ── Create Transmittal folder (staging/ scope only) ──

View file

@ -1793,7 +1793,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.25</span> <span class="build-timestamp">v0.0.26</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>

View file

@ -1536,7 +1536,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC</span> <span class="app-header__title">ZDDC</span>
<span class="build-timestamp">v0.0.25</span> <span class="build-timestamp">v0.0.26</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -2635,7 +2635,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">v0.0.25</span> <span class="build-timestamp">v0.0.26</span>
</div> </div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span> <span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action; <!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.25 archive=v0.0.26
transmittal=v0.0.25 transmittal=v0.0.26
classifier=v0.0.25 classifier=v0.0.26
landing=v0.0.25 landing=v0.0.26
form=v0.0.25 form=v0.0.26
tables=v0.0.25 tables=v0.0.26
browse=v0.0.25 browse=v0.0.26

View file

@ -1534,7 +1534,7 @@ body.is-elevated::after {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp">v0.0.25</span> <span class="build-timestamp">v0.0.26</span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">