@@ -2105,6 +2105,375 @@ https://github.com/nodeca/pako/blob/main/LICENSE
};
})(typeof window !== 'undefined' ? window : globalThis);
+// shared/zddc-source.js — source abstraction for tools that handle
+// directory trees (classifier, mdedit, transmittal, browse, archive).
+//
+// Two backends:
+//
+// 1. Local — wraps a real FileSystemDirectoryHandle from the
+// File System Access API. Reads + writes go through the
+// FS Access API directly.
+//
+// 2. HTTP — talks to zddc-server's directory listing JSON
+// (Accept: application/json) for reads and the file API
+// (PUT/DELETE/POST X-ZDDC-Op) for writes. Implements a
+// polyfill of the FS Access API surface area the tools
+// use (kind, name, values(), getFileHandle, getDirectoryHandle,
+// removeEntry, getFile, createWritable, queryPermission /
+// requestPermission) so existing code works unchanged.
+//
+// The polyfill makes auto-load possible: when zddc-server serves
+// a tool at //.html, the tool detects HTTP mode at
+// startup, builds an HttpDirectoryHandle for the tool's containing
+// directory, and hands it to the existing openDirectory(handle)
+// flow without ever showing the file picker.
+//
+// Renames inside a tool today are typically done as
+// "write new + remove old". With HTTP-backed handles this becomes
+// PUT + DELETE — non-atomic. Tools that prefer the atomic server
+// MOVE should call window.zddc.source.moveFile(srcUrl, dstUrl)
+// directly instead of going through the polyfill.
+(function () {
+ 'use strict';
+
+ if (!window.zddc) window.zddc = {};
+ var FA = window.FileSystemDirectoryHandle || null;
+
+ // -----------------------------------------------------------------
+ // HTTP file API helpers
+ // -----------------------------------------------------------------
+
+ function joinUrl(base, name, isDir) {
+ if (!base.endsWith('/')) base = base + '/';
+ return base + encodeURIComponent(name) + (isDir ? '/' : '');
+ }
+
+ // Server returns directory entries with a trailing "/" on names.
+ // Strip it for the FS Access API name surface.
+ function stripSlash(name) {
+ return name.endsWith('/') ? name.slice(0, -1) : name;
+ }
+
+ async function httpListing(url) {
+ var resp = await fetch(url, {
+ headers: { 'Accept': 'application/json' },
+ credentials: 'same-origin'
+ });
+ if (!resp.ok) {
+ var err = new Error('listing ' + url + ': HTTP ' + resp.status);
+ err.status = resp.status;
+ throw err;
+ }
+ var data = await resp.json();
+ if (!Array.isArray(data)) {
+ throw new Error('listing ' + url + ': non-array body');
+ }
+ return data;
+ }
+
+ async function httpExists(url) {
+ try {
+ var r = await fetch(url, { method: 'HEAD', credentials: 'same-origin' });
+ return r.ok;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // HttpFileHandle — FileSystemFileHandle polyfill
+ // -----------------------------------------------------------------
+
+ function makeFile(blob, name, modTime) {
+ return new File([blob], name, {
+ type: blob.type,
+ lastModified: modTime ? modTime.getTime() : Date.now()
+ });
+ }
+
+ function HttpFileHandle(url, name, size, modTime) {
+ this.kind = 'file';
+ this.name = name;
+ this._url = url;
+ this._size = size || 0;
+ this._modTime = modTime || null;
+ this._etag = null;
+ }
+ HttpFileHandle.prototype.getFile = async function () {
+ var resp = await fetch(this._url, { credentials: 'same-origin' });
+ if (!resp.ok) {
+ throw new Error('GET ' + this._url + ': ' + resp.status);
+ }
+ var etag = resp.headers.get('ETag');
+ if (etag) this._etag = etag.replace(/"/g, '');
+ var lm = resp.headers.get('Last-Modified');
+ var modTime = lm ? new Date(lm) : this._modTime;
+ var blob = await resp.blob();
+ return makeFile(blob, this.name, modTime);
+ };
+ HttpFileHandle.prototype.createWritable = async function () {
+ var chunks = [];
+ var handle = this;
+ return {
+ async write(data) {
+ if (data == null) return;
+ if (typeof data === 'object' && data && 'type' in data && data.type === 'write') {
+ chunks.push(data.data);
+ return;
+ }
+ if (typeof data === 'object' && data && 'type' in data) {
+ // seek/truncate not supported by HTTP backend
+ throw new Error('HttpFileHandle write op not supported: ' + data.type);
+ }
+ chunks.push(data);
+ },
+ async close() {
+ var blob = new Blob(chunks);
+ var resp = await fetch(handle._url, {
+ method: 'PUT',
+ body: blob,
+ credentials: 'same-origin'
+ });
+ if (!resp.ok) {
+ var body = '';
+ try { body = await resp.text(); } catch (_) { /* ignore */ }
+ throw new Error('PUT ' + handle._url + ': ' + resp.status + ' ' + body);
+ }
+ var et = resp.headers.get('ETag');
+ if (et) handle._etag = et.replace(/"/g, '');
+ handle._size = blob.size;
+ },
+ async abort() { chunks = []; }
+ };
+ };
+ HttpFileHandle.prototype.queryPermission = async function () { return 'granted'; };
+ HttpFileHandle.prototype.requestPermission = async function () { return 'granted'; };
+ HttpFileHandle.prototype.isHttp = true;
+ HttpFileHandle.prototype.url = function () { return this._url; };
+
+ // -----------------------------------------------------------------
+ // HttpDirectoryHandle — FileSystemDirectoryHandle polyfill
+ // -----------------------------------------------------------------
+
+ function HttpDirectoryHandle(url, name) {
+ this.kind = 'directory';
+ if (!url.endsWith('/')) url = url + '/';
+ this._url = url;
+ this.name = name || guessNameFromUrl(url);
+ }
+ function guessNameFromUrl(url) {
+ var u = url.replace(/\/+$/, '');
+ var slash = u.lastIndexOf('/');
+ return slash >= 0 ? decodeURIComponent(u.substring(slash + 1)) : u;
+ }
+ HttpDirectoryHandle.prototype.values = function () {
+ var url = this._url;
+ return (async function* () {
+ var entries;
+ try {
+ entries = await httpListing(url);
+ } catch (e) {
+ return;
+ }
+ for (var i = 0; i < entries.length; i++) {
+ var e = entries[i];
+ var rawName = stripSlash(e.name);
+ var childUrl = joinUrl(url, rawName, e.is_dir);
+ if (e.is_dir) {
+ yield new HttpDirectoryHandle(childUrl, rawName);
+ } else {
+ var modTime = e.mod_time ? new Date(e.mod_time) : null;
+ yield new HttpFileHandle(childUrl, rawName, e.size || 0, modTime);
+ }
+ }
+ })();
+ };
+ HttpDirectoryHandle.prototype.entries = function () {
+ var iter = this.values();
+ return (async function* () {
+ for (;;) {
+ var step = await iter.next();
+ if (step.done) return;
+ yield [step.value.name, step.value];
+ }
+ })();
+ };
+ HttpDirectoryHandle.prototype.keys = function () {
+ var iter = this.values();
+ return (async function* () {
+ for (;;) {
+ var step = await iter.next();
+ if (step.done) return;
+ yield step.value.name;
+ }
+ })();
+ };
+ HttpDirectoryHandle.prototype.getFileHandle = async function (name, opts) {
+ opts = opts || {};
+ var url = joinUrl(this._url, name, false);
+ var exists = await httpExists(url);
+ if (!exists && !opts.create) {
+ var err = new Error('NotFoundError: ' + name);
+ err.name = 'NotFoundError';
+ throw err;
+ }
+ return new HttpFileHandle(url, name, 0, null);
+ };
+ HttpDirectoryHandle.prototype.getDirectoryHandle = async function (name, opts) {
+ opts = opts || {};
+ var url = joinUrl(this._url, name, true);
+ if (opts.create) {
+ var resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'X-ZDDC-Op': 'mkdir' },
+ credentials: 'same-origin'
+ });
+ if (!resp.ok && resp.status !== 200 && resp.status !== 201) {
+ throw new Error('mkdir ' + url + ': ' + resp.status);
+ }
+ }
+ return new HttpDirectoryHandle(url, name);
+ };
+ HttpDirectoryHandle.prototype.removeEntry = async function (name, opts) {
+ opts = opts || {};
+ // Probe listing to discover whether name is a file or directory.
+ var entries;
+ try {
+ entries = await httpListing(this._url);
+ } catch (e) {
+ throw new Error('removeEntry probe failed: ' + e.message);
+ }
+ var match = null;
+ for (var i = 0; i < entries.length; i++) {
+ if (stripSlash(entries[i].name) === name) {
+ match = entries[i];
+ break;
+ }
+ }
+ if (!match) {
+ var err = new Error('NotFoundError: ' + name);
+ err.name = 'NotFoundError';
+ throw err;
+ }
+ if (match.is_dir && !opts.recursive) {
+ // Server doesn't expose a recursive-delete endpoint yet,
+ // and FS Access API requires recursive=true to remove a
+ // non-empty directory anyway. Reject explicitly so the
+ // caller doesn't silently leave a stale tree behind.
+ var derr = new Error('Removing directories over HTTP is not supported');
+ derr.name = 'InvalidStateError';
+ throw derr;
+ }
+ var url = joinUrl(this._url, name, match.is_dir);
+ var resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' });
+ if (!resp.ok && resp.status !== 204) {
+ throw new Error('DELETE ' + url + ': ' + resp.status);
+ }
+ };
+ HttpDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
+ HttpDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
+ HttpDirectoryHandle.prototype.isHttp = true;
+ HttpDirectoryHandle.prototype.url = function () { return this._url; };
+
+ // -----------------------------------------------------------------
+ // Top-level helpers
+ // -----------------------------------------------------------------
+
+ // Strip a trailing tool .html (e.g. classifier.html) from a path
+ // to land on the "directory the tool was opened in".
+ function pathToDir(pathname) {
+ if (!pathname) return '/';
+ if (pathname.endsWith('/')) return pathname;
+ var slash = pathname.lastIndexOf('/');
+ return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
+ }
+
+ // Probe the server-mode root for the current page. Returns:
+ //
+ // { handle: HttpDirectoryHandle, status: 200 } — server reachable, listing returned
+ // { handle: null, status: 403 } — server reachable but listing forbidden
+ // { handle: null, status: 0 } — not http(s), or server unreachable / non-JSON
+ //
+ // Tools that auto-load on startup distinguish 403 (show "no
+ // permission to list this directory" message) from 0 (fall back
+ // to local-mode welcome screen).
+ //
+ // Tool init pattern:
+ // if (location.protocol !== 'file:') {
+ // const r = await zddc.source.detectServerRoot();
+ // if (r.handle) await openDirectory(r.handle);
+ // else if (r.status === 403) showNoPermissionMessage();
+ // else showWelcome();
+ // } else { showWelcome(); }
+ async function detectServerRoot() {
+ if (typeof location === 'undefined') {
+ return { handle: null, status: 0 };
+ }
+ if (location.protocol !== 'http:' && location.protocol !== 'https:') {
+ return { handle: null, status: 0 };
+ }
+ var dirPath = pathToDir(location.pathname);
+ var url = location.origin + dirPath;
+ try {
+ await httpListing(url);
+ } catch (e) {
+ if (e && e.status === 403) {
+ return { handle: null, status: 403 };
+ }
+ return { handle: null, status: 0 };
+ }
+ return {
+ handle: new HttpDirectoryHandle(url, guessNameFromUrl(url)),
+ status: 200,
+ };
+ }
+
+ // Atomic file move. Path arguments are absolute URL paths
+ // (starting with /). Honors the file API's POST /op=move
+ // contract. Returns the new ETag.
+ async function moveFile(srcUrlPath, dstUrlPath, opts) {
+ opts = opts || {};
+ var headers = {
+ 'X-ZDDC-Op': 'move',
+ 'X-ZDDC-Destination': dstUrlPath
+ };
+ if (opts.ifMatch) headers['If-Match'] = opts.ifMatch;
+ var resp = await fetch(srcUrlPath, {
+ method: 'POST',
+ headers: headers,
+ credentials: 'same-origin'
+ });
+ if (!resp.ok) {
+ var body = '';
+ try { body = await resp.text(); } catch (_) { /* ignore */ }
+ throw new Error('move ' + srcUrlPath + ' → ' + dstUrlPath + ': ' + resp.status + ' ' + body);
+ }
+ var et = resp.headers.get('ETag');
+ return et ? et.replace(/"/g, '') : null;
+ }
+
+ // Detect at construction time whether a directory handle is the
+ // HTTP polyfill or a real FS Access API handle. Useful for tools
+ // that want to take the optimized path (e.g. atomic moveFile)
+ // when in HTTP mode rather than the FS-API copy+remove fallback.
+ function isHttpHandle(handle) {
+ return !!(handle && handle.isHttp === true);
+ }
+
+ window.zddc.source = {
+ HttpDirectoryHandle: HttpDirectoryHandle,
+ HttpFileHandle: HttpFileHandle,
+ detectServerRoot: detectServerRoot,
+ moveFile: moveFile,
+ isHttpHandle: isHttpHandle,
+ // Lower-level helpers exposed for tools that want to call the
+ // server directly without going through the polyfill.
+ httpListing: httpListing,
+ joinUrl: joinUrl,
+ stripSlash: stripSlash
+ };
+})();
+
/**
* ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'.
@@ -2765,33 +3134,85 @@ https://github.com/nodeca/pako/blob/main/LICENSE
* Initialize the application
*/
function init() {
+ // Cache DOM elements + wire events first so the welcome screen
+ // (and the HTTP-mode auto-load below) can use them.
+ cacheDOMElements();
+ setupEventListeners();
+
+ // Browser-compatibility branch:
+ // HTTP mode (served by zddc-server) — works everywhere; the
+ // HTTP polyfill stands in for the FS Access API. Auto-load
+ // the directory the page lives in.
+ // Local mode (file://) — requires FS Access API for write
+ // access to the user-picked folder. Show the warning if
+ // the API is missing.
+ if (location.protocol === 'http:' || location.protocol === 'https:') {
+ // Don't disable the picker button — even in HTTP mode the
+ // user might want to add a local folder. But the auto-load
+ // below means the welcome screen usually never shows.
+ (async function () {
+ try {
+ var probe = await window.zddc.source.detectServerRoot();
+ if (probe.handle) {
+ await openDirectory(probe.handle);
+ return;
+ }
+ if (probe.status === 403) {
+ showHttpForbiddenMessage();
+ return;
+ }
+ } catch (err) {
+ console.warn('classifier: server-mode auto-load failed:', err);
+ }
+ // Server-mode probe inconclusive — fall through to welcome.
+ if (!checkBrowserCompatibility()) {
+ showBrowserWarning();
+ return;
+ }
+ showWelcomeScreen();
+ })();
+ return;
+ }
-
- // Check browser compatibility
if (!checkBrowserCompatibility()) {
showBrowserWarning();
return;
}
-
- // Cache DOM elements
- cacheDOMElements();
-
- // Set up event listeners
- setupEventListeners();
-
- // Show welcome screen
showWelcomeScreen();
-
-
}
/**
- * Check if browser supports File System Access API
+ * Check if browser supports File System Access API. Used in local
+ * (file://) mode only — HTTP mode runs through the HTTP polyfill,
+ * which has no browser dependency beyond fetch.
*/
function checkBrowserCompatibility() {
return 'showDirectoryPicker' in window;
}
+ /**
+ * Show a clear "no permission to list" message for HTTP-mode users
+ * who land on a path their ACL doesn't allow them to list. Distinct
+ * from the welcome screen so the user understands why the file tree
+ * is empty rather than wondering if they need to pick a folder.
+ */
+ function showHttpForbiddenMessage() {
+ var screen = document.getElementById('welcomeScreen');
+ if (!screen) return;
+ screen.classList.remove('hidden');
+ var msg = document.createElement('div');
+ msg.className = 'classifier-forbidden-message';
+ msg.style.cssText = 'padding: 1.5rem; max-width: 36rem; margin: 0 auto; text-align: center;';
+ msg.innerHTML =
+ '
No permission to list this directory
' +
+ '
Your account does not have read access to this folder. ' +
+ 'You may still be able to upload files if your role allows it; ' +
+ 'contact the document controller if you believe this is wrong.
';
+ screen.appendChild(msg);
+ var addBtn = document.getElementById('addDirectoryBtn');
+ if (addBtn) addBtn.disabled = true;
+ }
+
/**
* Show browser compatibility warning
*/
@@ -5533,29 +5954,40 @@ https://github.com/nodeca/pako/blob/main/LICENSE
}
}
- // Rename by copying to new name and deleting old (more reliable than move)
+ // Rename. HTTP-backed handles (zddc-server) get the atomic
+ // POST /op=move path — single round-trip, server-side
+ // os.Rename, no risk of half-renamed state. Local FS Access
+ // API handles use copy+remove because the API has no native
+ // rename verb.
const oldFilename = zddc.joinExtension(file.originalFilename, file.extension);
-
try {
- // Get fresh handle for old file
- const oldHandle = await file.folderHandle.getFileHandle(oldFilename);
-
- // Read the file content
- const fileData = await oldHandle.getFile();
-
- // Create new file with new name
- const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true });
- const writable = await newHandle.createWritable();
- await writable.write(fileData);
- await writable.close();
-
- // Delete old file
- await file.folderHandle.removeEntry(oldFilename);
-
- // Update file handle
- file.handle = newHandle;
+ if (window.zddc.source.isHttpHandle(file.folderHandle)) {
+ const folderUrl = file.folderHandle.url();
+ const folderPath = new URL(folderUrl).pathname;
+ const srcPath = folderPath + encodeURIComponent(oldFilename);
+ const dstPath = folderPath + encodeURIComponent(newFilename);
+ await window.zddc.source.moveFile(srcPath, dstPath);
+ file.handle = await file.folderHandle.getFileHandle(newFilename);
+ } else {
+ // Get fresh handle for old file
+ const oldHandle = await file.folderHandle.getFileHandle(oldFilename);
+ // Read the file content
+ const fileData = await oldHandle.getFile();
+
+ // Create new file with new name
+ const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true });
+ const writable = await newHandle.createWritable();
+ await writable.write(fileData);
+ await writable.close();
+
+ // Delete old file
+ await file.folderHandle.removeEntry(oldFilename);
+
+ // Update file handle
+ file.handle = newHandle;
+ }
} catch (err) {
console.error(`Failed to rename file:`, err);
throw err;
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html
index 84461f9..8d098cf 100644
--- a/zddc/internal/apps/embedded/index.html
+++ b/zddc/internal/apps/embedded/index.html
@@ -885,7 +885,7 @@ body {
@@ -2303,6 +2303,375 @@ body.help-open .app-header {
}(typeof window !== 'undefined' ? window : this));
+// shared/zddc-source.js — source abstraction for tools that handle
+// directory trees (classifier, mdedit, transmittal, browse, archive).
+//
+// Two backends:
+//
+// 1. Local — wraps a real FileSystemDirectoryHandle from the
+// File System Access API. Reads + writes go through the
+// FS Access API directly.
+//
+// 2. HTTP — talks to zddc-server's directory listing JSON
+// (Accept: application/json) for reads and the file API
+// (PUT/DELETE/POST X-ZDDC-Op) for writes. Implements a
+// polyfill of the FS Access API surface area the tools
+// use (kind, name, values(), getFileHandle, getDirectoryHandle,
+// removeEntry, getFile, createWritable, queryPermission /
+// requestPermission) so existing code works unchanged.
+//
+// The polyfill makes auto-load possible: when zddc-server serves
+// a tool at //.html, the tool detects HTTP mode at
+// startup, builds an HttpDirectoryHandle for the tool's containing
+// directory, and hands it to the existing openDirectory(handle)
+// flow without ever showing the file picker.
+//
+// Renames inside a tool today are typically done as
+// "write new + remove old". With HTTP-backed handles this becomes
+// PUT + DELETE — non-atomic. Tools that prefer the atomic server
+// MOVE should call window.zddc.source.moveFile(srcUrl, dstUrl)
+// directly instead of going through the polyfill.
+(function () {
+ 'use strict';
+
+ if (!window.zddc) window.zddc = {};
+ var FA = window.FileSystemDirectoryHandle || null;
+
+ // -----------------------------------------------------------------
+ // HTTP file API helpers
+ // -----------------------------------------------------------------
+
+ function joinUrl(base, name, isDir) {
+ if (!base.endsWith('/')) base = base + '/';
+ return base + encodeURIComponent(name) + (isDir ? '/' : '');
+ }
+
+ // Server returns directory entries with a trailing "/" on names.
+ // Strip it for the FS Access API name surface.
+ function stripSlash(name) {
+ return name.endsWith('/') ? name.slice(0, -1) : name;
+ }
+
+ async function httpListing(url) {
+ var resp = await fetch(url, {
+ headers: { 'Accept': 'application/json' },
+ credentials: 'same-origin'
+ });
+ if (!resp.ok) {
+ var err = new Error('listing ' + url + ': HTTP ' + resp.status);
+ err.status = resp.status;
+ throw err;
+ }
+ var data = await resp.json();
+ if (!Array.isArray(data)) {
+ throw new Error('listing ' + url + ': non-array body');
+ }
+ return data;
+ }
+
+ async function httpExists(url) {
+ try {
+ var r = await fetch(url, { method: 'HEAD', credentials: 'same-origin' });
+ return r.ok;
+ } catch (_) {
+ return false;
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // HttpFileHandle — FileSystemFileHandle polyfill
+ // -----------------------------------------------------------------
+
+ function makeFile(blob, name, modTime) {
+ return new File([blob], name, {
+ type: blob.type,
+ lastModified: modTime ? modTime.getTime() : Date.now()
+ });
+ }
+
+ function HttpFileHandle(url, name, size, modTime) {
+ this.kind = 'file';
+ this.name = name;
+ this._url = url;
+ this._size = size || 0;
+ this._modTime = modTime || null;
+ this._etag = null;
+ }
+ HttpFileHandle.prototype.getFile = async function () {
+ var resp = await fetch(this._url, { credentials: 'same-origin' });
+ if (!resp.ok) {
+ throw new Error('GET ' + this._url + ': ' + resp.status);
+ }
+ var etag = resp.headers.get('ETag');
+ if (etag) this._etag = etag.replace(/"/g, '');
+ var lm = resp.headers.get('Last-Modified');
+ var modTime = lm ? new Date(lm) : this._modTime;
+ var blob = await resp.blob();
+ return makeFile(blob, this.name, modTime);
+ };
+ HttpFileHandle.prototype.createWritable = async function () {
+ var chunks = [];
+ var handle = this;
+ return {
+ async write(data) {
+ if (data == null) return;
+ if (typeof data === 'object' && data && 'type' in data && data.type === 'write') {
+ chunks.push(data.data);
+ return;
+ }
+ if (typeof data === 'object' && data && 'type' in data) {
+ // seek/truncate not supported by HTTP backend
+ throw new Error('HttpFileHandle write op not supported: ' + data.type);
+ }
+ chunks.push(data);
+ },
+ async close() {
+ var blob = new Blob(chunks);
+ var resp = await fetch(handle._url, {
+ method: 'PUT',
+ body: blob,
+ credentials: 'same-origin'
+ });
+ if (!resp.ok) {
+ var body = '';
+ try { body = await resp.text(); } catch (_) { /* ignore */ }
+ throw new Error('PUT ' + handle._url + ': ' + resp.status + ' ' + body);
+ }
+ var et = resp.headers.get('ETag');
+ if (et) handle._etag = et.replace(/"/g, '');
+ handle._size = blob.size;
+ },
+ async abort() { chunks = []; }
+ };
+ };
+ HttpFileHandle.prototype.queryPermission = async function () { return 'granted'; };
+ HttpFileHandle.prototype.requestPermission = async function () { return 'granted'; };
+ HttpFileHandle.prototype.isHttp = true;
+ HttpFileHandle.prototype.url = function () { return this._url; };
+
+ // -----------------------------------------------------------------
+ // HttpDirectoryHandle — FileSystemDirectoryHandle polyfill
+ // -----------------------------------------------------------------
+
+ function HttpDirectoryHandle(url, name) {
+ this.kind = 'directory';
+ if (!url.endsWith('/')) url = url + '/';
+ this._url = url;
+ this.name = name || guessNameFromUrl(url);
+ }
+ function guessNameFromUrl(url) {
+ var u = url.replace(/\/+$/, '');
+ var slash = u.lastIndexOf('/');
+ return slash >= 0 ? decodeURIComponent(u.substring(slash + 1)) : u;
+ }
+ HttpDirectoryHandle.prototype.values = function () {
+ var url = this._url;
+ return (async function* () {
+ var entries;
+ try {
+ entries = await httpListing(url);
+ } catch (e) {
+ return;
+ }
+ for (var i = 0; i < entries.length; i++) {
+ var e = entries[i];
+ var rawName = stripSlash(e.name);
+ var childUrl = joinUrl(url, rawName, e.is_dir);
+ if (e.is_dir) {
+ yield new HttpDirectoryHandle(childUrl, rawName);
+ } else {
+ var modTime = e.mod_time ? new Date(e.mod_time) : null;
+ yield new HttpFileHandle(childUrl, rawName, e.size || 0, modTime);
+ }
+ }
+ })();
+ };
+ HttpDirectoryHandle.prototype.entries = function () {
+ var iter = this.values();
+ return (async function* () {
+ for (;;) {
+ var step = await iter.next();
+ if (step.done) return;
+ yield [step.value.name, step.value];
+ }
+ })();
+ };
+ HttpDirectoryHandle.prototype.keys = function () {
+ var iter = this.values();
+ return (async function* () {
+ for (;;) {
+ var step = await iter.next();
+ if (step.done) return;
+ yield step.value.name;
+ }
+ })();
+ };
+ HttpDirectoryHandle.prototype.getFileHandle = async function (name, opts) {
+ opts = opts || {};
+ var url = joinUrl(this._url, name, false);
+ var exists = await httpExists(url);
+ if (!exists && !opts.create) {
+ var err = new Error('NotFoundError: ' + name);
+ err.name = 'NotFoundError';
+ throw err;
+ }
+ return new HttpFileHandle(url, name, 0, null);
+ };
+ HttpDirectoryHandle.prototype.getDirectoryHandle = async function (name, opts) {
+ opts = opts || {};
+ var url = joinUrl(this._url, name, true);
+ if (opts.create) {
+ var resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'X-ZDDC-Op': 'mkdir' },
+ credentials: 'same-origin'
+ });
+ if (!resp.ok && resp.status !== 200 && resp.status !== 201) {
+ throw new Error('mkdir ' + url + ': ' + resp.status);
+ }
+ }
+ return new HttpDirectoryHandle(url, name);
+ };
+ HttpDirectoryHandle.prototype.removeEntry = async function (name, opts) {
+ opts = opts || {};
+ // Probe listing to discover whether name is a file or directory.
+ var entries;
+ try {
+ entries = await httpListing(this._url);
+ } catch (e) {
+ throw new Error('removeEntry probe failed: ' + e.message);
+ }
+ var match = null;
+ for (var i = 0; i < entries.length; i++) {
+ if (stripSlash(entries[i].name) === name) {
+ match = entries[i];
+ break;
+ }
+ }
+ if (!match) {
+ var err = new Error('NotFoundError: ' + name);
+ err.name = 'NotFoundError';
+ throw err;
+ }
+ if (match.is_dir && !opts.recursive) {
+ // Server doesn't expose a recursive-delete endpoint yet,
+ // and FS Access API requires recursive=true to remove a
+ // non-empty directory anyway. Reject explicitly so the
+ // caller doesn't silently leave a stale tree behind.
+ var derr = new Error('Removing directories over HTTP is not supported');
+ derr.name = 'InvalidStateError';
+ throw derr;
+ }
+ var url = joinUrl(this._url, name, match.is_dir);
+ var resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' });
+ if (!resp.ok && resp.status !== 204) {
+ throw new Error('DELETE ' + url + ': ' + resp.status);
+ }
+ };
+ HttpDirectoryHandle.prototype.queryPermission = async function () { return 'granted'; };
+ HttpDirectoryHandle.prototype.requestPermission = async function () { return 'granted'; };
+ HttpDirectoryHandle.prototype.isHttp = true;
+ HttpDirectoryHandle.prototype.url = function () { return this._url; };
+
+ // -----------------------------------------------------------------
+ // Top-level helpers
+ // -----------------------------------------------------------------
+
+ // Strip a trailing tool .html (e.g. classifier.html) from a path
+ // to land on the "directory the tool was opened in".
+ function pathToDir(pathname) {
+ if (!pathname) return '/';
+ if (pathname.endsWith('/')) return pathname;
+ var slash = pathname.lastIndexOf('/');
+ return slash >= 0 ? pathname.substring(0, slash + 1) : '/';
+ }
+
+ // Probe the server-mode root for the current page. Returns:
+ //
+ // { handle: HttpDirectoryHandle, status: 200 } — server reachable, listing returned
+ // { handle: null, status: 403 } — server reachable but listing forbidden
+ // { handle: null, status: 0 } — not http(s), or server unreachable / non-JSON
+ //
+ // Tools that auto-load on startup distinguish 403 (show "no
+ // permission to list this directory" message) from 0 (fall back
+ // to local-mode welcome screen).
+ //
+ // Tool init pattern:
+ // if (location.protocol !== 'file:') {
+ // const r = await zddc.source.detectServerRoot();
+ // if (r.handle) await openDirectory(r.handle);
+ // else if (r.status === 403) showNoPermissionMessage();
+ // else showWelcome();
+ // } else { showWelcome(); }
+ async function detectServerRoot() {
+ if (typeof location === 'undefined') {
+ return { handle: null, status: 0 };
+ }
+ if (location.protocol !== 'http:' && location.protocol !== 'https:') {
+ return { handle: null, status: 0 };
+ }
+ var dirPath = pathToDir(location.pathname);
+ var url = location.origin + dirPath;
+ try {
+ await httpListing(url);
+ } catch (e) {
+ if (e && e.status === 403) {
+ return { handle: null, status: 403 };
+ }
+ return { handle: null, status: 0 };
+ }
+ return {
+ handle: new HttpDirectoryHandle(url, guessNameFromUrl(url)),
+ status: 200,
+ };
+ }
+
+ // Atomic file move. Path arguments are absolute URL paths
+ // (starting with /). Honors the file API's POST /op=move
+ // contract. Returns the new ETag.
+ async function moveFile(srcUrlPath, dstUrlPath, opts) {
+ opts = opts || {};
+ var headers = {
+ 'X-ZDDC-Op': 'move',
+ 'X-ZDDC-Destination': dstUrlPath
+ };
+ if (opts.ifMatch) headers['If-Match'] = opts.ifMatch;
+ var resp = await fetch(srcUrlPath, {
+ method: 'POST',
+ headers: headers,
+ credentials: 'same-origin'
+ });
+ if (!resp.ok) {
+ var body = '';
+ try { body = await resp.text(); } catch (_) { /* ignore */ }
+ throw new Error('move ' + srcUrlPath + ' → ' + dstUrlPath + ': ' + resp.status + ' ' + body);
+ }
+ var et = resp.headers.get('ETag');
+ return et ? et.replace(/"/g, '') : null;
+ }
+
+ // Detect at construction time whether a directory handle is the
+ // HTTP polyfill or a real FS Access API handle. Useful for tools
+ // that want to take the optimized path (e.g. atomic moveFile)
+ // when in HTTP mode rather than the FS-API copy+remove fallback.
+ function isHttpHandle(handle) {
+ return !!(handle && handle.isHttp === true);
+ }
+
+ window.zddc.source = {
+ HttpDirectoryHandle: HttpDirectoryHandle,
+ HttpFileHandle: HttpFileHandle,
+ detectServerRoot: detectServerRoot,
+ moveFile: moveFile,
+ isHttpHandle: isHttpHandle,
+ // Lower-level helpers exposed for tools that want to call the
+ // server directly without going through the polyfill.
+ httpListing: httpListing,
+ joinUrl: joinUrl,
+ stripSlash: stripSlash
+ };
+})();
+
/**
* ZDDC shared theme toggle — light / dark / auto.
* Persists choice to localStorage under 'zddc-theme'.
@@ -4175,43 +4544,48 @@ async function refreshDirectory() {
}
/**
- * Build a synthetic, read-only "file handle" backed by a URL.
- * Implements `getFile()` so the rest of the app (which only needs to read)
- * works without changes. Lacks `createWritable()` — saveFile detects this
- * and routes to a Save-As download.
+ * Surface a clear "no permission to list this directory" message in
+ * the file tree pane when the server returns 403 on the initial
+ * listing. Distinct from "host doesn't serve JSON" so the user
+ * understands why the tree is empty.
*/
-function createServerFileHandle(name, url) {
- let cached = null;
- return {
- kind: 'file',
- name,
- _serverUrl: url,
- _readOnly: true,
- async getFile() {
- if (cached) return cached;
- const resp = await fetch(url, { cache: 'no-cache' });
- if (!resp.ok) throw new Error(`HTTP ${resp.status} fetching ${url}`);
- const lastMod = resp.headers.get('Last-Modified');
- const lastModified = lastMod ? Date.parse(lastMod) : Date.now();
- const blob = await resp.blob();
- cached = new File([blob], name, { type: blob.type, lastModified });
- return cached;
- },
- };
+function showServerForbiddenMessage() {
+ const treeEl = document.getElementById('file-tree');
+ if (!treeEl) return;
+ treeEl.innerHTML =
+ '
' +
+ 'No permission to list this directory.' +
+ '
Your account does not have read access here. ' +
+ 'Contact the document controller if you believe this is wrong.
' +
+ '
';
}
/**
- * Build a synthetic directory handle (read-only) backed by a server URL.
- * Returned for nested entries so existing code paths that probe for `.handle`
- * still work; not currently used for traversal.
+ * Build a CRUD-capable file handle backed by a URL — uses the shared
+ * HTTP polyfill from window.zddc.source. The polyfill's getFile() does
+ * a GET, and createWritable() PUTs bytes back (file API on zddc-server).
+ *
+ * Adds `_serverUrl` for legacy code paths that still expect that field.
+ * Marks `_readOnly: false` so editor.js leaves save buttons enabled.
+ */
+function createServerFileHandle(name, url) {
+ const handle = new window.zddc.source.HttpFileHandle(url, name);
+ handle._serverUrl = url;
+ handle._readOnly = false;
+ return handle;
+}
+
+/**
+ * Build a CRUD-capable directory handle backed by a server URL — uses
+ * the shared HTTP polyfill. Supports values()/entries(), getFileHandle,
+ * getDirectoryHandle({create}), and removeEntry() against the server
+ * file API. _serverUrl/_readOnly are kept for legacy probes.
*/
function createServerDirectoryHandle(name, url) {
- return {
- kind: 'directory',
- name,
- _serverUrl: url,
- _readOnly: true,
- };
+ const handle = new window.zddc.source.HttpDirectoryHandle(url, name);
+ handle._serverUrl = url;
+ handle._readOnly = false;
+ return handle;
}
/**
@@ -4287,8 +4661,16 @@ async function loadServerDirectory() {
// listings (zddc-server / Caddy). On a plain static host the probe fails
// and we must leave "Add Local Directory" visible so the user can still
// load local files.
+ //
+ // 403 means the host is a zddc-server but the user lacks `r` on this
+ // directory (a "no list" permission posture). Show a clear message so
+ // the user understands why the tree is empty.
try {
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
+ if (resp.status === 403) {
+ showServerForbiddenMessage();
+ return;
+ }
if (!resp.ok) return;
const items = await resp.json();
if (!Array.isArray(items)) return;
@@ -4311,13 +4693,13 @@ async function loadServerDirectory() {
entries: {},
};
- // Surface refresh, hide write-only controls. "Add Local Directory"
- // stays visible (de-emphasized via btn--subtle) so the user can
- // switch to a local folder at any time.
+ // Surface refresh. The server now exposes a CRUD file API, so write
+ // controls (new file, save, delete) stay enabled — the polyfill
+ // routes their writes through PUT/DELETE/POST. "Add Local Directory"
+ // is de-emphasized so the user can still load a local folder if they
+ // want, but server-mode is now the default working mode.
const refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) refreshBtn.classList.remove('hidden');
- const newFileRootBtn = document.getElementById('new-file-root');
- if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
const addDirBtn = document.getElementById('addDirectoryBtn');
if (addDirBtn) {
addDirBtn.classList.remove('btn-primary');
@@ -4402,8 +4784,8 @@ function createActionButtons(filePath, type) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'tree-actions';
- // Server mode is read-only: no rename, delete, or new-file actions.
- if (serverSourceMode) return actionsDiv;
+ // Server mode now supports full CRUD via the file API — drop the
+ // legacy short-circuit that hid the rename/delete/new-file actions.
if (type === 'directory') {
// Directory: + (new file) + ✕ (delete)
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html
index 054f175..4f7a1a6 100644
--- a/zddc/internal/apps/embedded/transmittal.html
+++ b/zddc/internal/apps/embedded/transmittal.html
@@ -2192,7 +2192,7 @@ dialog.modal--narrow {