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