Compare commits

...

3 commits

Author SHA1 Message Date
ab552c8c1b chore(embedded): cut v0.0.17-beta
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 6s
2026-05-13 11:14:52 -05:00
6dca32b282 chore(build-label): drop three-word slug — use full timestamp + short SHA for alpha/beta cuts
The on-page label for `--release alpha|beta` cuts used to read
`vX.Y.Z-channel · YYYY-MM-DD · word-word-word`. The three-word slug
(derived deterministically from the source SHA via shared/build-words.txt)
was meant to make "is this the same build you emailed about?" a glance
check, but the cute-words layer turned out to be more confusing than
clarifying — testers prefer a real timestamp + short SHA.

New label shape, identical to plain dev builds:

  vX.Y.Z-channel · YYYY-MM-DD HH:MM:SS · <short-source-sha>[-dirty]

Helper renamed from _source_commit_slug to _source_commit_short_sha,
returning the short SHA of the source commit (walking past any
`chore(embedded): cut …` commit at HEAD so a re-cut on unchanged
source produces the same SHA). The wordlist file is no longer
referenced and is removed; tests/build-label.spec.js's regex
simplified to require the full timestamp + SHA form.
2026-05-13 11:14:35 -05:00
fb27e47866 fix(browse): bundle shared/zddc-source.js so downloadConverted is available
The markdown editor's DOCX/HTML/PDF download buttons silently no-op'd
because the gate `typeof window.zddc.source.downloadConverted ===
'function'` always failed: browse rolls its own server-mode detection
(state.source === 'server' + node.url) and never needed the shared/
zddc-source.js polyfill before. The new download helper lives on
window.zddc.source, so browse needs to bundle it.

Adds ../shared/zddc-source.js to browse/build.sh's concat_files list,
right after preview-lib.js. Bundle gains ~12 KB; tools that don't
need the polyfill (browse doesn't use HttpDirectoryHandle directly)
pay a small footprint cost in exchange for getting the helper.
2026-05-13 11:14:17 -05:00
11 changed files with 457 additions and 358 deletions

View file

@ -49,6 +49,7 @@ concat_files \
"../shared/logo.js" \
"../shared/help.js" \
"../shared/preview-lib.js" \
"../shared/zddc-source.js" \
"js/init.js" \
"js/loader.js" \
"js/tree.js" \

View file

@ -163,40 +163,12 @@ _source_commit_ref() {
echo "$_ref"
}
# Three-word slug derived deterministically from the source commit's
# full SHA. Same source state → same slug; survives embedded auto-
# commits by deferring to _source_commit_ref.
#
# Three 4-hex-char chunks of the SHA are taken modulo the wordlist
# length (shared/build-words.txt). 283 words × 3 = 22M combinations,
# enough that a tester eyeballing two beta builds can tell at a glance
# whether they're running the same source.
#
# Format: "word-word-word" (lowercase, hyphen-separated).
_source_commit_slug() {
# Short SHA of the underlying source commit (skipping past embedded
# auto-commits — see _source_commit_ref). Same source state → same SHA
# even after a `chore(embedded): cut …` commit has landed on top.
_source_commit_short_sha() {
_ref=$(_source_commit_ref)
_full_sha=$(git -C "$root_dir" rev-parse "$_ref" 2>/dev/null || echo "0000000000000000")
# build-lib.sh is sourced from each tool's build.sh as
# "$root_dir/../shared/build-lib.sh"; the wordlist lives next to
# this file in the same shared/ directory.
_words_file="$root_dir/../shared/build-words.txt"
if [ ! -f "$_words_file" ]; then
# Fall back to plain short SHA if the wordlist is missing
# (e.g. running from a checkout that predates this feature).
git -C "$root_dir" rev-parse --short=7 "$_ref" 2>/dev/null || echo "unknown"
return
fi
_wc=$(awk 'END{print NR}' "$_words_file")
_h1=$(echo "$_full_sha" | cut -c 1-4)
_h2=$(echo "$_full_sha" | cut -c 5-8)
_h3=$(echo "$_full_sha" | cut -c 9-12)
_n1=$(( (0x$_h1 % _wc) + 1 ))
_n2=$(( (0x$_h2 % _wc) + 1 ))
_n3=$(( (0x$_h3 % _wc) + 1 ))
_w1=$(awk -v n="$_n1" 'NR==n{print; exit}' "$_words_file")
_w2=$(awk -v n="$_n2" 'NR==n{print; exit}' "$_words_file")
_w3=$(awk -v n="$_n3" 'NR==n{print; exit}' "$_words_file")
echo "${_w1}-${_w2}-${_w3}"
git -C "$root_dir" rev-parse --short=7 "$_ref" 2>/dev/null || echo "unknown"
}
# Compute build label and channel. Reads positional args:
@ -217,9 +189,11 @@ _source_commit_slug() {
#
# HTML tools do NOT tag alpha/beta cuts (consistent with current
# behavior — alpha and beta artifacts are mutable files, not immutable
# per-build snapshots). The label distinguishes plain dev builds from
# explicit channel cuts via the timestamp granularity (full ts + dirty
# marker for plain builds vs. date-only for `--release alpha|beta`).
# per-build snapshots). Plain dev builds and `--release alpha|beta`
# cuts share the same on-page label format — full UTC timestamp + short
# source SHA — so testers see one rendering shape regardless of how the
# build was produced. A plain dev build may carry a "-dirty" SHA suffix
# when the working tree has uncommitted changes; release cuts don't.
compute_build_label() {
_tool="$1"
_flag="${2:-}"
@ -254,20 +228,12 @@ compute_build_label() {
case "$_arg" in
alpha | beta)
channel="$_arg"
_date=$(date -u +"%Y-%m-%d")
# Three-word slug derived from the *source* SHA — see
# _source_commit_slug. Two builds carrying the same slug
# were cut from the same source state, so a tester can
# glance at the on-page label and confirm "yes, this is
# the cobalt-otter-meadow build I was emailed about" without
# having to look up a SHA. The slug is invariant across
# the embedded auto-commit step (build:971-995) so a re-cut
# on unchanged source produces the same slug, no spurious
# commit. Full source SHA is available via the binary's
# `--version` output and the chart appVersion for any case
# where exact provenance matters.
_slug=$(_source_commit_slug)
build_label="v${_next_stable}-${channel} · ${_date} · ${_slug}"
# Full UTC timestamp + short source SHA — same format as
# plain dev builds. _source_commit_short_sha walks past
# any `chore(embedded): cut …` auto-commit at HEAD so a
# re-cut on unchanged source produces the same SHA.
_sha=$(_source_commit_short_sha)
build_label="v${_next_stable}-${channel} · ${build_timestamp} · ${_sha}"
_emit_build_label_sidecar "$_tool"
return 0
;;

View file

@ -1,283 +0,0 @@
acorn
agate
alder
almond
amber
anchor
angle
antler
apple
apricot
arbor
arch
arrow
ashen
aspen
atlas
attic
auburn
aurora
azure
badge
bagel
ballad
balm
banner
barn
basil
basin
basket
beacon
beam
bear
beech
beetle
berry
biscuit
blanket
blossom
boat
bonnet
book
boulder
brass
bread
breeze
brick
bridge
brook
broom
bubble
bucket
buckle
button
cabin
cable
cactus
cake
candle
canvas
canyon
carpet
cart
cedar
chair
chalk
chamber
chapel
charm
cherry
chime
cinder
citrus
clay
cliff
cloud
clover
cluster
coal
coast
cobble
cocoa
coin
comet
compass
copper
cottage
cove
cradle
crane
crater
creek
crescent
cricket
crystal
daisy
dawn
deer
denim
diamond
ditch
dock
dolphin
dome
drift
drum
dune
dusk
eagle
ember
emerald
fable
falcon
feather
fence
fern
fiddle
finch
flag
flame
flask
flax
fleece
flint
flute
foam
forest
fork
fort
fountain
fox
garden
garnet
gear
ginger
glacier
glade
glass
globe
gold
granite
grass
grove
hammer
harbor
harp
harvest
hazel
hearth
heron
hill
hinge
hive
holly
hook
horizon
horn
hut
indigo
iris
iron
island
ivory
ivy
jade
jasper
juniper
kayak
kelp
kettle
key
kiln
kite
lake
lamp
lantern
lark
laurel
leaf
ledge
lemon
lens
lilac
lily
linen
locust
log
loom
lotus
maple
marble
marigold
marsh
mast
meadow
melon
mesa
meteor
mint
mirror
mist
mitten
moon
moor
moss
mug
nectar
nest
niche
nickel
nimbus
oak
oasis
ocean
onyx
opal
orbit
orchard
otter
owl
oyster
paddle
palm
panel
parrot
patio
pearl
pebble
pelican
pepper
perch
pier
pillow
pine
pipe
plank
plaza
pocket
pond
poplar
port
prairie
prism
puddle
quarry
quartz
quill
quilt
rabbit
raft
rain
ranch
raven
reed
reef
ribbon
ridge
river
robin
rock
rope
saddle
sage
sail
salt
sand
sapphire
sash
satin
scarf
sea
seal
seed
shawl
shield
shore
shrub
silver
silo
slope
smoke
snail
snow
spire

View file

@ -45,18 +45,15 @@ for (const tool of tools) {
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
expect(match, 'build-timestamp element must have text content').toBeTruthy();
const label = match[1];
// Plain dev builds and --release alpha|beta carry the next-stable
// target as a pre-release suffix; the trailing field differs:
// plain dev → full timestamp + short SHA (+ "-dirty"):
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6-dirty"
// --release alpha|beta → date-only + a three-word source-SHA slug:
// "v0.0.17-beta · 2026-04-29 · candle-mast-pearl"
// stable cut → bare version: "v0.0.17"
const sha = '[0-9a-f]+(?:-dirty)?'; // plain dev build
const slug = '[a-z]+-[a-z]+-[a-z]+'; // --release alpha|beta
const isChannel = new RegExp(
`^v\\d+\\.\\d+\\.\\d+-(?:alpha|beta) · 20\\d\\d-\\d\\d-\\d\\d(?: \\d\\d:\\d\\d:\\d\\d)? · (?:${sha}|${slug})$`
).test(label);
// Plain dev builds and --release alpha|beta share one label
// shape — full UTC timestamp + short source SHA (with
// optional -dirty marker on plain dev when the tree is
// uncommitted):
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6"
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6-dirty"
// "v0.0.17-beta · 2026-05-13 15:29:05 · e7f6334"
// Stable cuts emit a bare version: "v0.0.17"
const isChannel = /^v\d+\.\d+\.\d+-(?:alpha|beta) · 20\d\d-\d\d-\d\d \d\d:\d\d:\d\d · [0-9a-f]+(?:-dirty)?$/.test(label);
const isVersion = /^v\d+\.\d+\.\d+$/.test(label);
expect(isChannel || isVersion,
`Expected channel or version label, got: "${label}"`

View file

@ -2470,7 +2470,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -1657,7 +1657,7 @@ html, body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b</span></span>
</div>
<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"></button>
@ -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 /<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);
// 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.

View file

@ -1681,7 +1681,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b</span></span>
</div>
<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>

View file

@ -1424,7 +1424,7 @@ body {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b</span></span>
</div>
</div>
<div class="header-right">

View file

@ -2523,7 +2523,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b</span></span>
</div>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
transmittal=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
classifier=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
landing=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
form=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
tables=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
browse=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
archive=v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b
transmittal=v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b
classifier=v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b
landing=v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
form=v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
tables=v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
browse=v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b</span></span>
</div>
</div>
<div class="header-right">