Compare commits

..

No commits in common. "7d9317190038cb4bf71521bc20d3bd8c3ffdf63a" and "362f5bd03646c2272a7fcd488de2982a926e95d8" have entirely different histories.

15 changed files with 143 additions and 987 deletions

View file

@ -175,36 +175,6 @@
background-color: var(--bg-hover); 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 { .folder-item.selected {
background-color: var(--bg-selected); background-color: var(--bg-selected);
font-weight: 500; font-weight: 500;

View file

@ -8,191 +8,52 @@
// Store ZIP data for later access // Store ZIP data for later access
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle } 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 * Scan directory and build folder tree with files
*/ */
async function scanDirectory(dirHandle, preserveState = false) { async function scanDirectory(dirHandle, preserveState = false) {
// Preserve which folders were expanded across a rescan (e.g. after a
// ZIP extract) so the user doesn't lose their place.
// Save current state if preserving
let savedExpanded = new Set(); let savedExpanded = new Set();
let savedSelected = new Set(); let savedSelected = new Set();
if (preserveState) { if (preserveState) {
savedExpanded = getExpandedPaths(window.app.folderTree); savedExpanded = getExpandedPaths(window.app.folderTree);
savedSelected = new Set(window.app.selectedFolders); savedSelected = new Set(window.app.selectedFolders);
} }
const myGen = ++scanGen; // Clear ZIP cache
zipCache.clear(); zipCache.clear();
scanStats = { folders: 0, files: 0, current: dirHandle.name, done: false, startedAt: Date.now() };
// Map to store files by folder handle (or ZIP path for virtual folders)
// Root node — render immediately so the pane never sits blank. const foldersMap = new Map();
const root = makeNode(dirHandle, dirHandle.name, null);
root.expanded = true; // Recursively scan
window.app.folderTree = [root]; 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); window.app.modules.store.setFolderTree(window.app.folderTree);
if (!preserveState) {
// Select the root so the grid shows its immediate files at once, if (preserveState) {
// instead of auto-loading the ENTIRE drive (the old behaviour, // Restore expanded state
// which is exactly what stalled on a large share). restoreExpandedPaths(window.app.folderTree, savedExpanded);
window.app.selectedFolders = new Set([root.path]); // Restore selection
window.app.lastSelectedFolderPath = root.path;
window.app.modules.store.setSelectedFolders(window.app.selectedFolders);
} else {
window.app.selectedFolders = savedSelected; window.app.selectedFolders = savedSelected;
// Render without changing selection
window.app.modules.tree.render();
window.app.modules.store.setSelectedFolders(savedSelected); 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();
} }
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();
} }
/** /**
@ -222,129 +83,43 @@
} }
} }
// Read ONE directory's immediate entries: files into node.files, child /**
// directories into node.children (left 'pending' for the BFS to descend). * Recursively scan a folder
// A .zip becomes an expandable zip-root child, scanned inline (its */
// contents are already in memory once the entry is read). Idempotent: async function scanFolder(dirHandle, foldersMap, currentPath) {
// only a 'pending' node is scanned, so concurrent callers (background + const items = [];
// 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 { try {
for await (const entry of node.handle.values()) { for await (const entry of dirHandle.values()) {
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
if (entry.kind === 'file') { if (entry.kind === 'file') {
const fo = await createFileObject(entry, node.handle); // Create file object
if (!fo) continue; const file = await createFileObject(entry, dirHandle);
fo.folderPath = node.path; if (file) {
files.push(fo); items.push(file);
if (scanStats) scanStats.files++;
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') { // Check if it's a ZIP file - scan its contents
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension); if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
const zipPath = node.path + '/' + zipName; await scanZipFile(file, foldersMap, currentPath, items);
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') { } else if (entry.kind === 'directory') {
const childPath = node.path + '/' + entry.name; // Add directory reference
childDirs.push(makeNode(entry, childPath, node)); items.push({
if (scanStats) scanStats.folders++; handle: entry,
isDirectory: true
});
// Recursively scan subdirectory
const childPath = currentPath + '/' + entry.name;
await scanFolder(entry, foldersMap, childPath);
} }
} }
} catch (err) { } catch (err) {
node.scanError = true; console.error('Error scanning folder:', dirHandle.name, err);
reportScanError(node.path, err);
} }
node.files = files;
node.fileCount = files.length; // Store files for this folder
node.children = childDirs; foldersMap.set(dirHandle, items);
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;
} }
/** /**
@ -654,7 +429,6 @@
// Export module // Export module
window.app.modules.scanner = { window.app.modules.scanner = {
scanDirectory, scanDirectory,
ensureScanned,
getZipCache, getZipCache,
extractZip extractZip
}; };

View file

@ -25,48 +25,6 @@
updateSelectedCount(); 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 * Create a folder element
*/ */
@ -75,11 +33,6 @@
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'folder-item'; 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.dataset.path = folder.path;
item.style.paddingLeft = `${level * 1.5}rem`; item.style.paddingLeft = `${level * 1.5}rem`;
@ -88,13 +41,10 @@
item.classList.add('selected'); item.classList.add('selected');
} }
// Toggle button: shown when the folder has children OR hasn't been // Toggle button (if has children)
// scanned yet (it might have children — expanding triggers its scan).
const toggle = document.createElement('span'); const toggle = document.createElement('span');
toggle.className = 'folder-toggle'; toggle.className = 'folder-toggle';
const mightHaveChildren = (folder.children && folder.children.length > 0) if (folder.children && folder.children.length > 0) {
|| folder.scanState === 'pending';
if (mightHaveChildren) {
toggle.textContent = folder.expanded ? '▼' : '▶'; toggle.textContent = folder.expanded ? '▼' : '▶';
toggle.addEventListener('click', (e) => { toggle.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@ -124,11 +74,10 @@
name.textContent = folder.name; name.textContent = folder.name;
item.appendChild(name); item.appendChild(name);
// Subfolder / file counts (immediate). Greyed via the row's .scanning // File count
// class until the subtree is fully scanned.
const count = document.createElement('span'); const count = document.createElement('span');
count.className = 'folder-count'; count.className = 'folder-count';
populateCount(count, folder); count.textContent = `(${folder.fileCount || 0})`;
item.appendChild(count); item.appendChild(count);
// Extract button for ZIP roots // Extract button for ZIP roots
@ -338,7 +287,7 @@
*/ */
function toggleFolder(folder, recursive = false) { function toggleFolder(folder, recursive = false) {
folder.expanded = !folder.expanded; folder.expanded = !folder.expanded;
if (recursive && folder.children) { if (recursive && folder.children) {
// Recursively expand/collapse all children // Recursively expand/collapse all children
const newState = folder.expanded; const newState = folder.expanded;
@ -350,16 +299,8 @@
} }
folder.children.forEach(setAllExpanded); folder.children.forEach(setAllExpanded);
} }
render(); 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,7 +58,6 @@
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span> <span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div> </div>
</div> </div>
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
<div id="folderTree" class="folder-tree"> <div id="folderTree" class="folder-tree">
<!-- Dynamically populated --> <!-- Dynamically populated -->
</div> </div>

View file

@ -807,19 +807,6 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return 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 // Auth check endpoints — machine-only forward_auth targets used by
// upstream proxies (e.g. the dev-shell pod's Caddy in front of // upstream proxies (e.g. the dev-shell pod's Caddy in front of
// code-server) to gate routes on root-admin status. Handled before // code-server) to gate routes on root-admin status. Handled before

View file

@ -2665,7 +2665,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.27-beta · 2026-06-09 15:30:13 · 237c353</span></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>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use 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>

View file

@ -2772,7 +2772,7 @@ li.CodeMirror-hint-active {
</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.27-beta · 2026-06-09 15:30:14 · 237c353</span></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>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <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> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>

View file

@ -1293,36 +1293,6 @@ body.is-elevated::after {
background-color: var(--bg-hover); 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 { .folder-item.selected {
background-color: var(--bg-selected); background-color: var(--bg-selected);
font-weight: 500; font-weight: 500;
@ -1906,7 +1876,7 @@ body.is-elevated::after {
</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.27-beta · 2026-06-09 15:30:13 · 237c353</span></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>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button> <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> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;"></button>
@ -1938,7 +1908,6 @@ body.is-elevated::after {
<span id="selectedFoldersCount" class="folder-count">0 folders selected</span> <span id="selectedFoldersCount" class="folder-count">0 folders selected</span>
</div> </div>
</div> </div>
<div id="scanStatus" class="scan-status" aria-live="polite"></div>
<div id="folderTree" class="folder-tree"> <div id="folderTree" class="folder-tree">
<!-- Dynamically populated --> <!-- Dynamically populated -->
</div> </div>
@ -6414,191 +6383,52 @@ X.B(E,Y);return E}return J}())
// Store ZIP data for later access // Store ZIP data for later access
const zipCache = new Map(); // path -> { zip: JSZip, fileHandle: FileSystemFileHandle } 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 * Scan directory and build folder tree with files
*/ */
async function scanDirectory(dirHandle, preserveState = false) { async function scanDirectory(dirHandle, preserveState = false) {
// Preserve which folders were expanded across a rescan (e.g. after a
// ZIP extract) so the user doesn't lose their place.
// Save current state if preserving
let savedExpanded = new Set(); let savedExpanded = new Set();
let savedSelected = new Set(); let savedSelected = new Set();
if (preserveState) { if (preserveState) {
savedExpanded = getExpandedPaths(window.app.folderTree); savedExpanded = getExpandedPaths(window.app.folderTree);
savedSelected = new Set(window.app.selectedFolders); savedSelected = new Set(window.app.selectedFolders);
} }
const myGen = ++scanGen; // Clear ZIP cache
zipCache.clear(); zipCache.clear();
scanStats = { folders: 0, files: 0, current: dirHandle.name, done: false, startedAt: Date.now() };
// Map to store files by folder handle (or ZIP path for virtual folders)
// Root node — render immediately so the pane never sits blank. const foldersMap = new Map();
const root = makeNode(dirHandle, dirHandle.name, null);
root.expanded = true; // Recursively scan
window.app.folderTree = [root]; 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); window.app.modules.store.setFolderTree(window.app.folderTree);
if (!preserveState) {
// Select the root so the grid shows its immediate files at once, if (preserveState) {
// instead of auto-loading the ENTIRE drive (the old behaviour, // Restore expanded state
// which is exactly what stalled on a large share). restoreExpandedPaths(window.app.folderTree, savedExpanded);
window.app.selectedFolders = new Set([root.path]); // Restore selection
window.app.lastSelectedFolderPath = root.path;
window.app.modules.store.setSelectedFolders(window.app.selectedFolders);
} else {
window.app.selectedFolders = savedSelected; window.app.selectedFolders = savedSelected;
// Render without changing selection
window.app.modules.tree.render();
window.app.modules.store.setSelectedFolders(savedSelected); 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();
} }
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();
} }
/** /**
@ -6628,129 +6458,43 @@ X.B(E,Y);return E}return J}())
} }
} }
// Read ONE directory's immediate entries: files into node.files, child /**
// directories into node.children (left 'pending' for the BFS to descend). * Recursively scan a folder
// A .zip becomes an expandable zip-root child, scanned inline (its */
// contents are already in memory once the entry is read). Idempotent: async function scanFolder(dirHandle, foldersMap, currentPath) {
// only a 'pending' node is scanned, so concurrent callers (background + const items = [];
// 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 { try {
for await (const entry of node.handle.values()) { for await (const entry of dirHandle.values()) {
if (myGen !== scanGen) { node.scanState = 'pending'; return; } // cancelled
if (entry.kind === 'file') { if (entry.kind === 'file') {
const fo = await createFileObject(entry, node.handle); // Create file object
if (!fo) continue; const file = await createFileObject(entry, dirHandle);
fo.folderPath = node.path; if (file) {
files.push(fo); items.push(file);
if (scanStats) scanStats.files++;
if (fo.extension === 'zip' && typeof JSZip !== 'undefined') { // Check if it's a ZIP file - scan its contents
const zipName = zddc.joinExtension(fo.originalFilename, fo.extension); if (file.extension === 'zip' && typeof JSZip !== 'undefined') {
const zipPath = node.path + '/' + zipName; await scanZipFile(file, foldersMap, currentPath, items);
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') { } else if (entry.kind === 'directory') {
const childPath = node.path + '/' + entry.name; // Add directory reference
childDirs.push(makeNode(entry, childPath, node)); items.push({
if (scanStats) scanStats.folders++; handle: entry,
isDirectory: true
});
// Recursively scan subdirectory
const childPath = currentPath + '/' + entry.name;
await scanFolder(entry, foldersMap, childPath);
} }
} }
} catch (err) { } catch (err) {
node.scanError = true; console.error('Error scanning folder:', dirHandle.name, err);
reportScanError(node.path, err);
} }
node.files = files;
node.fileCount = files.length; // Store files for this folder
node.children = childDirs; foldersMap.set(dirHandle, items);
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;
} }
/** /**
@ -7060,7 +6804,6 @@ X.B(E,Y);return E}return J}())
// Export module // Export module
window.app.modules.scanner = { window.app.modules.scanner = {
scanDirectory, scanDirectory,
ensureScanned,
getZipCache, getZipCache,
extractZip extractZip
}; };
@ -7094,48 +6837,6 @@ X.B(E,Y);return E}return J}())
updateSelectedCount(); 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 * Create a folder element
*/ */
@ -7144,11 +6845,6 @@ X.B(E,Y);return E}return J}())
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'folder-item'; 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.dataset.path = folder.path;
item.style.paddingLeft = `${level * 1.5}rem`; item.style.paddingLeft = `${level * 1.5}rem`;
@ -7157,13 +6853,10 @@ X.B(E,Y);return E}return J}())
item.classList.add('selected'); item.classList.add('selected');
} }
// Toggle button: shown when the folder has children OR hasn't been // Toggle button (if has children)
// scanned yet (it might have children — expanding triggers its scan).
const toggle = document.createElement('span'); const toggle = document.createElement('span');
toggle.className = 'folder-toggle'; toggle.className = 'folder-toggle';
const mightHaveChildren = (folder.children && folder.children.length > 0) if (folder.children && folder.children.length > 0) {
|| folder.scanState === 'pending';
if (mightHaveChildren) {
toggle.textContent = folder.expanded ? '▼' : '▶'; toggle.textContent = folder.expanded ? '▼' : '▶';
toggle.addEventListener('click', (e) => { toggle.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
@ -7193,11 +6886,10 @@ X.B(E,Y);return E}return J}())
name.textContent = folder.name; name.textContent = folder.name;
item.appendChild(name); item.appendChild(name);
// Subfolder / file counts (immediate). Greyed via the row's .scanning // File count
// class until the subtree is fully scanned.
const count = document.createElement('span'); const count = document.createElement('span');
count.className = 'folder-count'; count.className = 'folder-count';
populateCount(count, folder); count.textContent = `(${folder.fileCount || 0})`;
item.appendChild(count); item.appendChild(count);
// Extract button for ZIP roots // Extract button for ZIP roots
@ -7407,7 +7099,7 @@ X.B(E,Y);return E}return J}())
*/ */
function toggleFolder(folder, recursive = false) { function toggleFolder(folder, recursive = false) {
folder.expanded = !folder.expanded; folder.expanded = !folder.expanded;
if (recursive && folder.children) { if (recursive && folder.children) {
// Recursively expand/collapse all children // Recursively expand/collapse all children
const newState = folder.expanded; const newState = folder.expanded;
@ -7419,16 +7111,8 @@ X.B(E,Y);return E}return J}())
} }
folder.children.forEach(setAllExpanded); folder.children.forEach(setAllExpanded);
} }
render(); 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> </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.27-beta · 2026-06-09 15:30:13 · 237c353</span></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>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -2718,7 +2718,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.27-beta · 2026-06-09 15:30:13 · 237c353</span></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>
</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;

View file

@ -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.27-beta · 2026-06-09 15:30:13 · 237c353 archive=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
transmittal=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 transmittal=v0.0.27-beta · 2026-06-08 23:50:40 · 0326d46
classifier=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 classifier=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
landing=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 landing=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
form=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 form=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
tables=v0.0.27-beta · 2026-06-09 15:30:13 · 237c353 tables=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46
browse=v0.0.27-beta · 2026-06-09 15:30:14 · 237c353 browse=v0.0.27-beta · 2026-06-08 23:50:41 · 0326d46

View file

@ -1,126 +0,0 @@
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

@ -1,42 +0,0 @@
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,34 +918,3 @@ func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
t.Errorf("history should NOT follow a cross-dir move; err=%v", err) 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> </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.27-beta · 2026-06-09 15:30:13 · 237c353</span></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>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">