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"`