ZDDC/archive/js/presets.js
ZDDC e85d5fc660 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>
2026-05-11 13:03:53 -05:00

158 lines
6 KiB
JavaScript

(function() {
'use strict';
// Project-picker dropdown for the archive browser.
//
// In multi-project mode (HTTP source against zddc-server, OR ?projects=
// present in the URL), this dropdown lets the user toggle which projects
// are scanned. Toggling a checkbox updates window.app.projectFilter, pushes
// the new ?projects= state to the URL, and triggers a re-scan.
//
// In single-project mode the dropdown is hidden — only one project is ever
// in scope, so picking is meaningless.
let isOpen = false;
// The set of project names currently shown in the dropdown.
function getKnownProjects() {
if (window.app.availableProjects && window.app.availableProjects.length > 0) {
return window.app.availableProjects.slice();
}
// Fall back to whatever is in the URL filter — useful when the server's
// ProjectInfo endpoint isn't reachable but ?projects= names the set.
return Array.from(window.app.projectFilter || []);
}
// Visibility-only filter: change visibleProjects, push URL state, re-render
// UI. No rescan — already-scanned data stays in memory. URL is updated via
// history.replaceState (same mechanism as every other UI control).
function applyVisibility(names) {
window.app.visibleProjects = new Set(names);
window.app.modules.urlState.push();
window.app.modules.app.updateUI();
window.app.modules.filtering.applyFilters();
renderDropdown();
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderDropdown() {
var dropdown = document.getElementById('presetDropdown');
if (!dropdown) return;
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 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="' + nAttr + '"' + checked + '>'
+ ' ' + nLabel + hint
+ '</label>'
+ '</div>';
}).join('');
if (!projectsHtml) {
projectsHtml = '<div class="preset-no-presets"><i>No projects available</i></div>';
}
dropdown.innerHTML =
'<div class="preset-section-bottom">'
+ '<div class="preset-section-label">Projects:</div>'
+ '<div class="preset-projects-list">' + projectsHtml + '</div>'
+ '</div>';
}
function toggleDropdown() {
var dropdown = document.getElementById('presetDropdown');
if (isOpen) { closeDropdown(); return; }
isOpen = true;
if (dropdown) dropdown.classList.remove('hidden');
renderDropdown();
}
function closeDropdown() {
isOpen = false;
var dropdown = document.getElementById('presetDropdown');
if (dropdown) dropdown.classList.add('hidden');
}
function setupDropdownDelegation() {
var dropdown = document.getElementById('presetDropdown');
if (!dropdown) return;
dropdown.addEventListener('click', function(e) {
e.stopPropagation();
var checkbox = e.target.closest('.preset-checkbox');
if (!checkbox) return;
var projectName = checkbox.getAttribute('data-name');
if (!projectName) return;
var sel = new Set(window.app.visibleProjects || []);
if (checkbox.checked) sel.add(projectName);
else sel.delete(projectName);
applyVisibility(Array.from(sel));
});
}
function setupOutsideClickHandler() {
document.addEventListener('click', function(e) {
var section = document.getElementById('presetSection');
var dropdown = document.getElementById('presetDropdown');
if (isOpen && section && dropdown && !section.contains(e.target)) {
closeDropdown();
}
});
}
function init() {
var section = document.getElementById('presetSection');
if (!section) return;
// Hide the dropdown entirely outside multi-project mode.
if (!window.app.isMultiProject) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
var btn = document.getElementById('presetBtn');
if (!btn || btn.dataset.presetInit) return;
btn.dataset.presetInit = '1';
btn.title = 'Project picker';
btn.textContent = '▾ Projects';
btn.addEventListener('click', function(e) {
e.stopPropagation();
toggleDropdown();
});
setupDropdownDelegation();
setupOutsideClickHandler();
}
window.app.modules.presets = {
init: init,
toggleDropdown: toggleDropdown,
closeDropdown: closeDropdown,
// No-op kept so existing callers (events.js after grouping-folder click)
// don't need to null-check; preset dirty state was removed with the
// saved-presets feature.
checkDirty: function() {}
};
})();