Compare commits
No commits in common. "ab552c8c1bddae702276bacea8a7981b11353605" and "320c5d09ab822607adc7a8eaf1ac9dc65926f317" have entirely different histories.
ab552c8c1b
...
320c5d09ab
11 changed files with 358 additions and 457 deletions
|
|
@ -49,7 +49,6 @@ concat_files \
|
||||||
"../shared/logo.js" \
|
"../shared/logo.js" \
|
||||||
"../shared/help.js" \
|
"../shared/help.js" \
|
||||||
"../shared/preview-lib.js" \
|
"../shared/preview-lib.js" \
|
||||||
"../shared/zddc-source.js" \
|
|
||||||
"js/init.js" \
|
"js/init.js" \
|
||||||
"js/loader.js" \
|
"js/loader.js" \
|
||||||
"js/tree.js" \
|
"js/tree.js" \
|
||||||
|
|
|
||||||
|
|
@ -163,12 +163,40 @@ _source_commit_ref() {
|
||||||
echo "$_ref"
|
echo "$_ref"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Short SHA of the underlying source commit (skipping past embedded
|
# Three-word slug derived deterministically from the source commit's
|
||||||
# auto-commits — see _source_commit_ref). Same source state → same SHA
|
# full SHA. Same source state → same slug; survives embedded auto-
|
||||||
# even after a `chore(embedded): cut …` commit has landed on top.
|
# commits by deferring to _source_commit_ref.
|
||||||
_source_commit_short_sha() {
|
#
|
||||||
|
# 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() {
|
||||||
_ref=$(_source_commit_ref)
|
_ref=$(_source_commit_ref)
|
||||||
git -C "$root_dir" rev-parse --short=7 "$_ref" 2>/dev/null || echo "unknown"
|
_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}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Compute build label and channel. Reads positional args:
|
# Compute build label and channel. Reads positional args:
|
||||||
|
|
@ -189,11 +217,9 @@ _source_commit_short_sha() {
|
||||||
#
|
#
|
||||||
# HTML tools do NOT tag alpha/beta cuts (consistent with current
|
# HTML tools do NOT tag alpha/beta cuts (consistent with current
|
||||||
# behavior — alpha and beta artifacts are mutable files, not immutable
|
# behavior — alpha and beta artifacts are mutable files, not immutable
|
||||||
# per-build snapshots). Plain dev builds and `--release alpha|beta`
|
# per-build snapshots). The label distinguishes plain dev builds from
|
||||||
# cuts share the same on-page label format — full UTC timestamp + short
|
# explicit channel cuts via the timestamp granularity (full ts + dirty
|
||||||
# source SHA — so testers see one rendering shape regardless of how the
|
# marker for plain builds vs. date-only for `--release alpha|beta`).
|
||||||
# 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() {
|
compute_build_label() {
|
||||||
_tool="$1"
|
_tool="$1"
|
||||||
_flag="${2:-}"
|
_flag="${2:-}"
|
||||||
|
|
@ -228,12 +254,20 @@ compute_build_label() {
|
||||||
case "$_arg" in
|
case "$_arg" in
|
||||||
alpha | beta)
|
alpha | beta)
|
||||||
channel="$_arg"
|
channel="$_arg"
|
||||||
# Full UTC timestamp + short source SHA — same format as
|
_date=$(date -u +"%Y-%m-%d")
|
||||||
# plain dev builds. _source_commit_short_sha walks past
|
# Three-word slug derived from the *source* SHA — see
|
||||||
# any `chore(embedded): cut …` auto-commit at HEAD so a
|
# _source_commit_slug. Two builds carrying the same slug
|
||||||
# re-cut on unchanged source produces the same SHA.
|
# were cut from the same source state, so a tester can
|
||||||
_sha=$(_source_commit_short_sha)
|
# glance at the on-page label and confirm "yes, this is
|
||||||
build_label="v${_next_stable}-${channel} · ${build_timestamp} · ${_sha}"
|
# 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}"
|
||||||
_emit_build_label_sidecar "$_tool"
|
_emit_build_label_sidecar "$_tool"
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
|
|
||||||
283
shared/build-words.txt
Normal file
283
shared/build-words.txt
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
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
|
||||||
|
|
@ -45,15 +45,18 @@ for (const tool of tools) {
|
||||||
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
|
const match = html.match(/class="build-timestamp">(?:<span[^>]*>)?([^<]+?)(?:<\/span>)?</);
|
||||||
expect(match, 'build-timestamp element must have text content').toBeTruthy();
|
expect(match, 'build-timestamp element must have text content').toBeTruthy();
|
||||||
const label = match[1];
|
const label = match[1];
|
||||||
// Plain dev builds and --release alpha|beta share one label
|
// Plain dev builds and --release alpha|beta carry the next-stable
|
||||||
// shape — full UTC timestamp + short source SHA (with
|
// target as a pre-release suffix; the trailing field differs:
|
||||||
// optional -dirty marker on plain dev when the tree is
|
// plain dev → full timestamp + short SHA (+ "-dirty"):
|
||||||
// uncommitted):
|
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6-dirty"
|
||||||
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6"
|
// --release alpha|beta → date-only + a three-word source-SHA slug:
|
||||||
// "v0.0.17-alpha · 2026-04-29 00:50:17 · 714faf6-dirty"
|
// "v0.0.17-beta · 2026-04-29 · candle-mast-pearl"
|
||||||
// "v0.0.17-beta · 2026-05-13 15:29:05 · e7f6334"
|
// stable cut → bare version: "v0.0.17"
|
||||||
// Stable cuts emit a bare version: "v0.0.17"
|
const sha = '[0-9a-f]+(?:-dirty)?'; // plain dev build
|
||||||
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 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);
|
||||||
const isVersion = /^v\d+\.\d+\.\d+$/.test(label);
|
const isVersion = /^v\d+\.\d+\.\d+$/.test(label);
|
||||||
expect(isChannel || isVersion,
|
expect(isChannel || isVersion,
|
||||||
`Expected channel or version label, got: "${label}"`
|
`Expected channel or version label, got: "${label}"`
|
||||||
|
|
|
||||||
|
|
@ -2470,7 +2470,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.17-beta · 2026-05-13 16:14:42 · 6dca32b</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</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">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1657,7 +1657,7 @@ html, 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.17-beta · 2026-05-13 16:14:43 · 6dca32b</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</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">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
@ -4849,424 +4849,6 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr
|
||||||
};
|
};
|
||||||
})(typeof window !== 'undefined' ? window : this);
|
})(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
|
// Bootstrap window.app for the browse tool. Mirrors the convention
|
||||||
// used by every other ZDDC tool — ./build's CSS/JS concat order means
|
// used by every other ZDDC tool — ./build's CSS/JS concat order means
|
||||||
// this file runs FIRST inside the IIFE-of-IIFEs.
|
// this file runs FIRST inside the IIFE-of-IIFEs.
|
||||||
|
|
|
||||||
|
|
@ -1681,7 +1681,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.17-beta · 2026-05-13 16:14:42 · 6dca32b</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</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>
|
||||||
|
|
|
||||||
|
|
@ -1424,7 +1424,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.17-beta · 2026-05-13 16:14:43 · 6dca32b</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -2523,7 +2523,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.17-beta · 2026-05-13 16:14:42 · 6dca32b</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</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;
|
||||||
|
|
|
||||||
|
|
@ -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.17-beta · 2026-05-13 16:14:42 · 6dca32b
|
archive=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||||
transmittal=v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b
|
transmittal=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||||
classifier=v0.0.17-beta · 2026-05-13 16:14:42 · 6dca32b
|
classifier=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||||
landing=v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
|
landing=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||||
form=v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
|
form=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||||
tables=v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
|
tables=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||||
browse=v0.0.17-beta · 2026-05-13 16:14:43 · 6dca32b
|
browse=v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel
|
||||||
|
|
|
||||||
|
|
@ -1300,7 +1300,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<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 16:14:43 · 6dca32b</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-13 · plaza-fiddle-panel</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue