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) + '/.' +
+ '