User report: project root listings showed both "Archive" (PascalCase on
disk) and "archive (empty)" (lowercase virtual) — confusing duplicates.
This sweep:
1. Test fixture migrated to lowercase canonical folder names.
tests/data/test-archive.sh now creates archive/, received/, issued/
on disk. Three projects also get human-friendly .zddc titles
("Wabash Industrial Refit — Phase 1", etc.), and Project-3 carries
a display: override demonstrating the new map. Party names
(PartyA/B/C) stay unchanged — non-canonical.
2. New .zddc display: schema. Maps a child entry's on-disk name to a
human-friendly label. The on-disk name stays canonical (lowercase
for project-root folders); only the rendered label changes. Match
is case-insensitive. Example:
display:
archive: "Records"
working: "In-Progress"
No upward cascade — a parent .zddc doesn't relabel grand-children;
each directory sets display: on its own children.
3. listing.FileInfo gets a DisplayName field. fs.ListDirectory reads
the directory's .zddc display map and stamps DisplayName per entry.
The field is omitempty so listings without overrides stay
byte-identical to before.
4. Virtual canonical project-root folders (archive/working/staging/
reviewing) are now emitted by zddc-server (fs.ListDirectory) at any
project root where the on-disk variant is absent in any case. This
replaces the client-side injection in browse and lets the display:
map apply to virtual entries the same way it applies to real ones.
Browse drops its withVirtualCanonicals helper; the loader carries
display_name through from the server's listing.
5. Archive app project picker dropdown shows the .zddc title of each
project (sourced from ProjectInfo.Title in the server's project
list), falling back to the folder name when no title is set. When
they differ, the folder name is rendered in muted mono after the
title for traceability. data-name still carries the canonical
folder name so URL state stays stable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
6.2 KiB
JavaScript
163 lines
6.2 KiB
JavaScript
// loader.js — fetches directory entries for either source mode.
|
|
//
|
|
// Server mode: GET <urlPath> with Accept: application/json. zddc-server
|
|
// (and Caddy's built-in browse, which we mirror) returns an array of
|
|
// FileInfo {name, size, url, mod_time, mode, is_dir, is_symlink}.
|
|
//
|
|
// FS-API mode: enumerate a FileSystemDirectoryHandle's children. No
|
|
// network involved; works on local folders the user picked.
|
|
(function () {
|
|
'use strict';
|
|
|
|
var state = window.app.state;
|
|
|
|
function splitExt(name) {
|
|
var i = name.lastIndexOf('.');
|
|
if (i <= 0 || i === name.length - 1) return '';
|
|
return name.substring(i + 1).toLowerCase();
|
|
}
|
|
|
|
// Build a raw entry from the server's FileInfo shape.
|
|
function fromServerEntry(e) {
|
|
// Server returns directory names with a trailing "/". Strip
|
|
// it for display; the is_dir flag is the canonical signal.
|
|
var name = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
|
|
// displayName is the friendlier label set by the parent .zddc
|
|
// `display:` map (when present). The on-disk basename stays in
|
|
// .name so URL composition (pathFor) and the chevron's title
|
|
// attribute still reflect the real folder name.
|
|
var displayName = (typeof e.display_name === 'string' && e.display_name)
|
|
? e.display_name
|
|
: '';
|
|
return {
|
|
name: name,
|
|
displayName: displayName,
|
|
isDir: e.is_dir,
|
|
size: e.size || 0,
|
|
modTime: e.mod_time ? new Date(e.mod_time) : null,
|
|
ext: e.is_dir ? '' : splitExt(name),
|
|
url: e.url || null,
|
|
// FS-API specific (null in server mode):
|
|
handle: null
|
|
};
|
|
}
|
|
|
|
// Build a raw entry from a FileSystemHandle.
|
|
async function fromHandle(handle) {
|
|
var name = handle.name;
|
|
var isDir = handle.kind === 'directory';
|
|
var size = 0;
|
|
var modTime = null;
|
|
if (!isDir) {
|
|
try {
|
|
var f = await handle.getFile();
|
|
size = f.size;
|
|
modTime = new Date(f.lastModified);
|
|
} catch (_e) {
|
|
// permission lost; leave size/modTime defaults
|
|
}
|
|
}
|
|
return {
|
|
name: name,
|
|
isDir: isDir,
|
|
size: size,
|
|
modTime: modTime,
|
|
ext: isDir ? '' : splitExt(name),
|
|
url: null,
|
|
handle: handle
|
|
};
|
|
}
|
|
|
|
// Fetch children of a directory in server mode.
|
|
// path must end with '/' so the request hits the directory route.
|
|
//
|
|
// 404 is treated as "empty directory" rather than a hard error.
|
|
// A directory that doesn't exist on the server (e.g. a fresh
|
|
// project's working/ before any drafts have been created, or a
|
|
// dir deleted between listing and expand) is functionally
|
|
// indistinguishable from an empty one for tree-rendering purposes.
|
|
// Server-side, zddc-server already returns 200 + [] for canonical
|
|
// project folders that are missing on disk; this fallback covers
|
|
// the same UX for anything else and for non-zddc-server backends.
|
|
async function fetchServerChildren(path) {
|
|
if (!path.endsWith('/')) path += '/';
|
|
var resp = await fetch(path, {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin'
|
|
});
|
|
if (resp.status === 404) {
|
|
return [];
|
|
}
|
|
if (!resp.ok) {
|
|
throw new Error('HTTP ' + resp.status + ' fetching ' + path);
|
|
}
|
|
var data = await resp.json();
|
|
if (!Array.isArray(data)) {
|
|
throw new Error('Unexpected response shape from ' + path);
|
|
}
|
|
return data.map(fromServerEntry);
|
|
}
|
|
|
|
// Enumerate a FileSystemDirectoryHandle's immediate children.
|
|
async function fetchFsChildren(dirHandle) {
|
|
var entries = [];
|
|
for await (var [_name, handle] of dirHandle.entries()) {
|
|
entries.push(await fromHandle(handle));
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
// Probe whether THIS page is being served by zddc-server (or any
|
|
// server that responds to JSON listing requests). If so, switch to
|
|
// server mode automatically and load the current directory.
|
|
async function autoDetectServerMode() {
|
|
// Only attempt when running over http(s) and the location's
|
|
// path looks like a directory. Probing on file:// is pointless.
|
|
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
|
|
return false;
|
|
}
|
|
// Strip any /<tool>.html from the path to get the directory.
|
|
var path = location.pathname;
|
|
// If the URL points at the browse.html itself, the directory
|
|
// is the parent. If it's a directory ending in '/', use it.
|
|
var dirPath;
|
|
if (path.endsWith('/')) {
|
|
dirPath = path;
|
|
} else {
|
|
// e.g. '/some/dir/browse.html' → '/some/dir/'
|
|
var slash = path.lastIndexOf('/');
|
|
dirPath = slash >= 0 ? path.substring(0, slash + 1) : '/';
|
|
}
|
|
|
|
try {
|
|
var entries = await fetchServerChildren(dirPath);
|
|
state.source = 'server';
|
|
state.currentPath = dirPath;
|
|
return { entries: entries, path: dirPath };
|
|
} catch (_e) {
|
|
// Not a server-backed page (e.g. opened via file://).
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// JSZip is vendored into the bundle (shared/vendor/jszip.min.js
|
|
// is concatenated ahead of init.js by build.sh), so it's always
|
|
// already attached to window.JSZip by the time any tree code runs.
|
|
// We keep the helper because tree.js calls it before reaching for
|
|
// window.JSZip; if the bundle is ever rebuilt without the vendor
|
|
// copy this will throw a clear error rather than silently failing.
|
|
function ensureJSZip() {
|
|
if (window.JSZip) return Promise.resolve();
|
|
return Promise.reject(new Error(
|
|
'JSZip not bundled — rebuild browse with shared/vendor/jszip.min.js'));
|
|
}
|
|
|
|
// Public API
|
|
window.app.modules.loader = {
|
|
fetchServerChildren: fetchServerChildren,
|
|
fetchFsChildren: fetchFsChildren,
|
|
autoDetectServerMode: autoDetectServerMode,
|
|
splitExt: splitExt,
|
|
ensureJSZip: ensureJSZip
|
|
};
|
|
})();
|