From ab552c8c1bddae702276bacea8a7981b11353605 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Wed, 13 May 2026 11:14:52 -0500 Subject: [PATCH] chore(embedded): cut v0.0.17-beta --- zddc/internal/apps/embedded/archive.html | 2 +- zddc/internal/apps/embedded/browse.html | 420 ++++++++++++++++++- zddc/internal/apps/embedded/classifier.html | 2 +- zddc/internal/apps/embedded/index.html | 2 +- zddc/internal/apps/embedded/transmittal.html | 2 +- zddc/internal/apps/embedded/versions.txt | 14 +- zddc/internal/handler/tables.html | 2 +- 7 files changed, 431 insertions(+), 13 deletions(-) diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index 455a273..f4c65e8 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2470,7 +2470,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel + v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 8d29d89..9c9c880 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -1657,7 +1657,7 @@ html, body {
ZDDC Browse - v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel + v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
@@ -4849,6 +4849,424 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }; })(typeof window !== 'undefined' ? window : this); +// shared/zddc-source.js — source abstraction for tools that handle +// directory trees (classifier, 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); + // Listing entries can carry an explicit URL for virtual + // links (e.g. the reviewing-aggregator's received/+staged/ + // entries point to canonical archive/+staging paths). + // Use it when present so navigation follows the listing's + // own routing rather than computing a synthetic child URL + // off the parent. Caddy-shape listings don't set url + // (or set it to a relative form) — joinUrl handles those. + var childUrl; + if (e.url && /^https?:\/\/|^\//.test(e.url)) { + // Absolute or root-relative: use as-is, normalised against origin. + var u = e.url; + if (u[0] === '/') { + u = location.origin + u; + } + childUrl = u; + } else { + 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); + } + + // downloadConverted fetches a server-side MD→{docx,html,pdf} + // conversion and triggers a browser download with a clean filename. + // srcUrl points at the .md source on the server. fmt is one of + // "docx" | "html" | "pdf". The server response status maps to a + // friendly error message for the caller to surface (toast / status). + async function downloadConverted(srcUrl, fileName, fmt) { + var resp = await fetch(srcUrl + '?convert=' + encodeURIComponent(fmt), + { credentials: 'same-origin' }); + if (!resp.ok) { + var msg; + if (resp.status === 503) msg = 'Conversion service unavailable on this server.'; + else if (resp.status === 422) msg = 'Conversion failed — the source may be malformed.'; + else if (resp.status === 504) msg = 'Conversion timed out.'; + else msg = 'Conversion failed (HTTP ' + resp.status + ').'; + // Append server-supplied body text if it adds detail. + try { + var detail = await resp.text(); + if (detail && detail.length < 400) msg += ' ' + detail.trim(); + } catch (_) { /* ignore */ } + throw new Error(msg); + } + var blob = await resp.blob(); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = fileName.replace(/\.md$/i, '') + '.' + fmt; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000); + } + + window.zddc.source = { + HttpDirectoryHandle: HttpDirectoryHandle, + HttpFileHandle: HttpFileHandle, + detectServerRoot: detectServerRoot, + moveFile: moveFile, + isHttpHandle: isHttpHandle, + downloadConverted: downloadConverted, + // Lower-level helpers exposed for tools that want to call the + // server directly without going through the polyfill. + httpListing: httpListing, + joinUrl: joinUrl, + stripSlash: stripSlash + }; +})(); + // Bootstrap window.app for the browse tool. Mirrors the convention // used by every other ZDDC tool — ./build's CSS/JS concat order means // this file runs FIRST inside the IIFE-of-IIFEs. diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 4ab211d..c3714fe 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1681,7 +1681,7 @@ body.help-open .app-header {
ZDDC Classifier - v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel + v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index ff555ea..e2fd817 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -1424,7 +1424,7 @@ body {
ZDDC - v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel + v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index d2927b0..c2139b9 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2523,7 +2523,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel + v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b
JavaScript not available