Compare commits

...

5 commits

Author SHA1 Message Date
7d93171900 chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
2026-06-09 10:30:20 -05:00
237c353845 feat(server): /_apps/ — virtual public directory of standalone tool HTMLs
A no-auth virtual folder so anyone can grab a tool and run it against their own
local filesystem: GET /_apps/ is an index (Download / Open links); GET
/_apps/<tool>.html serves that tool's HTML (?download forces a save). Prefers
the site .zddc.zip bundle member (freshest), falls back to the binary's
embedded copy; tables/form come from the embedded tables bundle. Carries no
data, so it's served before the ACL/cascade and the reserved-prefix guard;
`_`-prefixed + virtual means no collision with content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:28:01 -05:00
3d02084397 feat(classifier): direct+total counts in tree; toast scan errors
- Counts now read "direct+total" — e.g. "(2+10 folders, 15+300 files)". The
  direct number (immediate children) shows as soon as a folder's own directory
  is read; the total (whole-subtree) is accumulated progressively and flashes
  grey until the subtree is fully scanned, then goes solid. The "+total" is
  omitted once done and there's nothing deeper.
- Scan errors (permission denied, network hiccups on a share) now surface as a
  toast (de-duped per path) instead of only console noise; a failed folder/zip
  is marked done-empty so it doesn't wedge the walk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:28:01 -05:00
ecb0a270cc feat(classifier): incremental scan — status, top-levels-first, per-folder state
Replaces the full depth-first "scan everything, then render once + expandAll +
selectAll" walk (which looked stalled and was a render bomb on a large network
drive) with a progressive, breadth-first scan:

- Walks level-by-level behind a bounded worker pool (6), rendering as it goes —
  the top folder levels appear immediately, deeper levels fill in the
  background. Workers await between directories so the UI stays responsive.
- Live status line under the tree header: "Scanning… N folders · M files —
  <current path>", ending "Scanned … in Ts."
- Per-folder state machine (pending → scanning → children → done) with
  immediate subfolder/file counts; the row is greyed (with a faint pulse) until
  its whole subtree is scanned, then turns solid — the at-a-glance signal.
- Opening a folder jumps its subtree to the front of the scan (ensureScanned),
  so an opened folder always shows complete contents; idempotent vs the
  background walk.
- No more auto-expand/auto-select-all (that loaded the entire drive up front);
  the root is selected so the grid shows its files immediately.
- ZIPs stay expandable, scanned inline into virtual nodes (already in memory
  once read); whole zip subtree marked done at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:03:46 -05:00
e0ae0772da test(server): file API preserves basename case on mkdir + PUT
Regression guard: mkdir and PUT under working/<party>/ keep the requested
basename case verbatim (MixedCaseDir, UPPER-Name.MD), confirming the server
does not normalize filename case — tracking numbers and the like must stay as
typed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:17:31 -05:00
15 changed files with 997 additions and 153 deletions

View file

@ -175,6 +175,36 @@
background-color: var(--bg-hover);
}
/* Counts read "direct+total". The direct number stays solid (immediate info);
the "+total" subtree count is muted and pulses while its subtree is still
being scanned, then goes solid once final. */
.folder-count .ct-total {
color: var(--text-secondary, #6b7280);
}
.folder-count .ct-total.pending {
color: var(--text-muted, #9aa0a6);
font-style: italic;
animation: scan-pulse 1.2s ease-in-out infinite;
}
@keyframes scan-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* Live scan status line under the tree-pane header. */
.scan-status {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
color: var(--text-muted, #8a8a8a);
border-bottom: 1px solid var(--border, #e2e2e2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-height: 1.1em;
}
.scan-status:empty { display: none; }
.scan-status.scanning { color: var(--primary, #2868c8); }
.folder-item.selected {
background-color: var(--bg-selected);
font-weight: 500;

View file

@ -8,52 +8,191 @@
// Store ZIP data for later access
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
// ── Incremental-scan state ───────────────────────────────────────────────
// The scan no longer reads the whole tree before rendering. It walks
// breadth-first behind a small worker pool, renders progressively (top
// levels appear first), and shows live status — so a huge network drive
// never looks stalled. Each folder tracks its own scan state + counts.
let scanGen = 0; // bumped per scan; stale workers bail
let scanStats = null; // { folders, files, current, done, startedAt }
let renderTimer = null; // throttle for progressive re-render
function scheduleRender() {
if (renderTimer) return;
renderTimer = setTimeout(function () {
renderTimer = null;
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
updateScanStatus();
}, 180);
}
function flushRender() {
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
updateScanStatus();
}
// Render the running scan status into the tree-pane header.
function updateScanStatus() {
const el = document.getElementById('scanStatus');
if (!el || !scanStats) return;
if (scanStats.done) {
const secs = ((Date.now() - scanStats.startedAt) / 1000).toFixed(1);
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
+ scanStats.files + ' files in ' + secs + 's';
el.classList.remove('scanning');
} else {
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
+ scanStats.files + ' files'
+ (scanStats.current ? ' — ' + scanStats.current : '');
el.classList.add('scanning');
}
}
// Make a tree node. scanState: 'pending' (children not read) →
// 'scanning' → 'children' (immediate children read, subtree still going) →
// 'done' (entire subtree enumerated). The UI greys a node until 'done'.
function makeNode(handle, path, parent) {
const node = {
name: handle.name,
path: path,
handle: handle,
parent: parent || null,
files: [],
fileCount: 0, // direct files in this folder
subdirCount: 0, // direct subfolders
runFiles: 0, // files in the whole subtree (grows as scanned; final on 'done')
runDirs: 0, // subfolders in the whole subtree
children: [],
expanded: false,
scanState: 'pending',
pending: 0, // child dirs not yet 'done'
};
if (handle.isZipRoot) { node.isZipRoot = true; node.zipPath = handle.zipPath; }
if (handle.isVirtualDir) { node.isVirtualDir = true; node.zipPath = handle.zipPath; }
return node;
}
// Mark a node's subtree fully scanned: roll up recursive totals and
// propagate completion to the parent (which flips to 'done' once all its
// children are done). This is what turns a folder from grey to solid.
function markDone(node) {
if (node.scanState === 'done') return;
// runFiles/runDirs were accumulated into this node (and its ancestors)
// as each descendant was scanned, so by the time the subtree is
// complete they already hold the final totals — nothing to compute.
node.scanState = 'done';
const p = node.parent;
if (p && p.scanState !== 'done') {
p.pending -= 1;
if (p.pending <= 0 && (p.scanState === 'children' || p.scanState === 'scanning')) {
markDone(p);
}
}
}
// One-shot toast for scan errors (permission denied, network hiccups on a
// share). De-duped per path so a flaky folder doesn't spam.
const scanErrorsSeen = new Set();
function reportScanError(path, err) {
console.error('Scan error:', path, err);
if (scanErrorsSeen.has(path)) return;
scanErrorsSeen.add(path);
const msg = 'Couldnt scan ' + path + ': ' + (err && err.message ? err.message : err);
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, 'error');
}
}
/**
* Scan directory and build folder tree with files
*/
async function scanDirectory(dirHandle, preserveState = false) {
// Save current state if preserving
// Preserve which folders were expanded across a rescan (e.g. after a
// ZIP extract) so the user doesn't lose their place.
let savedExpanded = new Set();
let savedSelected = new Set();
if (preserveState) {
savedExpanded = getExpandedPaths(window.app.folderTree);
savedSelected = new Set(window.app.selectedFolders);
}
// Clear ZIP cache
zipCache.clear();
// Map to store files by folder handle (or ZIP path for virtual folders)
const foldersMap = new Map();
// Recursively scan
await scanFolder(dirHandle, foldersMap, dirHandle.name);
// Build tree structure
window.app.folderTree = window.app.modules.tree.buildTree(dirHandle, foldersMap);
// Set in store
window.app.modules.store.setFolderTree(window.app.folderTree);
if (preserveState) {
// Restore expanded state
restoreExpandedPaths(window.app.folderTree, savedExpanded);
// Restore selection
window.app.selectedFolders = savedSelected;
// Render without changing selection
window.app.modules.tree.render();
window.app.modules.store.setSelectedFolders(savedSelected);
} else {
// Render tree
window.app.modules.tree.render();
// Auto-expand and select all folders
window.app.modules.tree.expandAll();
window.app.modules.tree.selectAll();
}
const myGen = ++scanGen;
zipCache.clear();
scanStats = { folders: 0, files: 0, current: dirHandle.name, done: false, startedAt: Date.now() };
// Root node — render immediately so the pane never sits blank.
const root = makeNode(dirHandle, dirHandle.name, null);
root.expanded = true;
window.app.folderTree = [root];
window.app.modules.store.setFolderTree(window.app.folderTree);
if (!preserveState) {
// Select the root so the grid shows its immediate files at once,
// instead of auto-loading the ENTIRE drive (the old behaviour,
// which is exactly what stalled on a large share).
window.app.selectedFolders = new Set([root.path]);
window.app.lastSelectedFolderPath = root.path;
window.app.modules.store.setSelectedFolders(window.app.selectedFolders);
} else {
window.app.selectedFolders = savedSelected;
window.app.modules.store.setSelectedFolders(savedSelected);
}
flushRender();
// Breadth-first by level behind a bounded worker pool: level 1, then
// level 2, … each rendered as it lands (top levels appear first).
// Deeper levels keep filling in; workers await between directories so
// the UI stays responsive on a slow/large network drive.
let level = [root];
while (level.length && myGen === scanGen) {
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
const next = [];
for (const n of level) {
for (const c of n.children) {
if (preserveState && savedExpanded.has(c.path)) c.expanded = true;
if (c.scanState === 'pending') next.push(c);
}
}
level = next;
}
if (myGen !== scanGen) return; // superseded by a newer scan
scanStats.done = true;
scanStats.current = '';
flushRender();
}
// Run fn over items with at most `limit` concurrent calls; resolves when
// all have settled. Termination is clean (no transient-empty-queue race).
async function runWithConcurrency(items, limit, fn) {
let i = 0;
async function runner() {
while (i < items.length) {
const idx = i++;
await fn(items[idx]);
}
}
const runners = [];
for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner());
await Promise.all(runners);
}
// Force a folder's subtree to scan NOW (jumped ahead of the background
// walk). Called when the user opens a folder, so an opened folder always
// shows complete contents. Idempotent + shares the live scan generation.
async function ensureScanned(node) {
if (!node || !node.handle || node.scanState === 'done') return;
const myGen = scanGen;
let level = [node];
while (level.length && myGen === scanGen) {
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
const next = [];
for (const n of level) {
for (const c of n.children) if (c.scanState === 'pending') next.push(c);
}
level = next;
}
flushRender();
}
/**
@ -83,43 +222,129 @@
}
}
/**
* Recursively scan a folder
*/
async function scanFolder(dirHandle, foldersMap, currentPath) {
const items = [];
// Read ONE directory's immediate entries: files into node.files, child
// directories into node.children (left 'pending' for the BFS to descend).
// A .zip becomes an expandable zip-root child, scanned inline (its
// contents are already in memory once the entry is read). Idempotent:
// only a 'pending' node is scanned, so concurrent callers (background +
// open-prioritised) don't double-scan.
async function scanNodeChildren(node, myGen) {
if (node.scanState !== 'pending') return;
node.scanState = 'scanning';
if (scanStats) scanStats.current = node.path;
const files = [];
const childDirs = [];
try {
for await (const entry of dirHandle.values()) {
for await (const entry of node.handle.values()) {
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
if (entry.kind === 'file') {
// Create file object
const file = await createFileObject(entry, dirHandle);
if (file) {
items.push(file);
// Check if it's a ZIP file - scan its contents
if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
await scanZipFile(file, foldersMap, currentPath, items);
}
const fo = await createFileObject(entry, node.handle);
if (!fo) continue;
fo.folderPath = node.path;
files.push(fo);
if (scanStats) scanStats.files++;
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension);
const zipPath = node.path + '/' + zipName;
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
const zipNode = makeNode(zh, zipPath, node);
try { await scanZipIntoNode(zipNode, fo); }
catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
childDirs.push(zipNode);
if (scanStats) scanStats.folders++;
}
} else if (entry.kind === 'directory') {
// Add directory reference
items.push({
handle: entry,
isDirectory: true
});
// Recursively scan subdirectory
const childPath = currentPath + '/' + entry.name;
await scanFolder(entry, foldersMap, childPath);
const childPath = node.path + '/' + entry.name;
childDirs.push(makeNode(entry, childPath, node));
if (scanStats) scanStats.folders++;
}
}
} catch (err) {
console.error('Error scanning folder:', dirHandle.name, err);
node.scanError = true;
reportScanError(node.path, err);
}
// Store files for this folder
foldersMap.set(dirHandle, items);
node.files = files;
node.fileCount = files.length;
node.children = childDirs;
node.subdirCount = childDirs.length;
// Roll this folder's own files/dirs (plus the full contents of any
// inline-zip children) into the running subtree totals of this node
// and every ancestor. Regular child dirs add their own share when they
// get scanned — that's how the total fills in progressively.
let addF = files.length;
let addD = childDirs.length;
for (const c of childDirs) {
if (c.scanState === 'done') { addF += c.runFiles; addD += c.runDirs; }
}
for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; }
// Zip children are scanned inline ('done'); real dirs are still pending.
node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length;
if (node.pending === 0) {
markDone(node);
} else {
node.scanState = 'children';
}
scheduleRender();
}
// Build a zip-root node's children from its archive contents (in memory),
// marking the whole zip subtree 'done' immediately. Mirrors the on-disk
// node shape so the rest of the app treats zip folders like real ones.
async function scanZipIntoNode(zipNode, fileObj) {
const f = await fileObj.handle.getFile();
const zip = await JSZip.loadAsync(await f.arrayBuffer());
const zipPath = zipNode.path;
zipCache.set(zipPath, { zip: zip, fileHandle: fileObj.handle, folderHandle: fileObj.folderHandle });
const dirNodes = new Map();
dirNodes.set(zipPath, zipNode);
function ensureDir(dirPath) {
if (dirNodes.has(dirPath)) return dirNodes.get(dirPath);
const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/'));
const parent = ensureDir(parentPath);
const name = dirPath.substring(dirPath.lastIndexOf('/') + 1);
const vh = { name: name, kind: 'directory', isVirtualDir: true, zipPath: zipPath, virtualPath: dirPath };
const child = makeNode(vh, dirPath, parent);
parent.children.push(child);
dirNodes.set(dirPath, child);
return child;
}
zip.forEach(function (relativePath, entry) {
if (entry.dir) {
ensureDir(zipPath + '/' + relativePath.replace(/\/$/, ''));
} else {
const fileName = relativePath.split('/').pop();
const fileDir = relativePath.includes('/')
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
: zipPath;
const dirNode = ensureDir(fileDir);
const split = zddc.splitExtension(fileName);
dirNode.files.push({
originalFilename: split.name,
extension: split.extension,
size: entry._data ? entry._data.uncompressedSize : 0,
lastModified: entry.date ? entry.date.getTime() : Date.now(),
isVirtual: true,
zipPath: zipPath,
zipEntryPath: relativePath,
folderPath: dirNode.path,
trackingNumber: '', revision: '', status: '', title: '',
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null
});
}
});
finalizeZipNode(zipNode);
}
// Roll up a zip node's counts + mark its whole subtree 'done'.
function finalizeZipNode(node) {
node.fileCount = node.files.length;
node.subdirCount = node.children.length;
let rf = node.files.length, rd = node.children.length;
for (const c of node.children) { finalizeZipNode(c); rf += c.runFiles; rd += c.runDirs; }
node.runFiles = rf;
node.runDirs = rd;
node.scanState = 'done';
node.pending = 0;
}
/**
@ -429,6 +654,7 @@
// Export module
window.app.modules.scanner = {
scanDirectory,
ensureScanned,
getZipCache,
extractZip
};

View file

@ -25,6 +25,48 @@
updateSelectedCount();
}
/**
* Populate a folder row's count element with "direct+total" counts, e.g.
* "(2+10 folders, 15+300 files)" direct (immediate children) shows as
* soon as the folder's own directory is read; the total (whole subtree)
* grows and flashes grey until the subtree is fully scanned, then goes
* solid. The "+total" part is omitted once scanning is done and there's
* nothing deeper (direct == total).
*/
function populateCount(el, folder) {
el.textContent = '';
const st = folder.scanState;
if (st === 'pending') return;
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
const done = st === 'done';
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode('('));
if (dDir > 0 || tDir > 0) {
appendPair(frag, dDir, tDir, done);
frag.appendChild(document.createTextNode(tDir === 1 ? ' folder, ' : ' folders, '));
}
appendPair(frag, dFile, tFile, done);
frag.appendChild(document.createTextNode(tFile === 1 ? ' file)' : ' files)'));
el.appendChild(frag);
}
// Append "<direct>" and, when there's a subtree (or scanning is ongoing),
// "+<total>" with the total in a span that greys + pulses until final.
function appendPair(frag, direct, total, done) {
frag.appendChild(document.createTextNode(String(direct)));
if (!done || total > direct) {
frag.appendChild(document.createTextNode('+'));
const t = document.createElement('span');
t.className = 'ct-total' + (done ? '' : ' pending');
t.textContent = String(total);
frag.appendChild(t);
}
}
/**
* Create a folder element
*/
@ -33,6 +75,11 @@
const item = document.createElement('div');
item.className = 'folder-item';
// Grey the row until its subtree is fully scanned (scanState 'done');
// 'scanning' rows also get a subtle pulse via CSS.
if (folder.scanState && folder.scanState !== 'done') {
item.classList.add('scanning');
}
item.dataset.path = folder.path;
item.style.paddingLeft = `${level * 1.5}rem`;
@ -41,10 +88,13 @@
item.classList.add('selected');
}
// Toggle button (if has children)
// Toggle button: shown when the folder has children OR hasn't been
// scanned yet (it might have children — expanding triggers its scan).
const toggle = document.createElement('span');
toggle.className = 'folder-toggle';
if (folder.children && folder.children.length > 0) {
const mightHaveChildren = (folder.children && folder.children.length > 0)
|| folder.scanState === 'pending';
if (mightHaveChildren) {
toggle.textContent = folder.expanded ? '▼' : '▶';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
@ -74,10 +124,11 @@
name.textContent = folder.name;
item.appendChild(name);
// File count
// Subfolder / file counts (immediate). Greyed via the row's .scanning
// class until the subtree is fully scanned.
const count = document.createElement('span');
count.className = 'folder-count';
count.textContent = `(${folder.fileCount || 0})`;
populateCount(count, folder);
item.appendChild(count);
// Extract button for ZIP roots
@ -287,7 +338,7 @@
*/
function toggleFolder(folder, recursive = false) {
folder.expanded = !folder.expanded;
if (recursive && folder.children) {
// Recursively expand/collapse all children
const newState = folder.expanded;
@ -299,8 +350,16 @@
}
folder.children.forEach(setAllExpanded);
}
render();
// Opening a not-yet-complete folder jumps its subtree to the front of
// the scan so its contents are complete on open (re-renders as it
// fills in). Background scanning continues for everything else.
if (folder.expanded && folder.scanState !== 'done'
&& window.app.modules.scanner && window.app.modules.scanner.ensureScanned) {
window.app.modules.scanner.ensureScanned(folder).then(render).catch(() => {});
}
}
/**

View file

@ -58,6 +58,7 @@
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div>
</div>
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
<div id="folderTree" class="folder-tree">
<!-- Dynamically populated -->
</div>

View file

@ -807,6 +807,19 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
// /_apps/ — virtual, public directory of the standalone tool HTMLs, so
// people can download one and run it against their own local filesystem.
// Tool UI only, no data, no auth. Before the reserved-prefix ('_'/'.')
// guard so it isn't 404'd.
if urlPath == "/_apps" {
http.Redirect(w, r, handler.AppsVirtualPrefix, http.StatusMovedPermanently)
return
}
if strings.HasPrefix(urlPath, handler.AppsVirtualPrefix) {
handler.ServeApps(appsSrv, w, r)
return
}
// Auth check endpoints — machine-only forward_auth targets used by
// upstream proxies (e.g. the dev-shell pod's Caddy in front of
// code-server) to gate routes on root-admin status. Handled before

View file

@ -2665,7 +2665,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.27-beta · 2026-06-08 23:50:40 · 0326d46</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2772,7 +2772,7 @@ li.CodeMirror-hint-active {
</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.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:14 · 237c353</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>

View file

@ -1293,6 +1293,36 @@ body.is-elevated::after {
background-color: var(--bg-hover);
}
/* Counts read "direct+total". The direct number stays solid (immediate info);
the "+total" subtree count is muted and pulses while its subtree is still
being scanned, then goes solid once final. */
.folder-count .ct-total {
color: var(--text-secondary, #6b7280);
}
.folder-count .ct-total.pending {
color: var(--text-muted, #9aa0a6);
font-style: italic;
animation: scan-pulse 1.2s ease-in-out infinite;
}
@keyframes scan-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
/* Live scan status line under the tree-pane header. */
.scan-status {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
color: var(--text-muted, #8a8a8a);
border-bottom: 1px solid var(--border, #e2e2e2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-height: 1.1em;
}
.scan-status:empty { display: none; }
.scan-status.scanning { color: var(--primary, #2868c8); }
.folder-item.selected {
background-color: var(--bg-selected);
font-weight: 500;
@ -1876,7 +1906,7 @@ body.is-elevated::after {
</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.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Use 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>
@ -1908,6 +1938,7 @@ body.is-elevated::after {
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div>
</div>
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
<div id="folderTree" class="folder-tree">
<!-- Dynamically populated -->
</div>
@ -6383,52 +6414,191 @@ X.B(E,Y);return E}return J}())
// Store ZIP data for later access
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle }
// ── Incremental-scan state ───────────────────────────────────────────────
// The scan no longer reads the whole tree before rendering. It walks
// breadth-first behind a small worker pool, renders progressively (top
// levels appear first), and shows live status — so a huge network drive
// never looks stalled. Each folder tracks its own scan state + counts.
let scanGen = 0; // bumped per scan; stale workers bail
let scanStats = null; // { folders, files, current, done, startedAt }
let renderTimer = null; // throttle for progressive re-render
function scheduleRender() {
if (renderTimer) return;
renderTimer = setTimeout(function () {
renderTimer = null;
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
updateScanStatus();
}, 180);
}
function flushRender() {
if (renderTimer) { clearTimeout(renderTimer); renderTimer = null; }
try { window.app.modules.tree.render(); } catch (_) { /* ignore */ }
updateScanStatus();
}
// Render the running scan status into the tree-pane header.
function updateScanStatus() {
const el = document.getElementById('scanStatus');
if (!el || !scanStats) return;
if (scanStats.done) {
const secs = ((Date.now() - scanStats.startedAt) / 1000).toFixed(1);
el.textContent = 'Scanned ' + scanStats.folders + ' folders · '
+ scanStats.files + ' files in ' + secs + 's';
el.classList.remove('scanning');
} else {
el.textContent = 'Scanning… ' + scanStats.folders + ' folders · '
+ scanStats.files + ' files'
+ (scanStats.current ? ' — ' + scanStats.current : '');
el.classList.add('scanning');
}
}
// Make a tree node. scanState: 'pending' (children not read) →
// 'scanning' → 'children' (immediate children read, subtree still going) →
// 'done' (entire subtree enumerated). The UI greys a node until 'done'.
function makeNode(handle, path, parent) {
const node = {
name: handle.name,
path: path,
handle: handle,
parent: parent || null,
files: [],
fileCount: 0, // direct files in this folder
subdirCount: 0, // direct subfolders
runFiles: 0, // files in the whole subtree (grows as scanned; final on 'done')
runDirs: 0, // subfolders in the whole subtree
children: [],
expanded: false,
scanState: 'pending',
pending: 0, // child dirs not yet 'done'
};
if (handle.isZipRoot) { node.isZipRoot = true; node.zipPath = handle.zipPath; }
if (handle.isVirtualDir) { node.isVirtualDir = true; node.zipPath = handle.zipPath; }
return node;
}
// Mark a node's subtree fully scanned: roll up recursive totals and
// propagate completion to the parent (which flips to 'done' once all its
// children are done). This is what turns a folder from grey to solid.
function markDone(node) {
if (node.scanState === 'done') return;
// runFiles/runDirs were accumulated into this node (and its ancestors)
// as each descendant was scanned, so by the time the subtree is
// complete they already hold the final totals — nothing to compute.
node.scanState = 'done';
const p = node.parent;
if (p && p.scanState !== 'done') {
p.pending -= 1;
if (p.pending <= 0 && (p.scanState === 'children' || p.scanState === 'scanning')) {
markDone(p);
}
}
}
// One-shot toast for scan errors (permission denied, network hiccups on a
// share). De-duped per path so a flaky folder doesn't spam.
const scanErrorsSeen = new Set();
function reportScanError(path, err) {
console.error('Scan error:', path, err);
if (scanErrorsSeen.has(path)) return;
scanErrorsSeen.add(path);
const msg = 'Couldnt scan ' + path + ': ' + (err && err.message ? err.message : err);
if (window.zddc && typeof window.zddc.toast === 'function') {
window.zddc.toast(msg, 'error');
}
}
/**
* Scan directory and build folder tree with files
*/
async function scanDirectory(dirHandle, preserveState = false) {
// Save current state if preserving
// Preserve which folders were expanded across a rescan (e.g. after a
// ZIP extract) so the user doesn't lose their place.
let savedExpanded = new Set();
let savedSelected = new Set();
if (preserveState) {
savedExpanded = getExpandedPaths(window.app.folderTree);
savedSelected = new Set(window.app.selectedFolders);
}
// Clear ZIP cache
zipCache.clear();
// Map to store files by folder handle (or ZIP path for virtual folders)
const foldersMap = new Map();
// Recursively scan
await scanFolder(dirHandle, foldersMap, dirHandle.name);
// Build tree structure
window.app.folderTree = window.app.modules.tree.buildTree(dirHandle, foldersMap);
// Set in store
window.app.modules.store.setFolderTree(window.app.folderTree);
if (preserveState) {
// Restore expanded state
restoreExpandedPaths(window.app.folderTree, savedExpanded);
// Restore selection
window.app.selectedFolders = savedSelected;
// Render without changing selection
window.app.modules.tree.render();
window.app.modules.store.setSelectedFolders(savedSelected);
} else {
// Render tree
window.app.modules.tree.render();
// Auto-expand and select all folders
window.app.modules.tree.expandAll();
window.app.modules.tree.selectAll();
}
const myGen = ++scanGen;
zipCache.clear();
scanStats = { folders: 0, files: 0, current: dirHandle.name, done: false, startedAt: Date.now() };
// Root node — render immediately so the pane never sits blank.
const root = makeNode(dirHandle, dirHandle.name, null);
root.expanded = true;
window.app.folderTree = [root];
window.app.modules.store.setFolderTree(window.app.folderTree);
if (!preserveState) {
// Select the root so the grid shows its immediate files at once,
// instead of auto-loading the ENTIRE drive (the old behaviour,
// which is exactly what stalled on a large share).
window.app.selectedFolders = new Set([root.path]);
window.app.lastSelectedFolderPath = root.path;
window.app.modules.store.setSelectedFolders(window.app.selectedFolders);
} else {
window.app.selectedFolders = savedSelected;
window.app.modules.store.setSelectedFolders(savedSelected);
}
flushRender();
// Breadth-first by level behind a bounded worker pool: level 1, then
// level 2, … each rendered as it lands (top levels appear first).
// Deeper levels keep filling in; workers await between directories so
// the UI stays responsive on a slow/large network drive.
let level = [root];
while (level.length && myGen === scanGen) {
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
const next = [];
for (const n of level) {
for (const c of n.children) {
if (preserveState && savedExpanded.has(c.path)) c.expanded = true;
if (c.scanState === 'pending') next.push(c);
}
}
level = next;
}
if (myGen !== scanGen) return; // superseded by a newer scan
scanStats.done = true;
scanStats.current = '';
flushRender();
}
// Run fn over items with at most `limit` concurrent calls; resolves when
// all have settled. Termination is clean (no transient-empty-queue race).
async function runWithConcurrency(items, limit, fn) {
let i = 0;
async function runner() {
while (i < items.length) {
const idx = i++;
await fn(items[idx]);
}
}
const runners = [];
for (let k = 0; k < Math.min(limit, items.length); k++) runners.push(runner());
await Promise.all(runners);
}
// Force a folder's subtree to scan NOW (jumped ahead of the background
// walk). Called when the user opens a folder, so an opened folder always
// shows complete contents. Idempotent + shares the live scan generation.
async function ensureScanned(node) {
if (!node || !node.handle || node.scanState === 'done') return;
const myGen = scanGen;
let level = [node];
while (level.length && myGen === scanGen) {
await runWithConcurrency(level, 6, function (n) { return scanNodeChildren(n, myGen); });
const next = [];
for (const n of level) {
for (const c of n.children) if (c.scanState === 'pending') next.push(c);
}
level = next;
}
flushRender();
}
/**
@ -6458,43 +6628,129 @@ X.B(E,Y);return E}return J}())
}
}
/**
* Recursively scan a folder
*/
async function scanFolder(dirHandle, foldersMap, currentPath) {
const items = [];
// Read ONE directory's immediate entries: files into node.files, child
// directories into node.children (left 'pending' for the BFS to descend).
// A .zip becomes an expandable zip-root child, scanned inline (its
// contents are already in memory once the entry is read). Idempotent:
// only a 'pending' node is scanned, so concurrent callers (background +
// open-prioritised) don't double-scan.
async function scanNodeChildren(node, myGen) {
if (node.scanState !== 'pending') return;
node.scanState = 'scanning';
if (scanStats) scanStats.current = node.path;
const files = [];
const childDirs = [];
try {
for await (const entry of dirHandle.values()) {
for await (const entry of node.handle.values()) {
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
if (entry.kind === 'file') {
// Create file object
const file = await createFileObject(entry, dirHandle);
if (file) {
items.push(file);
// Check if it's a ZIP file - scan its contents
if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
await scanZipFile(file, foldersMap, currentPath, items);
}
const fo = await createFileObject(entry, node.handle);
if (!fo) continue;
fo.folderPath = node.path;
files.push(fo);
if (scanStats) scanStats.files++;
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') {
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension);
const zipPath = node.path + '/' + zipName;
const zh = { name: zipName, kind: 'directory', isZipRoot: true, zipPath: zipPath };
const zipNode = makeNode(zh, zipPath, node);
try { await scanZipIntoNode(zipNode, fo); }
catch (e) { reportScanError(zipPath, e); zipNode.scanState = 'done'; }
childDirs.push(zipNode);
if (scanStats) scanStats.folders++;
}
} else if (entry.kind === 'directory') {
// Add directory reference
items.push({
handle: entry,
isDirectory: true
});
// Recursively scan subdirectory
const childPath = currentPath + '/' + entry.name;
await scanFolder(entry, foldersMap, childPath);
const childPath = node.path + '/' + entry.name;
childDirs.push(makeNode(entry, childPath, node));
if (scanStats) scanStats.folders++;
}
}
} catch (err) {
console.error('Error scanning folder:', dirHandle.name, err);
node.scanError = true;
reportScanError(node.path, err);
}
// Store files for this folder
foldersMap.set(dirHandle, items);
node.files = files;
node.fileCount = files.length;
node.children = childDirs;
node.subdirCount = childDirs.length;
// Roll this folder's own files/dirs (plus the full contents of any
// inline-zip children) into the running subtree totals of this node
// and every ancestor. Regular child dirs add their own share when they
// get scanned — that's how the total fills in progressively.
let addF = files.length;
let addD = childDirs.length;
for (const c of childDirs) {
if (c.scanState === 'done') { addF += c.runFiles; addD += c.runDirs; }
}
for (let a = node; a; a = a.parent) { a.runFiles += addF; a.runDirs += addD; }
// Zip children are scanned inline ('done'); real dirs are still pending.
node.pending = childDirs.filter(function (c) { return c.scanState !== 'done'; }).length;
if (node.pending === 0) {
markDone(node);
} else {
node.scanState = 'children';
}
scheduleRender();
}
// Build a zip-root node's children from its archive contents (in memory),
// marking the whole zip subtree 'done' immediately. Mirrors the on-disk
// node shape so the rest of the app treats zip folders like real ones.
async function scanZipIntoNode(zipNode, fileObj) {
const f = await fileObj.handle.getFile();
const zip = await JSZip.loadAsync(await f.arrayBuffer());
const zipPath = zipNode.path;
zipCache.set(zipPath, { zip: zip, fileHandle: fileObj.handle, folderHandle: fileObj.folderHandle });
const dirNodes = new Map();
dirNodes.set(zipPath, zipNode);
function ensureDir(dirPath) {
if (dirNodes.has(dirPath)) return dirNodes.get(dirPath);
const parentPath = dirPath.substring(0, dirPath.lastIndexOf('/'));
const parent = ensureDir(parentPath);
const name = dirPath.substring(dirPath.lastIndexOf('/') + 1);
const vh = { name: name, kind: 'directory', isVirtualDir: true, zipPath: zipPath, virtualPath: dirPath };
const child = makeNode(vh, dirPath, parent);
parent.children.push(child);
dirNodes.set(dirPath, child);
return child;
}
zip.forEach(function (relativePath, entry) {
if (entry.dir) {
ensureDir(zipPath + '/' + relativePath.replace(/\/$/, ''));
} else {
const fileName = relativePath.split('/').pop();
const fileDir = relativePath.includes('/')
? zipPath + '/' + relativePath.substring(0, relativePath.lastIndexOf('/'))
: zipPath;
const dirNode = ensureDir(fileDir);
const split = zddc.splitExtension(fileName);
dirNode.files.push({
originalFilename: split.name,
extension: split.extension,
size: entry._data ? entry._data.uncompressedSize : 0,
lastModified: entry.date ? entry.date.getTime() : Date.now(),
isVirtual: true,
zipPath: zipPath,
zipEntryPath: relativePath,
folderPath: dirNode.path,
trackingNumber: '', revision: '', status: '', title: '',
isDirty: false, error: false, errorMessage: '', validation: null, sha256: null
});
}
});
finalizeZipNode(zipNode);
}
// Roll up a zip node's counts + mark its whole subtree 'done'.
function finalizeZipNode(node) {
node.fileCount = node.files.length;
node.subdirCount = node.children.length;
let rf = node.files.length, rd = node.children.length;
for (const c of node.children) { finalizeZipNode(c); rf += c.runFiles; rd += c.runDirs; }
node.runFiles = rf;
node.runDirs = rd;
node.scanState = 'done';
node.pending = 0;
}
/**
@ -6804,6 +7060,7 @@ X.B(E,Y);return E}return J}())
// Export module
window.app.modules.scanner = {
scanDirectory,
ensureScanned,
getZipCache,
extractZip
};
@ -6837,6 +7094,48 @@ X.B(E,Y);return E}return J}())
updateSelectedCount();
}
/**
* Populate a folder row's count element with "direct+total" counts, e.g.
* "(2+10 folders, 15+300 files)" — direct (immediate children) shows as
* soon as the folder's own directory is read; the total (whole subtree)
* grows and flashes grey until the subtree is fully scanned, then goes
* solid. The "+total" part is omitted once scanning is done and there's
* nothing deeper (direct == total).
*/
function populateCount(el, folder) {
el.textContent = '';
const st = folder.scanState;
if (st === 'pending') return;
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
const done = st === 'done';
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
const dFile = folder.fileCount || 0, tFile = folder.runFiles || 0;
const frag = document.createDocumentFragment();
frag.appendChild(document.createTextNode('('));
if (dDir > 0 || tDir > 0) {
appendPair(frag, dDir, tDir, done);
frag.appendChild(document.createTextNode(tDir === 1 ? ' folder, ' : ' folders, '));
}
appendPair(frag, dFile, tFile, done);
frag.appendChild(document.createTextNode(tFile === 1 ? ' file)' : ' files)'));
el.appendChild(frag);
}
// Append "<direct>" and, when there's a subtree (or scanning is ongoing),
// "+<total>" with the total in a span that greys + pulses until final.
function appendPair(frag, direct, total, done) {
frag.appendChild(document.createTextNode(String(direct)));
if (!done || total > direct) {
frag.appendChild(document.createTextNode('+'));
const t = document.createElement('span');
t.className = 'ct-total' + (done ? '' : ' pending');
t.textContent = String(total);
frag.appendChild(t);
}
}
/**
* Create a folder element
*/
@ -6845,6 +7144,11 @@ X.B(E,Y);return E}return J}())
const item = document.createElement('div');
item.className = 'folder-item';
// Grey the row until its subtree is fully scanned (scanState 'done');
// 'scanning' rows also get a subtle pulse via CSS.
if (folder.scanState && folder.scanState !== 'done') {
item.classList.add('scanning');
}
item.dataset.path = folder.path;
item.style.paddingLeft = `${level * 1.5}rem`;
@ -6853,10 +7157,13 @@ X.B(E,Y);return E}return J}())
item.classList.add('selected');
}
// Toggle button (if has children)
// Toggle button: shown when the folder has children OR hasn't been
// scanned yet (it might have children — expanding triggers its scan).
const toggle = document.createElement('span');
toggle.className = 'folder-toggle';
if (folder.children && folder.children.length > 0) {
const mightHaveChildren = (folder.children && folder.children.length > 0)
|| folder.scanState === 'pending';
if (mightHaveChildren) {
toggle.textContent = folder.expanded ? '▼' : '▶';
toggle.addEventListener('click', (e) => {
e.stopPropagation();
@ -6886,10 +7193,11 @@ X.B(E,Y);return E}return J}())
name.textContent = folder.name;
item.appendChild(name);
// File count
// Subfolder / file counts (immediate). Greyed via the row's .scanning
// class until the subtree is fully scanned.
const count = document.createElement('span');
count.className = 'folder-count';
count.textContent = `(${folder.fileCount || 0})`;
populateCount(count, folder);
item.appendChild(count);
// Extract button for ZIP roots
@ -7099,7 +7407,7 @@ X.B(E,Y);return E}return J}())
*/
function toggleFolder(folder, recursive = false) {
folder.expanded = !folder.expanded;
if (recursive && folder.children) {
// Recursively expand/collapse all children
const newState = folder.expanded;
@ -7111,8 +7419,16 @@ X.B(E,Y);return E}return J}())
}
folder.children.forEach(setAllExpanded);
}
render();
// Opening a not-yet-complete folder jumps its subtree to the front of
// the scan so its contents are complete on open (re-renders as it
// fills in). Background scanning continues for everything else.
if (folder.expanded && folder.scanState !== 'done'
&& window.app.modules.scanner && window.app.modules.scanner.ensureScanned) {
window.app.modules.scanner.ensureScanned(folder).then(render).catch(() => {});
}
}
/**

View file

@ -1619,7 +1619,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.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
</div>
</div>
<div class="header-right">

View file

@ -2718,7 +2718,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.27-beta · 2026-06-08 23:50:40 · 0326d46</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</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.27-beta · 2026-06-08 23:50:40 · 0326d46
transmittal=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
classifier=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
landing=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
form=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
tables=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
browse=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
archive=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
transmittal=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
classifier=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
landing=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
form=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
tables=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353
browse=v0.0.27-beta · 2026-06-09 15:30:14 · 237c353

View file

@ -0,0 +1,126 @@
package handler
import (
"html"
"net/http"
"strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
)
// AppsVirtualPrefix is a virtual, public directory that serves the standalone
// tool HTMLs so anyone can grab one and run it against their OWN local
// filesystem — download it, open it from disk, and it runs offline via the
// browser's File System Access picker. It carries no project data, so it's
// served with no auth/ACL. `_`-prefixed names are system-reserved (mkdir
// rejects them), so /_apps/ never collides with content, and it's virtual —
// nothing exists on disk.
const AppsVirtualPrefix = "/_apps/"
type standaloneApp struct {
File string // URL + filename, e.g. "classifier.html"
Name string
Desc string
}
// The order here is the order shown on the index; "local-first" tools lead.
var standaloneApps = []standaloneApp{
{"classifier.html", "Classifier", "Rename a local folder of files to ZDDC naming conventions."},
{"browse.html", "Browse", "Browse + edit a local directory tree — markdown, YAML, CSV, and more."},
{"transmittal.html", "Transmittal", "Assemble a transmittal package from local files."},
{"tables.html", "Tables", "View and edit a directory of YAML rows as a sortable table."},
{"form.html", "Form", "Edit a single YAML record with a schema-driven form."},
{"archive.html", "Archive", "Review an archive of ZDDC-named files."},
}
// appBytesForFile returns the HTML for a /_apps/<file> request, or nil for an
// unknown file. It prefers the site .zddc.zip bundle member (operator
// override / the freshest dev build) and falls back to the binary's embedded
// copy. tables + form share the one embedded tables/form bundle.
func appBytesForFile(appsSrv *apps.Server, file string) []byte {
if appsSrv != nil && appsSrv.Bundle != nil {
if b, ok := appsSrv.Bundle.Member(file); ok && len(b) > 0 {
return b
}
}
switch file {
case "classifier.html":
return apps.EmbeddedBytes("classifier")
case "browse.html":
return apps.EmbeddedBytes("browse")
case "transmittal.html":
return apps.EmbeddedBytes("transmittal")
case "archive.html":
return apps.EmbeddedBytes("archive")
case "tables.html", "form.html":
return EmbeddedTablesHTML()
}
return nil
}
// ServeApps handles GET/HEAD under /_apps/. "/_apps/" → an index of the
// standalone tools (Download / Open links); "/_apps/<tool>.html" → that tool's
// embedded HTML. Append ?download to force a save dialog. No auth — tool UI
// only, no data.
func ServeApps(appsSrv *apps.Server, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
rest := strings.TrimPrefix(r.URL.Path, AppsVirtualPrefix)
if rest == "" || rest == "index" {
serveAppsIndex(w, r)
return
}
if strings.Contains(rest, "/") {
http.NotFound(w, r)
return
}
body := appBytesForFile(appsSrv, rest)
if body == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Conditional-GET-friendly: revalidate each load; bytes change only on a
// binary redeploy. (Index is no-store; it's tiny + generated.)
w.Header().Set("Cache-Control", "max-age=0, must-revalidate")
if r.URL.Query().Get("download") != "" {
w.Header().Set("Content-Disposition", `attachment; filename="`+rest+`"`)
}
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(body)
}
func serveAppsIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodHead {
return
}
var b strings.Builder
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">`)
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1">`)
b.WriteString(`<title>ZDDC standalone apps</title><style>`)
b.WriteString(`body{font-family:system-ui,-apple-system,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem;line-height:1.5;color:#222}`)
b.WriteString(`h1{font-size:1.4rem;margin:0 0 .3rem}.lead{color:#555;font-size:.92rem;margin:0 0 1.2rem}`)
b.WriteString(`.app{border:1px solid #e2e2e2;border-radius:8px;padding:.7rem 1rem;margin:.55rem 0}`)
b.WriteString(`.app h2{font-size:1.05rem;margin:0 0 .15rem}.app p{margin:.15rem 0 .55rem;color:#555;font-size:.88rem}`)
b.WriteString(`a.btn{display:inline-block;padding:.28rem .7rem;border:1px solid #2868c8;border-radius:5px;color:#2868c8;text-decoration:none;margin-right:.4rem;font-size:.85rem}`)
b.WriteString(`a.btn:hover{background:#2868c8;color:#fff}`)
b.WriteString(`</style></head><body>`)
b.WriteString(`<h1>ZDDC standalone apps</h1>`)
b.WriteString(`<p class="lead">Each tool is a single, self-contained HTML file. <strong>Download</strong> one and open it from your disk to run it offline against a folder on your own machine (use a Chromium-family browser — it needs the File System Access API). <strong>Open</strong> runs it here, against the server.</p>`)
for _, a := range standaloneApps {
b.WriteString(`<div class="app"><h2>` + html.EscapeString(a.Name) + `</h2>`)
b.WriteString(`<p>` + html.EscapeString(a.Desc) + `</p>`)
b.WriteString(`<a class="btn" href="` + AppsVirtualPrefix + a.File + `?download=1" download="` + a.File + `">Download</a>`)
b.WriteString(`<a class="btn" href="` + AppsVirtualPrefix + a.File + `" target="_blank" rel="noopener">Open</a>`)
b.WriteString(`</div>`)
}
b.WriteString(`</body></html>`)
_, _ = w.Write([]byte(b.String()))
}

View file

@ -0,0 +1,42 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestServeApps(t *testing.T) {
// Index lists the tools.
rec := httptest.NewRecorder()
ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix, nil))
if rec.Code != http.StatusOK {
t.Fatalf("index: want 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Classifier") {
t.Errorf("index should list Classifier")
}
// A known tool resolves to HTML (embedded bytes may be empty in a fresh
// checkout, so accept 200 with a body OR 404 only when the slot is empty).
rec = httptest.NewRecorder()
ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"classifier.html", nil))
if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound {
t.Errorf("classifier.html: unexpected %d", rec.Code)
}
// Unknown name → 404.
rec = httptest.NewRecorder()
ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"nope.html", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("unknown: want 404, got %d", rec.Code)
}
// Path traversal / subpath → 404.
rec = httptest.NewRecorder()
ServeApps(nil, rec, httptest.NewRequest(http.MethodGet, AppsVirtualPrefix+"a/b.html", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("subpath: want 404, got %d", rec.Code)
}
}

View file

@ -918,3 +918,34 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
t.Errorf("history should NOT follow a cross-dir move; err=%v", err)
}
}
// TestFileAPI_PreservesCase — mkdir + PUT must keep the basename's case
// verbatim (tracking numbers are uppercase; the server must not normalize).
func TestFileAPI_PreservesCase(t *testing.T) {
// working/<party>/… is the realistic create surface; the harness
// auto-registers Acme so the party gate passes. Subfolder + file names
// under it are arbitrary — isolates the basename-case question.
_, do, root := fileAPITestSetup(t, []string{"Proj/working/Acme"}, nil)
base := filepath.Join(root, "Proj", "working", "Acme")
rec := do(http.MethodPost, "/Proj/working/Acme/MixedCaseDir", "alice@example.com", nil, map[string]string{"X-ZDDC-Op": "mkdir"})
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Fatalf("mkdir: %d %s", rec.Code, rec.Body.String())
}
rec = do(http.MethodPut, "/Proj/working/Acme/UPPER-Name.MD", "alice@example.com", []byte("# hi\n"), map[string]string{"Content-Type": "text/markdown"})
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Fatalf("put: %d %s", rec.Code, rec.Body.String())
}
ents, _ := os.ReadDir(base)
var names []string
for _, e := range ents {
names = append(names, e.Name())
}
t.Logf("on-disk under working/Acme: %v", names)
if _, err := os.Stat(filepath.Join(base, "MixedCaseDir")); err != nil {
t.Errorf("mkdir case NOT preserved (%v)", names)
}
if _, err := os.Stat(filepath.Join(base, "UPPER-Name.MD")); err != nil {
t.Errorf("PUT case NOT preserved (%v)", names)
}
}

View file

@ -1670,7 +1670,7 @@ body.is-elevated::after {
</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.27-beta · 2026-06-08 23:50:41 · 0326d46</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-09 15:30:13 · 237c353</span></span>
</div>
</div>
<div class="header-right">