feat(zddc): canonical lowercase + .zddc display map + archive project titles

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>
This commit is contained in:
ZDDC 2026-05-11 13:03:53 -05:00
parent ee67b9e596
commit e85d5fc660
12 changed files with 244 additions and 82 deletions

View file

@ -848,6 +848,15 @@ input[type="checkbox"] {
cursor: pointer;
}
/* Folder-name hint after the friendly title shown only when the
project's .zddc declares a different `title:`. Muted so the title
reads first; the folder name is reference info. */
.preset-project-folder {
color: var(--text-muted);
font-size: 0.78rem;
font-family: var(--font-mono);
}
.preset-footer-actions {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border);

View file

@ -128,6 +128,9 @@
// Fetch the server's ACL-filtered project list so we can drop any
// listed names the user doesn't actually have access to (and so
// the empty-projects= "include everything" mode has a list to use).
// ProjectInfo carries an optional `title` field sourced from each
// project's .zddc — capture it so the dropdown can show the
// human-friendly label instead of the folder name.
var serverNames = null;
try {
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
@ -136,6 +139,13 @@
if (Array.isArray(serverProjects) && serverProjects.length > 0
&& serverProjects[0] && typeof serverProjects[0].name === 'string') {
serverNames = new Set(serverProjects.map(function(p) { return p.name; }));
var titles = {};
serverProjects.forEach(function (p) {
if (p && typeof p.title === 'string' && p.title) {
titles[p.name] = p.title;
}
});
window.app.projectTitles = titles;
}
}
} catch (e) {

View file

@ -46,13 +46,24 @@
var selected = new Set(window.app.visibleProjects || []);
var known = getKnownProjects().slice().sort();
// Show the human-friendly title from each project's .zddc
// when present (captured during auto-detect into
// window.app.projectTitles), falling back to the folder name.
// The data-name attribute always carries the canonical folder
// name so URL state stays stable regardless of label.
var titles = window.app.projectTitles || {};
var projectsHtml = known.map(name => {
var checked = selected.has(name) ? ' checked' : '';
var n = escapeHtml(name);
var label = titles[name] || name;
var nAttr = escapeHtml(name);
var nLabel = escapeHtml(label);
var hint = (label !== name)
? ' <span class="preset-project-folder">(' + escapeHtml(name) + ')</span>'
: '';
return '<div class="preset-project-item">'
+ '<label class="preset-project-label">'
+ '<input type="checkbox" class="preset-checkbox" data-name="' + n + '"' + checked + '>'
+ ' ' + n
+ '<input type="checkbox" class="preset-checkbox" data-name="' + nAttr + '"' + checked + '>'
+ ' ' + nLabel + hint
+ '</label>'
+ '</div>';
}).join('');

View file

@ -8,49 +8,16 @@
var tree = window.app.modules.tree;
var events = window.app.modules.events;
// Canonical folders that should appear at the root of a project
// view even if they don't yet exist on disk. Matches the four
// stage cards on the project landing page. zddc-server returns an
// empty listing for these paths (see commit 3fc3717), so
// navigating into a virtual folder works without 404.
var CANONICAL_PROJECT_FOLDERS = ['archive', 'working', 'staging', 'reviewing'];
// Decide whether `path` looks like a project root — i.e. exactly
// one path segment after the leading slash. /Project-1/ → yes;
// / → no; /Project-1/working/ → no.
function isProjectRoot(path) {
if (!path || path === '/') return false;
var trimmed = path.replace(/^\/+|\/+$/g, '');
if (!trimmed) return false;
return trimmed.indexOf('/') < 0;
}
// Merge virtual entries for any canonical folders absent from the
// server's listing. Each virtual entry is shaped like a normal
// directory entry so the tree renderer treats it the same way.
function withVirtualCanonicals(entries, path) {
if (!isProjectRoot(path)) return entries;
var present = Object.create(null);
entries.forEach(function (e) { if (e.isDir) present[e.name] = true; });
var augmented = entries.slice();
CANONICAL_PROJECT_FOLDERS.forEach(function (name) {
if (!present[name]) {
augmented.push({
name: name,
isDir: true,
size: 0,
modTime: null,
ext: '',
url: path.replace(/\/$/, '') + '/' + name + '/',
virtual: true
});
}
});
return augmented;
}
// Virtual canonical folder injection used to live here (browse
// appended archive/working/staging/reviewing entries at a project
// root when missing). zddc-server now emits them in the listing
// directly so the .zddc `display:` map can override their labels
// the same as real entries. This pass-through stub keeps the
// events.js rescope contract intact without doing any merging.
function passThroughEntries(entries) { return entries; }
// Expose for events.js's client-side rescope on dblclick.
window.app.modules.augmentRoot = withVirtualCanonicals;
window.app.modules.augmentRoot = passThroughEntries;
async function bootstrap() {
events.init();
@ -61,8 +28,7 @@
// state with the "Select Directory" button.
var detected = await loader.autoDetectServerMode();
if (detected) {
var entries = withVirtualCanonicals(detected.entries, detected.path);
tree.setRoot(entries);
tree.setRoot(detected.entries);
events.showBrowseRoot();
tree.render();
events.statusInfo('Loaded ' + detected.entries.length + ' item'
@ -83,7 +49,7 @@
window.app.state.currentPath = path;
window.app.state.selectedId = null;
window.app.state.lastPreviewedNodeId = null;
tree.setRoot(withVirtualCanonicals(es, path));
tree.setRoot(es);
tree.render();
var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = '';

View file

@ -356,13 +356,10 @@
// the new root doesn't carry stale highlight state.
state.selectedId = null;
state.lastPreviewedNodeId = null;
// Inject virtual canonical folders at the new scope if it's a
// project root. (app.js owns this helper; expose via window.app.)
var augment = window.app.modules.augmentRoot;
var rootEntries = (typeof augment === 'function')
? augment(entries, url)
: entries;
tree.setRoot(rootEntries);
// Virtual canonical folders are emitted by zddc-server itself
// (so .zddc display: overrides apply uniformly); no client-side
// merge needed.
tree.setRoot(entries);
tree.render();
// Reset the preview pane so the user sees an "empty selection"
// state at the new scope instead of the previous file.

View file

@ -21,13 +21,21 @@
function fromServerEntry(e) {
// Server returns directory names with a trailing "/". Strip
// it for display; the is_dir flag is the canonical signal.
var displayName = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
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: displayName,
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(displayName),
ext: e.is_dir ? '' : splitExt(name),
url: e.url || null,
// FS-API specific (null in server mode):
handle: null

View file

@ -21,6 +21,10 @@
var node = {
id: id,
name: raw.name,
// displayName is the rendered label when set by the parent
// .zddc display: map. Sort + lookup continues to use .name
// (the on-disk basename) so URL composition stays canonical.
displayName: raw.displayName || '',
isDir: raw.isDir,
size: raw.size,
modTime: raw.modTime,
@ -178,7 +182,7 @@
+ '<span class="' + chevronClass + '"></span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>'
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
+ escapeHtml(node.name) + '</span>'
+ escapeHtml(node.displayName || node.name) + '</span>'
+ virtualHint
+ '</div>';
}

View file

@ -3,7 +3,7 @@
# testing of master + cache + mirror.
#
# The fixture mimics the SHAPE of a real ZDDC archive (project →
# Archive → party → Received|Issued → dated transmittal folder →
# archive → party → received|issued → dated transmittal folder →
# tracking-number-named files) but contains zero identifying data.
# Every file's content is a 4-line metadata block:
#
@ -329,13 +329,24 @@ render_zip() {
}
# Write a per-directory .zddc ACL config. Synthetic emails only.
# Project role accepts an optional 3rd arg — a human-friendly project
# title that surfaces in landing/archive UIs. A 4th arg embeds a
# display: block exercising the canonical-folder display-override
# feature (e.g. show "Records" instead of "archive" in the project
# root listing).
write_zddc_config() {
out="$1"
role="${2:-default}" # default | party | project
title="${3:-}"
extra_yaml="${4:-}"
case "$role" in
project|party)
cat > "$out" <<EOF
title: "Synthetic ${role} ACL — test fixture"
if [ -z "$title" ]; then
title="Synthetic ${role} ACL — test fixture"
fi
{
cat <<EOF
title: "$title"
admins:
- $ADMIN_EMAIL
acl:
@ -345,10 +356,14 @@ acl:
"bob@example.com": rw
"carol@example.com": r
EOF
if [ -n "$extra_yaml" ]; then
printf '%s\n' "$extra_yaml"
fi
} > "$out"
;;
*)
cat > "$out" <<EOF
title: "Synthetic root ACL — test fixture"
title: "ZDDC test fixture — synthetic root"
admins:
- $ADMIN_EMAIL
acl:
@ -360,6 +375,35 @@ EOF
esac
}
# Per-project display titles. Stable across rebuilds so the dropdown
# in the archive app has recognisable, human-friendly names. The
# third project also gets a display: override on canonical folders to
# exercise that feature.
project_title() {
case "$1" in
Project-1) echo "Wabash Industrial Refit — Phase 1" ;;
Project-2) echo "North Avenue Transit Spur" ;;
Project-3) echo "Lincoln Square Substation Upgrade" ;;
*) echo "Synthetic project — $1" ;;
esac
}
project_extra_yaml() {
case "$1" in
Project-3)
# Exercise the display-override feature on canonical
# project-root folders. The on-disk names stay lowercase
# (canonical); the UI shows the friendly label.
cat <<'EOF'
display:
archive: "Records"
working: "In-Progress"
staging: "Outbox"
reviewing: "Pending Responses"
EOF
;;
esac
}
# ---------------------------------------------------------------------
# build
# ---------------------------------------------------------------------
@ -398,22 +442,23 @@ cmd_build() {
proj_dir="$TARGET/$project"
mkdir -p "$proj_dir"
chmod 0777 "$proj_dir"
write_zddc_config "$proj_dir/.zddc" project
write_zddc_config "$proj_dir/.zddc" project \
"$(project_title "$project")" "$(project_extra_yaml "$project")"
for party in $parties; do
party_dir="$proj_dir/Archive/$party"
mkdir -p "$party_dir/Received" "$party_dir/Issued"
chmod 0777 "$party_dir" "$party_dir/Received" "$party_dir/Issued"
party_dir="$proj_dir/archive/$party"
mkdir -p "$party_dir/received" "$party_dir/issued"
chmod 0777 "$party_dir" "$party_dir/received" "$party_dir/issued"
write_zddc_config "$party_dir/.zddc" party
i=0
while [ "$i" -lt "$per_party" ]; do
i=$((i + 1))
# Alternate Received / Issued.
# Alternate received / issued.
if [ $((i % 2)) = 0 ]; then
bucket="Received"
bucket="received"
else
bucket="Issued"
bucket="issued"
fi
# Transmittal envelope: <date>_<tracking> (<status>) - <title>
t_track=$(make_tracking "$party")

View file

@ -68,6 +68,11 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// when no entries match — clients (browse, archive) expect an array.
result := make([]listing.FileInfo, 0, len(entries))
// Display overrides for this directory's children, sourced from
// THIS directory's .zddc `display:` map. Built once and looked up
// case-insensitively per entry. Empty map = no overrides.
displayMap := readDisplayMap(absDir)
for _, entry := range entries {
name := entry.Name()
@ -82,6 +87,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
}
isDir := entry.IsDir()
displayName := lookupDisplay(displayMap, name)
if isDir {
// ACL check for subdirectory
@ -96,12 +102,13 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
continue // omit denied directories silently
}
fi := listing.FileInfo{
Name: name + "/",
Size: info.Size(),
URL: baseURL + url.PathEscape(name) + "/",
ModTime: info.ModTime(),
Mode: uint32(info.Mode()),
IsDir: true,
Name: name + "/",
Size: info.Size(),
URL: baseURL + url.PathEscape(name) + "/",
ModTime: info.ModTime(),
Mode: uint32(info.Mode()),
IsDir: true,
DisplayName: displayName,
}
result = append(result, fi)
continue
@ -109,12 +116,13 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// Regular file
fi := listing.FileInfo{
Name: name,
Size: info.Size(),
URL: baseURL + url.PathEscape(name),
ModTime: info.ModTime(),
Mode: uint32(info.Mode()),
IsDir: false,
Name: name,
Size: info.Size(),
URL: baseURL + url.PathEscape(name),
ModTime: info.ModTime(),
Mode: uint32(info.Mode()),
IsDir: false,
DisplayName: displayName,
}
result = append(result, fi)
}
@ -128,9 +136,59 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
result = append(result, syn)
}
// At a project root, surface the four canonical project folders
// (archive/working/staging/reviewing) as virtual entries when no
// on-disk variant exists in any case. The browse client previously
// did this client-side; moving it server-side lets the directory's
// `display:` map apply to virtual entries the same way it applies
// to real ones.
result = append(result, virtualCanonicalFolders(fsRoot, dirPath, baseURL, result, displayMap)...)
return result, nil
}
// virtualCanonicalFolders returns synthetic entries for any canonical
// project-root folder absent from real. Fires only when dirPath is a
// depth-1 directory under fsRoot (the project root); other depths get
// an empty slice. Case-insensitive presence check so an on-disk
// "Archive" suppresses the lowercase "archive" virtual entry.
func virtualCanonicalFolders(fsRoot, dirPath, baseURL string,
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
rel := strings.Trim(filepath.ToSlash(dirPath), "/")
if rel == "" {
return nil
}
parts := strings.Split(rel, "/")
if len(parts) != 1 {
return nil // not a project root
}
present := make(map[string]bool, len(real))
for _, fi := range real {
if !fi.IsDir {
continue
}
bare := strings.TrimSuffix(fi.Name, "/")
present[strings.ToLower(bare)] = true
}
var synth []listing.FileInfo
for _, name := range zddc.ProjectRootFolders {
if present[name] {
continue
}
synth = append(synth, listing.FileInfo{
Name: name + "/",
URL: baseURL + url.PathEscape(name) + "/",
IsDir: true,
Virtual: true,
DisplayName: lookupDisplay(displayMap, name),
})
}
return synth
}
// virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
// should be appended to a working/ listing, or (zero, false) when no
// synthetic entry applies.
@ -166,3 +224,32 @@ func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []l
Virtual: true,
}, true
}
// readDisplayMap parses dirAbs/.zddc and returns its Display map (or
// nil when the file doesn't exist or has no display block). All keys
// are case-folded to lowercase so lookupDisplay's case-insensitive
// match is a simple map read.
func readDisplayMap(dirAbs string) map[string]string {
zf, err := zddc.ParseFile(filepath.Join(dirAbs, ".zddc"))
if err != nil || len(zf.Display) == 0 {
return nil
}
out := make(map[string]string, len(zf.Display))
for k, v := range zf.Display {
if v == "" {
continue
}
out[strings.ToLower(strings.TrimSpace(k))] = v
}
return out
}
// lookupDisplay returns the custom display label for name (matched
// case-insensitively against displayMap's keys), or "" when no
// override applies.
func lookupDisplay(displayMap map[string]string, name string) string {
if len(displayMap) == 0 {
return ""
}
return displayMap[strings.ToLower(name)]
}

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 17:41:46 · d052e9f-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 18:02:37 · ee67b9e-dirty</span></span>
</div>
</div>
<div class="header-right">

View file

@ -21,4 +21,11 @@ type FileInfo struct {
// listings drop the synthetic entry. Clients can use this flag to
// render the entry differently (placeholder badge, drop-target hint).
Virtual bool `json:"virtual,omitempty"`
// DisplayName is the human-friendly label rendered by clients in
// place of Name when set. Sourced from the directory's .zddc
// `display:` map (matched case-insensitively on the on-disk
// basename). Empty = render Name. Never empty for an explicit
// override — clients shouldn't infer a default from absence here.
DisplayName string `json:"display_name,omitempty"`
}

View file

@ -126,6 +126,24 @@ type ZddcFile struct {
// directory that hosts a table declares it directly.
Tables map[string]string `yaml:"tables,omitempty" json:"tables,omitempty"`
// Display maps a child entry's on-disk name to a human-friendly
// label rendered by browse / archive / landing in place of the raw
// folder name. The on-disk name remains canonical (lowercase for
// the project-root folders); only the rendered string changes.
//
// Match is case-insensitive on the key. Example, on Project-3/.zddc:
//
// display:
// archive: "Records"
// working: "In-Progress"
//
// Effect: project-3 listings show "Records" and "In-Progress" in
// the tree, but URLs still resolve at /Project-3/archive/ and
// /Project-3/working/. No upward cascade in v1 — a parent .zddc
// doesn't relabel grand-children. Operators set display: on the
// directory whose entries they want renamed.
Display map[string]string `yaml:"display,omitempty" json:"display,omitempty"`
// Roles are named principal groups available at this level and below.
// See Role for member syntax.
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`