From e85d5fc66042ec356e615d10c363df142552a4fb Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 11 May 2026 13:03:53 -0500 Subject: [PATCH] feat(zddc): canonical lowercase + .zddc display map + archive project titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- archive/css/components.css | 9 +++ archive/js/app.js | 10 +++ archive/js/presets.js | 17 ++++- browse/js/app.js | 54 +++------------ browse/js/events.js | 11 ++- browse/js/loader.js | 14 +++- browse/js/tree.js | 6 +- tests/data/test-archive.sh | 67 +++++++++++++++--- zddc/internal/fs/tree.go | 111 ++++++++++++++++++++++++++---- zddc/internal/handler/tables.html | 2 +- zddc/internal/listing/types.go | 7 ++ zddc/internal/zddc/file.go | 18 +++++ 12 files changed, 244 insertions(+), 82 deletions(-) diff --git a/archive/css/components.css b/archive/css/components.css index e2f38a0..1daace6 100644 --- a/archive/css/components.css +++ b/archive/css/components.css @@ -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); diff --git a/archive/js/app.js b/archive/js/app.js index 920b8cf..0d6b8ec 100644 --- a/archive/js/app.js +++ b/archive/js/app.js @@ -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) { diff --git a/archive/js/presets.js b/archive/js/presets.js index 90a7916..82c34b4 100644 --- a/archive/js/presets.js +++ b/archive/js/presets.js @@ -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) + ? ' (' + escapeHtml(name) + ')' + : ''; return '
' + '' + '
'; }).join(''); diff --git a/browse/js/app.js b/browse/js/app.js index b6abc6d..e26ecd8 100644 --- a/browse/js/app.js +++ b/browse/js/app.js @@ -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 = ''; diff --git a/browse/js/events.js b/browse/js/events.js index 89f0aa3..0a53b8a 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -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. diff --git a/browse/js/loader.js b/browse/js/loader.js index eb473ca..5925e09 100644 --- a/browse/js/loader.js +++ b/browse/js/loader.js @@ -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 diff --git a/browse/js/tree.js b/browse/js/tree.js index e930ee4..31183fd 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -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 @@ + '' + '' + iconChar + '' + '' - + escapeHtml(node.name) + '' + + escapeHtml(node.displayName || node.name) + '' + virtualHint + ''; } diff --git a/tests/data/test-archive.sh b/tests/data/test-archive.sh index d7ef1ec..5b81690 100755 --- a/tests/data/test-archive.sh +++ b/tests/data/test-archive.sh @@ -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" < "$out" ;; *) cat > "$out" <_ () - t_track=$(make_tracking "$party") diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index a0bcf62..6ab9929 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -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)] +} diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 8a1c268..57c9dc1 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -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"> diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 4627137..877c94c 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -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"` } diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index bb9cf2b..ba3aa72 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -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"`