chore(embedded): cut v0.0.16-beta with file API + RBAC
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
Refreshes the //go:embed bytes off 3115e38. Dev image (ZDDC_REF=main)
now ships the file API and verb-based RBAC.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3115e388fc
commit
2b17c9f030
8 changed files with 1300 additions and 84 deletions
|
|
@ -2131,7 +2131,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · ae75855</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-05 · 3115e38</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -896,7 +896,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · ae75855</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-05 · 3115e38</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1394,7 +1394,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · ae75855</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-05 · 3115e38</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
@ -2105,6 +2105,375 @@ https://github.com/nodeca/pako/blob/main/LICENSE
|
||||||
};
|
};
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(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 /<dir>/<tool>.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.
|
* ZDDC shared theme toggle — light / dark / auto.
|
||||||
* Persists choice to localStorage under 'zddc-theme'.
|
* Persists choice to localStorage under 'zddc-theme'.
|
||||||
|
|
@ -2765,33 +3134,85 @@ https://github.com/nodeca/pako/blob/main/LICENSE
|
||||||
* Initialize the application
|
* Initialize the application
|
||||||
*/
|
*/
|
||||||
function init() {
|
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()) {
|
if (!checkBrowserCompatibility()) {
|
||||||
showBrowserWarning();
|
showBrowserWarning();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache DOM elements
|
|
||||||
cacheDOMElements();
|
|
||||||
|
|
||||||
// Set up event listeners
|
|
||||||
setupEventListeners();
|
|
||||||
|
|
||||||
// Show welcome screen
|
|
||||||
showWelcomeScreen();
|
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() {
|
function checkBrowserCompatibility() {
|
||||||
return 'showDirectoryPicker' in window;
|
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 =
|
||||||
|
'<h2 style="margin-bottom: 0.75rem;">No permission to list this directory</h2>' +
|
||||||
|
'<p>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.</p>';
|
||||||
|
screen.appendChild(msg);
|
||||||
|
var addBtn = document.getElementById('addDirectoryBtn');
|
||||||
|
if (addBtn) addBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show browser compatibility warning
|
* 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);
|
const oldFilename = zddc.joinExtension(file.originalFilename, file.extension);
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get fresh handle for old file
|
if (window.zddc.source.isHttpHandle(file.folderHandle)) {
|
||||||
const oldHandle = await file.folderHandle.getFileHandle(oldFilename);
|
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
|
// Read the file content
|
||||||
const fileData = await oldHandle.getFile();
|
const fileData = await oldHandle.getFile();
|
||||||
|
|
||||||
// Create new file with new name
|
// Create new file with new name
|
||||||
const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true });
|
const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true });
|
||||||
const writable = await newHandle.createWritable();
|
const writable = await newHandle.createWritable();
|
||||||
await writable.write(fileData);
|
await writable.write(fileData);
|
||||||
await writable.close();
|
await writable.close();
|
||||||
|
|
||||||
// Delete old file
|
// Delete old file
|
||||||
await file.folderHandle.removeEntry(oldFilename);
|
await file.folderHandle.removeEntry(oldFilename);
|
||||||
|
|
||||||
// Update file handle
|
|
||||||
file.handle = newHandle;
|
|
||||||
|
|
||||||
|
// Update file handle
|
||||||
|
file.handle = newHandle;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to rename file:`, err);
|
console.error(`Failed to rename file:`, err);
|
||||||
throw err;
|
throw err;
|
||||||
|
|
|
||||||
|
|
@ -885,7 +885,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · ae75855</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-05 · 3115e38</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -1792,7 +1792,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Markdown</span>
|
<span class="app-header__title">ZDDC Markdown</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · ae75855</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-05 · 3115e38</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
@ -2303,6 +2303,375 @@ body.help-open .app-header {
|
||||||
|
|
||||||
}(typeof window !== 'undefined' ? window : this));
|
}(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 /<dir>/<tool>.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.
|
* ZDDC shared theme toggle — light / dark / auto.
|
||||||
* Persists choice to localStorage under 'zddc-theme'.
|
* 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.
|
* Surface a clear "no permission to list this directory" message in
|
||||||
* Implements `getFile()` so the rest of the app (which only needs to read)
|
* the file tree pane when the server returns 403 on the initial
|
||||||
* works without changes. Lacks `createWritable()` — saveFile detects this
|
* listing. Distinct from "host doesn't serve JSON" so the user
|
||||||
* and routes to a Save-As download.
|
* understands why the tree is empty.
|
||||||
*/
|
*/
|
||||||
function createServerFileHandle(name, url) {
|
function showServerForbiddenMessage() {
|
||||||
let cached = null;
|
const treeEl = document.getElementById('file-tree');
|
||||||
return {
|
if (!treeEl) return;
|
||||||
kind: 'file',
|
treeEl.innerHTML =
|
||||||
name,
|
'<div class="server-forbidden-message" style="padding: 1rem; color: var(--text-muted, #555); font-size: 0.875rem;">' +
|
||||||
_serverUrl: url,
|
'<strong>No permission to list this directory.</strong>' +
|
||||||
_readOnly: true,
|
'<p style="margin: 0.5rem 0 0;">Your account does not have read access here. ' +
|
||||||
async getFile() {
|
'Contact the document controller if you believe this is wrong.</p>' +
|
||||||
if (cached) return cached;
|
'</div>';
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a synthetic directory handle (read-only) backed by a server URL.
|
* Build a CRUD-capable file handle backed by a URL — uses the shared
|
||||||
* Returned for nested entries so existing code paths that probe for `.handle`
|
* HTTP polyfill from window.zddc.source. The polyfill's getFile() does
|
||||||
* still work; not currently used for traversal.
|
* 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) {
|
function createServerDirectoryHandle(name, url) {
|
||||||
return {
|
const handle = new window.zddc.source.HttpDirectoryHandle(url, name);
|
||||||
kind: 'directory',
|
handle._serverUrl = url;
|
||||||
name,
|
handle._readOnly = false;
|
||||||
_serverUrl: url,
|
return handle;
|
||||||
_readOnly: true,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -4287,8 +4661,16 @@ async function loadServerDirectory() {
|
||||||
// listings (zddc-server / Caddy). On a plain static host the probe fails
|
// 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
|
// and we must leave "Add Local Directory" visible so the user can still
|
||||||
// load local files.
|
// 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 {
|
try {
|
||||||
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
const resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-cache' });
|
||||||
|
if (resp.status === 403) {
|
||||||
|
showServerForbiddenMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const items = await resp.json();
|
const items = await resp.json();
|
||||||
if (!Array.isArray(items)) return;
|
if (!Array.isArray(items)) return;
|
||||||
|
|
@ -4311,13 +4693,13 @@ async function loadServerDirectory() {
|
||||||
entries: {},
|
entries: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Surface refresh, hide write-only controls. "Add Local Directory"
|
// Surface refresh. The server now exposes a CRUD file API, so write
|
||||||
// stays visible (de-emphasized via btn--subtle) so the user can
|
// controls (new file, save, delete) stay enabled — the polyfill
|
||||||
// switch to a local folder at any time.
|
// 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');
|
const refreshBtn = document.getElementById('refreshHeaderBtn');
|
||||||
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
if (refreshBtn) refreshBtn.classList.remove('hidden');
|
||||||
const newFileRootBtn = document.getElementById('new-file-root');
|
|
||||||
if (newFileRootBtn) newFileRootBtn.classList.add('hidden');
|
|
||||||
const addDirBtn = document.getElementById('addDirectoryBtn');
|
const addDirBtn = document.getElementById('addDirectoryBtn');
|
||||||
if (addDirBtn) {
|
if (addDirBtn) {
|
||||||
addDirBtn.classList.remove('btn-primary');
|
addDirBtn.classList.remove('btn-primary');
|
||||||
|
|
@ -4402,8 +4784,8 @@ function createActionButtons(filePath, type) {
|
||||||
const actionsDiv = document.createElement('div');
|
const actionsDiv = document.createElement('div');
|
||||||
actionsDiv.className = 'tree-actions';
|
actionsDiv.className = 'tree-actions';
|
||||||
|
|
||||||
// Server mode is read-only: no rename, delete, or new-file actions.
|
// Server mode now supports full CRUD via the file API — drop the
|
||||||
if (serverSourceMode) return actionsDiv;
|
// legacy short-circuit that hid the rename/delete/new-file actions.
|
||||||
|
|
||||||
if (type === 'directory') {
|
if (type === 'directory') {
|
||||||
// Directory: + (new file) + ✕ (delete)
|
// Directory: + (new file) + ✕ (delete)
|
||||||
|
|
|
||||||
|
|
@ -2192,7 +2192,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · ae75855</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-05 · 3115e38</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
@ -3123,6 +3123,375 @@ https://github.com/nodeca/pako/blob/main/LICENSE
|
||||||
};
|
};
|
||||||
})(typeof window !== 'undefined' ? window : globalThis);
|
})(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 /<dir>/<tool>.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.
|
* ZDDC shared theme toggle — light / dark / auto.
|
||||||
* Persists choice to localStorage under 'zddc-theme'.
|
* Persists choice to localStorage under 'zddc-theme'.
|
||||||
|
|
@ -6696,6 +7065,39 @@ https://github.com/nodeca/pako/blob/main/LICENSE
|
||||||
filesModule.bindActionButtons();
|
filesModule.bindActionButtons();
|
||||||
filesModule.setupTableEditing();
|
filesModule.setupTableEditing();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-load when served by zddc-server: the page lives at
|
||||||
|
// /<...>/Staging/<folder>/transmittal.html and that folder IS the
|
||||||
|
// working transmittal. Build an HTTP polyfill handle for it,
|
||||||
|
// assign it as the selected directory, and run the same scan
|
||||||
|
// pipeline the "Add Directory" button does.
|
||||||
|
//
|
||||||
|
// A 403 on the listing probe means the user can't list this folder —
|
||||||
|
// transmittal needs `r` at minimum, so show a clear message rather
|
||||||
|
// than silently leaving the editor empty.
|
||||||
|
app.registerInit(async function () {
|
||||||
|
if (typeof location === 'undefined') { return; }
|
||||||
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') { return; }
|
||||||
|
if (app.data.selectedDirHandle) { return; }
|
||||||
|
try {
|
||||||
|
var probe = await window.zddc.source.detectServerRoot();
|
||||||
|
if (probe.handle) {
|
||||||
|
app.data.selectedDirHandle = probe.handle;
|
||||||
|
updateDirectoryIndicator(probe.handle.name);
|
||||||
|
// Run the same flow as the "Add Directory" button, minus
|
||||||
|
// the click-event plumbing — selectDirectory will skip the
|
||||||
|
// picker because selectedDirHandle is already set.
|
||||||
|
await selectDirectory({ currentTarget: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (probe.status === 403) {
|
||||||
|
console.warn('[transmittal] no permission to list directory; transmittal needs `r` at minimum');
|
||||||
|
updateDirectoryIndicator('— no permission to list this directory —');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[transmittal] HTTP auto-load failed:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
})(window.transmittalApp);
|
})(window.transmittalApp);
|
||||||
|
|
||||||
(function (app) {
|
(function (app) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.16-beta · 2026-05-04 · ae75855
|
archive=v0.0.16-beta · 2026-05-05 · 3115e38
|
||||||
transmittal=v0.0.16-beta · 2026-05-04 · ae75855
|
transmittal=v0.0.16-beta · 2026-05-05 · 3115e38
|
||||||
classifier=v0.0.16-beta · 2026-05-04 · ae75855
|
classifier=v0.0.16-beta · 2026-05-05 · 3115e38
|
||||||
mdedit=v0.0.16-beta · 2026-05-04 · ae75855
|
mdedit=v0.0.16-beta · 2026-05-05 · 3115e38
|
||||||
landing=v0.0.16-beta · 2026-05-04 · ae75855
|
landing=v0.0.16-beta · 2026-05-05 · 3115e38
|
||||||
form=v0.0.16-beta · 2026-05-04 · ae75855
|
form=v0.0.16-beta · 2026-05-05 · 3115e38
|
||||||
browse=v0.0.16-beta · 2026-05-04 · ae75855
|
browse=v0.0.16-beta · 2026-05-05 · 3115e38
|
||||||
|
|
|
||||||
|
|
@ -741,7 +741,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="form-title">ZDDC Form</span>
|
<span class="app-header__title" id="form-title">ZDDC Form</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-04 · ae75855</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.16-beta · 2026-05-05 · 3115e38</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue