diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 78b1b2e..416ccd8 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2131,7 +2131,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.16-beta · 2026-05-04 · ae75855 + v0.0.16-beta · 2026-05-05 · 3115e38
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 2c02a31..6604590 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -896,7 +896,7 @@ body {
ZDDC Browse - v0.0.16-beta · 2026-05-04 · ae75855 + v0.0.16-beta · 2026-05-05 · 3115e38
diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 177941d..edb1cc1 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1394,7 +1394,7 @@ body.help-open .app-header {
ZDDC Classifier - v0.0.16-beta · 2026-05-04 · ae75855 + v0.0.16-beta · 2026-05-05 · 3115e38
@@ -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 {
ZDDC - v0.0.16-beta · 2026-05-04 · ae75855 + v0.0.16-beta · 2026-05-05 · 3115e38
diff --git a/zddc/internal/apps/embedded/mdedit.html b/zddc/internal/apps/embedded/mdedit.html index e6a13f8..221f95c 100644 --- a/zddc/internal/apps/embedded/mdedit.html +++ b/zddc/internal/apps/embedded/mdedit.html @@ -1792,7 +1792,7 @@ body.help-open .app-header {
ZDDC Markdown - v0.0.16-beta · 2026-05-04 · ae75855 + v0.0.16-beta · 2026-05-05 · 3115e38
@@ -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 {
ZDDC Transmittal - v0.0.16-beta · 2026-05-04 · ae75855 + v0.0.16-beta · 2026-05-05 · 3115e38
JavaScript not available