// 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 }; })();