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; 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 { .preset-footer-actions {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-top: 1px solid var(--border); 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 // 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 // listed names the user doesn't actually have access to (and so
// the empty-projects= "include everything" mode has a list to use). // 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; var serverNames = null;
try { try {
var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } }); var resp = await fetch(baseUrl, { headers: { 'Accept': 'application/json' } });
@ -136,6 +139,13 @@
if (Array.isArray(serverProjects) && serverProjects.length > 0 if (Array.isArray(serverProjects) && serverProjects.length > 0
&& serverProjects[0] && typeof serverProjects[0].name === 'string') { && serverProjects[0] && typeof serverProjects[0].name === 'string') {
serverNames = new Set(serverProjects.map(function(p) { return p.name; })); 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) { } catch (e) {

View file

@ -46,13 +46,24 @@
var selected = new Set(window.app.visibleProjects || []); var selected = new Set(window.app.visibleProjects || []);
var known = getKnownProjects().slice().sort(); 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 projectsHtml = known.map(name => {
var checked = selected.has(name) ? ' checked' : ''; 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">' return '<div class="preset-project-item">'
+ '<label class="preset-project-label">' + '<label class="preset-project-label">'
+ '<input type="checkbox" class="preset-checkbox" data-name="' + n + '"' + checked + '>' + '<input type="checkbox" class="preset-checkbox" data-name="' + nAttr + '"' + checked + '>'
+ ' ' + n + ' ' + nLabel + hint
+ '</label>' + '</label>'
+ '</div>'; + '</div>';
}).join(''); }).join('');

View file

@ -8,49 +8,16 @@
var tree = window.app.modules.tree; var tree = window.app.modules.tree;
var events = window.app.modules.events; var events = window.app.modules.events;
// Canonical folders that should appear at the root of a project // Virtual canonical folder injection used to live here (browse
// view even if they don't yet exist on disk. Matches the four // appended archive/working/staging/reviewing entries at a project
// stage cards on the project landing page. zddc-server returns an // root when missing). zddc-server now emits them in the listing
// empty listing for these paths (see commit 3fc3717), so // directly so the .zddc `display:` map can override their labels
// navigating into a virtual folder works without 404. // the same as real entries. This pass-through stub keeps the
var CANONICAL_PROJECT_FOLDERS = ['archive', 'working', 'staging', 'reviewing']; // events.js rescope contract intact without doing any merging.
function passThroughEntries(entries) { return entries; }
// 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;
}
// Expose for events.js's client-side rescope on dblclick. // Expose for events.js's client-side rescope on dblclick.
window.app.modules.augmentRoot = withVirtualCanonicals; window.app.modules.augmentRoot = passThroughEntries;
async function bootstrap() { async function bootstrap() {
events.init(); events.init();
@ -61,8 +28,7 @@
// state with the "Select Directory" button. // state with the "Select Directory" button.
var detected = await loader.autoDetectServerMode(); var detected = await loader.autoDetectServerMode();
if (detected) { if (detected) {
var entries = withVirtualCanonicals(detected.entries, detected.path); tree.setRoot(detected.entries);
tree.setRoot(entries);
events.showBrowseRoot(); events.showBrowseRoot();
tree.render(); tree.render();
events.statusInfo('Loaded ' + detected.entries.length + ' item' events.statusInfo('Loaded ' + detected.entries.length + ' item'
@ -83,7 +49,7 @@
window.app.state.currentPath = path; window.app.state.currentPath = path;
window.app.state.selectedId = null; window.app.state.selectedId = null;
window.app.state.lastPreviewedNodeId = null; window.app.state.lastPreviewedNodeId = null;
tree.setRoot(withVirtualCanonicals(es, path)); tree.setRoot(es);
tree.render(); tree.render();
var previewBody = document.getElementById('previewBody'); var previewBody = document.getElementById('previewBody');
if (previewBody) previewBody.innerHTML = ''; if (previewBody) previewBody.innerHTML = '';

View file

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

View file

@ -21,13 +21,21 @@
function fromServerEntry(e) { function fromServerEntry(e) {
// Server returns directory names with a trailing "/". Strip // Server returns directory names with a trailing "/". Strip
// it for display; the is_dir flag is the canonical signal. // 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 { return {
name: displayName, name: name,
displayName: displayName,
isDir: e.is_dir, isDir: e.is_dir,
size: e.size || 0, size: e.size || 0,
modTime: e.mod_time ? new Date(e.mod_time) : null, 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, url: e.url || null,
// FS-API specific (null in server mode): // FS-API specific (null in server mode):
handle: null handle: null

View file

@ -21,6 +21,10 @@
var node = { var node = {
id: id, id: id,
name: raw.name, 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, isDir: raw.isDir,
size: raw.size, size: raw.size,
modTime: raw.modTime, modTime: raw.modTime,
@ -178,7 +182,7 @@
+ '<span class="' + chevronClass + '"></span>' + '<span class="' + chevronClass + '"></span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>' + '<span class="tree-name__icon">' + iconChar + '</span>'
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">' + '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">'
+ escapeHtml(node.name) + '</span>' + escapeHtml(node.displayName || node.name) + '</span>'
+ virtualHint + virtualHint
+ '</div>'; + '</div>';
} }

View file

@ -3,7 +3,7 @@
# testing of master + cache + mirror. # testing of master + cache + mirror.
# #
# The fixture mimics the SHAPE of a real ZDDC archive (project → # 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. # tracking-number-named files) but contains zero identifying data.
# Every file's content is a 4-line metadata block: # 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. # 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() { write_zddc_config() {
out="$1" out="$1"
role="${2:-default}" # default | party | project role="${2:-default}" # default | party | project
title="${3:-}"
extra_yaml="${4:-}"
case "$role" in case "$role" in
project|party) project|party)
cat > "$out" <<EOF if [ -z "$title" ]; then
title: "Synthetic ${role} ACL — test fixture" title="Synthetic ${role} ACL — test fixture"
fi
{
cat <<EOF
title: "$title"
admins: admins:
- $ADMIN_EMAIL - $ADMIN_EMAIL
acl: acl:
@ -345,10 +356,14 @@ acl:
"bob@example.com": rw "bob@example.com": rw
"carol@example.com": r "carol@example.com": r
EOF EOF
if [ -n "$extra_yaml" ]; then
printf '%s\n' "$extra_yaml"
fi
} > "$out"
;; ;;
*) *)
cat > "$out" <<EOF cat > "$out" <<EOF
title: "Synthetic root ACL — test fixture" title: "ZDDC test fixture — synthetic root"
admins: admins:
- $ADMIN_EMAIL - $ADMIN_EMAIL
acl: acl:
@ -360,6 +375,35 @@ EOF
esac 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 # build
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
@ -398,22 +442,23 @@ cmd_build() {
proj_dir="$TARGET/$project" proj_dir="$TARGET/$project"
mkdir -p "$proj_dir" mkdir -p "$proj_dir"
chmod 0777 "$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 for party in $parties; do
party_dir="$proj_dir/Archive/$party" party_dir="$proj_dir/archive/$party"
mkdir -p "$party_dir/Received" "$party_dir/Issued" mkdir -p "$party_dir/received" "$party_dir/issued"
chmod 0777 "$party_dir" "$party_dir/Received" "$party_dir/Issued" chmod 0777 "$party_dir" "$party_dir/received" "$party_dir/issued"
write_zddc_config "$party_dir/.zddc" party write_zddc_config "$party_dir/.zddc" party
i=0 i=0
while [ "$i" -lt "$per_party" ]; do while [ "$i" -lt "$per_party" ]; do
i=$((i + 1)) i=$((i + 1))
# Alternate Received / Issued. # Alternate received / issued.
if [ $((i % 2)) = 0 ]; then if [ $((i % 2)) = 0 ]; then
bucket="Received" bucket="received"
else else
bucket="Issued" bucket="issued"
fi fi
# Transmittal envelope: <date>_<tracking> (<status>) - <title> # Transmittal envelope: <date>_<tracking> (<status>) - <title>
t_track=$(make_tracking "$party") 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. // when no entries match — clients (browse, archive) expect an array.
result := make([]listing.FileInfo, 0, len(entries)) 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 { for _, entry := range entries {
name := entry.Name() name := entry.Name()
@ -82,6 +87,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
} }
isDir := entry.IsDir() isDir := entry.IsDir()
displayName := lookupDisplay(displayMap, name)
if isDir { if isDir {
// ACL check for subdirectory // ACL check for subdirectory
@ -96,12 +102,13 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
continue // omit denied directories silently continue // omit denied directories silently
} }
fi := listing.FileInfo{ fi := listing.FileInfo{
Name: name + "/", Name: name + "/",
Size: info.Size(), Size: info.Size(),
URL: baseURL + url.PathEscape(name) + "/", URL: baseURL + url.PathEscape(name) + "/",
ModTime: info.ModTime(), ModTime: info.ModTime(),
Mode: uint32(info.Mode()), Mode: uint32(info.Mode()),
IsDir: true, IsDir: true,
DisplayName: displayName,
} }
result = append(result, fi) result = append(result, fi)
continue continue
@ -109,12 +116,13 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
// Regular file // Regular file
fi := listing.FileInfo{ fi := listing.FileInfo{
Name: name, Name: name,
Size: info.Size(), Size: info.Size(),
URL: baseURL + url.PathEscape(name), URL: baseURL + url.PathEscape(name),
ModTime: info.ModTime(), ModTime: info.ModTime(),
Mode: uint32(info.Mode()), Mode: uint32(info.Mode()),
IsDir: false, IsDir: false,
DisplayName: displayName,
} }
result = append(result, fi) result = append(result, fi)
} }
@ -128,9 +136,59 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
result = append(result, syn) 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 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 // virtualUserHomeEntry returns the synthetic <viewer-email>/ entry that
// should be appended to a working/ listing, or (zero, false) when no // should be appended to a working/ listing, or (zero, false) when no
// synthetic entry applies. // synthetic entry applies.
@ -166,3 +224,32 @@ func virtualUserHomeEntry(fsRoot, dirPath, viewerEmail, baseURL string, real []l
Virtual: true, Virtual: true,
}, 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> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <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> </div>
<div class="header-right"> <div class="header-right">

View file

@ -21,4 +21,11 @@ type FileInfo struct {
// listings drop the synthetic entry. Clients can use this flag to // listings drop the synthetic entry. Clients can use this flag to
// render the entry differently (placeholder badge, drop-target hint). // render the entry differently (placeholder badge, drop-target hint).
Virtual bool `json:"virtual,omitempty"` 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. // directory that hosts a table declares it directly.
Tables map[string]string `yaml:"tables,omitempty" json:"tables,omitempty"` 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. // Roles are named principal groups available at this level and below.
// See Role for member syntax. // See Role for member syntax.
Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"` Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`