ZDDC/archive/js/app.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

1003 lines
43 KiB
JavaScript

(function() {
'use strict';
// window.app is initialized in init.js. Reference shape (read-only docs):
// directories[], groupingFolders[], transmittalFolders[], files[],
// filteredFiles[], selectedFiles:Set, sourceMode ('local'|'http'),
// isScanning, scanProgress,
// columnFilters {trackingNumber,title,revisions}, columnFilterASTs {...},
// groupingFilter, transmittalFilter,
// enabledFolderTypes:Set('issued','received'),
// sortField ('trackingNumber'), sortDirection ('asc'|'desc'),
// selectedGroupingFolders:Set, selectedTransmittalFolders:Set,
// collapsedDateGroups:Set, collapsedGroupingFolders:Set,
// selectAllGroupingFolders:bool, selectAllTransmittals:bool,
// availableModifiers:Set, selectedModifiers:Set, showSelectedOnly:bool
// Parse search terms from filter string
function parseSearchTerms(filter) {
if (!filter || !filter.trim()) return [];
return filter.trim().toLowerCase().split(/\s+/);
}
// Check if text matches all search terms (AND logic)
function matchesSearchTerms(text, terms) {
if (!terms || terms.length === 0) return true;
return terms.every(term => text.includes(term));
}
// Initialize application
function initApp() {
// Detect source mode from protocol
window.app.sourceMode = (location.protocol === 'file:') ? 'local' : 'http';
if (window.app.sourceMode === 'local') {
// Check File System Access API support (local mode only)
if (!('showDirectoryPicker' in window)) {
showUnsupportedBrowserMessage();
return;
}
}
// Set up event listeners
window.app.modules.events.setupEventListeners();
// Set up file link handlers (event delegation)
window.app.modules.table.setupFileLinkHandlers();
// Apply source-mode-specific UI adjustments
applySourceModeUI();
// Restore filter/sort state from URL query string
window.app.modules.urlState.restore();
// Initialize UI
updateUI();
// Show initial sort indicator
window.app.modules.table.updateSortIndicators();
if (window.app.sourceMode === 'http') {
// Auto-connect to the server in HTTP mode
autoConnectHttpSource();
} else {
// Show empty state if no directories (local mode)
if (window.app.directories.length === 0) {
showEmptyState();
}
}
}
// Apply UI differences based on source mode
function applySourceModeUI() {
// "Add Local Directory" button is always visible in both modes —
// in HTTP mode the user can augment the online archive with local directories.
}
// Auto-connect to the HTTP server
// Derives the base URL from the current page's location.
//
// Two URL shapes can serve the archive tool:
// (a) Filename-style: /Project-1/Archive/PartyA/archive.html
// (b) Auto-served: /Project-1/Archive/PartyA/Issued
//
// For (a) the URL ends with a filename (`*.html`) and the
// archive's scan root is the parent directory. For (b) the URL
// path IS the directory; trying to strip the last segment would
// wrongly scan the parent. Detect (b) by checking whether the
// path's last segment looks like a file (has a dot + extension).
async function autoConnectHttpSource() {
var href = window.location.href;
// Strip query string and fragment
href = href.split('?')[0].split('#')[0];
var baseUrl;
if (href.endsWith('/')) {
// Directory URL, e.g. /Project-1/archive/ or /
baseUrl = href;
} else {
var lastSlash = href.lastIndexOf('/');
var lastSegment = lastSlash >= 0 ? href.substring(lastSlash + 1) : href;
// A "filename" has a dot in its last segment. A bare
// directory name (e.g. "Issued") doesn't. Don't be fooled
// by dots in domain names — lastSlash is past the host.
if (lastSegment.indexOf('.') >= 0) {
baseUrl = href.substring(0, lastSlash + 1);
} else {
// Auto-served at a directory URL with no trailing
// slash. Treat the whole path as the scan root.
baseUrl = href + '/';
}
}
// Multi-project mode is opt-in via ?projects= in the URL.
// ?projects= absent → not multi-project; scan whatever the URL
// points to (single-project or in-archive
// mode). The server's project list, if any,
// stays out of view.
// ?projects= empty → multi-project; include every project the
// server says the user can access.
// ?projects=A,B → multi-project; include only the listed
// projects (intersected with server access).
// The archive never sees projects beyond this scope — the visibility
// dropdown only narrows what's already in availableProjects.
var urlParams = new URLSearchParams(location.search);
var projectsParamPresent = urlParams.has('projects');
if (projectsParamPresent) {
window.app.isMultiProject = true;
// 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' } });
if (resp.ok) {
var serverProjects = await resp.json();
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) {
// Plain Caddy or proxy-stripped — trust the URL list as-is.
}
if (window.app.projectFilter && window.app.projectFilter.size > 0) {
// Listed names: intersect with server access, warn about misses.
if (serverNames) {
var accessible = new Set();
var missing = [];
window.app.projectFilter.forEach(function(p) {
if (serverNames.has(p)) accessible.add(p);
else missing.push(p);
});
window.app.projectFilter = accessible;
if (missing.length > 0) showProjectWarning(missing);
}
window.app.availableProjects = Array.from(window.app.projectFilter).sort();
} else if (serverNames) {
// Empty ?projects= — scan everything the user can access.
window.app.projectFilter = new Set(serverNames);
window.app.availableProjects = Array.from(serverNames).sort();
}
// else: ?projects= empty AND no server list — leave projectFilter
// empty; source.js will fall through to in-archive mode.
}
// visibleProjects: default to projectFilter (everything visible), or
// honor an explicit ?show= from the URL (intersected with projectFilter
// to drop names that aren't in scope). An empty ?show= means "hide
// everything" — distinct from "no ?show= at all".
var showInUrl = urlParams.has('show');
if (showInUrl) {
var inScope = new Set();
(window.app.visibleProjects || new Set()).forEach(function(n) {
if (window.app.projectFilter.has(n)) inScope.add(n);
});
window.app.visibleProjects = inScope;
} else {
window.app.visibleProjects = new Set(window.app.projectFilter);
}
await addHttpSource(baseUrl);
}
// Add an HTTP source root (analogous to addDirectory() for local mode)
async function addHttpSource(baseUrl) {
// Derive a display name from the URL path
var urlPath = baseUrl.replace(/\/$/, '');
var rootName = urlPath.substring(urlPath.lastIndexOf('/') + 1) || urlPath;
// Check if already added
var exists = window.app.directories.some(function(d) { return d.url === baseUrl; });
if (exists) return;
window.app.directories.push({
handle: null,
name: rootName,
path: rootName,
url: baseUrl
});
if (window.app.directories.length === 1) {
hideEmptyState();
}
await scanHttpSource(baseUrl, rootName);
updateUI();
}
// Scan an HTTP source root
async function scanHttpSource(baseUrl, rootName) {
window.app.isScanning = true;
window.app.scanProgress = 'Connecting to server...';
updateStatusBar();
var source = window.app.modules.source.createSource('http', { baseUrl: baseUrl });
var fileCount = 0;
var callbacks = {
onGroupingFolder: function(folder) {
window.app.groupingFolders.push(folder);
},
onTransmittalFolder: function(folder) {
window.app.transmittalFolders.push(folder);
},
onFile: function(file) {
window.app.files.push(file);
fileCount++;
// Throttled progress update — don't update DOM on every file
if (fileCount % 10 === 0) {
window.app.scanProgress = 'Scanning\u2026 ' + fileCount + ' files found';
updateStatusBar();
}
},
onProgress: function() { /* no-op: parallel scan — spinner is enough */ }
};
try {
await source.scan(baseUrl, callbacks);
// Auto-select top-level party folders (shallowest depth). Keyed by
// party NAME so duplicate third-party folders across projects merge.
var groupingDepths = window.app.groupingFolders.map(function(f) { return f.path.split('/').length; });
var minGroupingDepth = groupingDepths.length > 0 ? Math.min.apply(null, groupingDepths) : 1;
window.app.groupingFolders.forEach(function(folder) {
if (folder.path.split('/').length === minGroupingDepth) {
window.app.selectedGroupingFolders.add(folder.name);
}
});
window.app.transmittalFolders.forEach(function(folder) {
if (!isUnderHiddenFolderType(folder.path)) {
window.app.selectedTransmittalFolders.add(folder.path);
}
});
ensureOutstandingTransmittal();
// Auto-select Outstanding if selectAllTransmittals is active
if (window.app.selectAllTransmittals) {
window.app.selectedTransmittalFolders.add('__outstanding__');
}
collectModifiers();
updateUI();
window.app.modules.filtering.applyFilters();
if (window.app.modules.presets) {
window.app.modules.presets.init();
}
} catch (err) {
console.error('Error scanning HTTP source:', err);
showHttpErrorState(err.message);
} finally {
window.app.isScanning = false;
window.app.scanProgress = '';
updateStatusBar();
}
}
// Ensure the Outstanding virtual transmittal exists if there are any outstanding files.
// Called after each scan completes. Idempotent — safe to call multiple times.
function ensureOutstandingTransmittal() {
const hasOutstanding = window.app.files.some(f => f.folderPath === '__outstanding__');
const alreadyExists = window.app.transmittalFolders.some(f => f.path === '__outstanding__');
if (hasOutstanding && !alreadyExists) {
window.app.transmittalFolders.push({
name: 'Outstanding',
path: '__outstanding__',
displayPath: 'Outstanding',
handle: null,
url: null,
isVirtual: true
});
}
}
// Show error state when HTTP server is unreachable
function showHttpErrorState(message) {
var el = document.getElementById('noDirectoryMessage');
if (!el) return;
var content = el.querySelector('.empty-state__inner');
if (content) {
content.innerHTML =
'<h2>Could not connect to server</h2>' +
'<p>The archive browser could not retrieve the directory listing from the server.</p>' +
'<p><strong>Error:</strong> ' + escapeHtml(message || 'Unknown error') + '</p>' +
'<p>Ensure the server is running, CORS is not blocking the request, and Caddy\'s file browsing is enabled.</p>';
}
el.classList.remove('hidden');
}
// Show a warning banner listing projects in the URL filter that the user cannot access
function showProjectWarning(missingProjects) {
var el = document.getElementById('projectWarningBanner');
if (!el || missingProjects.length === 0) return;
var list = missingProjects.map(function(p) { return escapeHtml(p); }).join(', ');
el.querySelector('.project-warning-text').innerHTML =
'This link includes projects you don\'t have access to: <strong>' + list + '</strong>';
el.classList.remove('hidden');
}
function dismissProjectWarning() {
var el = document.getElementById('projectWarningBanner');
if (el) el.classList.add('hidden');
}
// Show unsupported browser message
function showUnsupportedBrowserMessage() {
const app = document.getElementById('appContainer');
app.innerHTML = `
<div class="empty-state empty-state--overlay">
<div class="empty-state__inner empty-state__inner--centered">
<h2>Browser Not Supported</h2>
<p>This application requires a Chromium-based browser (Chrome, Edge, Brave) with File System Access API support.</p>
<p>Please use one of these browsers to access the Archive Browser.</p>
</div>
</div>
`;
}
// Show empty state
function showEmptyState() {
document.getElementById('noDirectoryMessage').classList.remove('hidden');
document.querySelector('.main-container').style.display = 'none';
// Keep header visible
document.querySelector('.app-header').style.display = '';
var refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) { refreshBtn.classList.add('hidden'); }
}
// Hide empty state
function hideEmptyState() {
document.getElementById('noDirectoryMessage').classList.add('hidden');
document.querySelector('.main-container').style.display = '';
var refreshBtn = document.getElementById('refreshHeaderBtn');
if (refreshBtn) { refreshBtn.classList.remove('hidden'); }
}
// Update UI based on current state
function updateUI() {
renderFolderTypeBar();
renderFolderLists();
window.app.modules.table.updateFileTable();
updateStatusBar();
}
// Render folder lists (rebuilds DOM)
function renderFolderLists() {
renderGroupingFolders();
renderTransmittalFolders();
}
// Check if a folder path is under a hidden folder type
// Returns true if any path segment is a known folder type that is NOT currently enabled
function isUnderHiddenFolderType(path) {
const parts = path.toLowerCase().split('/');
return parts.some(part =>
window.app.FOLDER_TYPE_NAMES.includes(part) && !window.app.enabledFolderTypes.has(part)
);
}
// Get filtered grouping folders (single source of truth for filtering logic)
function getFilteredGroupingFolders() {
const filter = window.app.groupingFilter;
return window.app.groupingFolders.filter(folder => {
if (isUnderHiddenFolderType(folder.path)) {
return false;
}
if (!filter) return true;
const terms = parseSearchTerms(filter);
return matchesSearchTerms(folder.name.toLowerCase(), terms);
});
}
// Render grouping folders as a flat list of unique party names. Same-named
// third-party folders across multiple projects collapse to one row.
// selectedGroupingFolders is a Set of party NAMES (not paths) so toggling
// affects every project occurrence at once.
function renderGroupingFolders() {
const container = document.getElementById('groupingFoldersList');
// Get filtered grouping folders (uses shared filtering logic)
const filteredFolders = getFilteredGroupingFolders();
// Only show top-level party folders (the shallowest depth among all grouping folders)
const allDepths = window.app.groupingFolders.map(f => f.path.split('/').length);
const minDepth = allDepths.length > 0 ? Math.min(...allDepths) : 1;
const partyFolders = filteredFolders.filter(f => f.path.split('/').length === minDepth);
// Dedupe by name (keep first occurrence per name). In multi-project mode,
// skip parties whose every occurrence is under a hidden project — if at
// least one occurrence is in a visible project, the party stays.
const seen = new Set();
const uniqueParties = [];
for (const f of partyFolders) {
if (seen.has(f.name)) continue;
if (window.app.isMultiProject) {
const hasVisible = partyFolders.some(p =>
p.name === f.name && pathIsInVisibleProject(p.path)
);
if (!hasVisible) continue;
}
seen.add(f.name);
uniqueParties.push(f);
}
uniqueParties.sort((a, b) => a.name.localeCompare(b.name));
const partyNames = new Set(uniqueParties.map(f => f.name));
// If "Select All" mode is active, auto-select all visible party names.
if (window.app.selectAllGroupingFolders) {
window.app.selectedGroupingFolders.clear();
uniqueParties.forEach(f => window.app.selectedGroupingFolders.add(f.name));
} else {
// Remove selections for names that are no longer visible.
for (const selectedName of window.app.selectedGroupingFolders) {
if (!partyNames.has(selectedName)) {
window.app.selectedGroupingFolders.delete(selectedName);
}
}
}
// Sync checkbox state
const checkbox = document.getElementById('selectAllGroupingCheckbox');
if (checkbox) checkbox.checked = window.app.selectAllGroupingFolders;
if (uniqueParties.length === 0 && window.app.groupingFilter) {
container.innerHTML = '<div class="folder-list-empty">No parties match your filter</div>';
updateFolderSelectionState('groupingFoldersList');
return;
}
container.innerHTML = uniqueParties.map(folder => `
<div class="folder-item ${window.app.selectedGroupingFolders.has(folder.name) ? 'selected' : ''}"
data-path="${escapeHtml(folder.name)}"
data-folder-type="grouping">
<span class="folder-item-name" title="${escapeHtml(folder.name)}">${escapeHtml(folder.name)}</span>
</div>
`).join('');
updateFolderSelectionState('groupingFoldersList');
}
// Render the global folder type toggle bar
function renderFolderTypeBar() {
const bar = document.getElementById('folderTypeBar');
if (!bar) return;
const FOLDER_TYPE_LABELS = { mdl: 'MDL', incoming: 'Incoming', issued: 'Issued', received: 'Received' };
bar.innerHTML = window.app.FOLDER_TYPE_NAMES.map(type => {
const active = window.app.enabledFolderTypes.has(type);
const label = FOLDER_TYPE_LABELS[type] || (type.charAt(0).toUpperCase() + type.slice(1));
return `<button class="folder-type-toggle ${active ? 'active' : ''}"
data-type="${type}"
title="Toggle ${label} folders">${label}</button>`;
}).join('');
}
// Toggle a folder type on/off globally.
// Off->on triggers a refresh because source.js skips listings for disabled folder types
// entirely (no listing fetched), so newly-enabled types need a rescan to surface their data.
function toggleFolderType(type) {
const wasEnabled = window.app.enabledFolderTypes.has(type);
if (wasEnabled) {
window.app.enabledFolderTypes.delete(type);
} else {
window.app.enabledFolderTypes.add(type);
}
renderFolderTypeBar();
renderGroupingFolders();
renderTransmittalFolders();
window.app.modules.filtering.applyFilters();
window.app.modules.urlState.push();
if (!wasEnabled && window.app.directories.length > 0) {
window.app.modules.directory.refreshDirectories();
}
}
// In multi-project mode, returns true if the path contains a segment matching
// a checked project in the picker. Single-project mode always returns true
// (no project segment to match against).
function pathIsInVisibleProject(path) {
if (!window.app.isMultiProject) return true;
if (!window.app.visibleProjects || window.app.visibleProjects.size === 0) return false;
return path.split('/').some(seg => window.app.visibleProjects.has(seg));
}
// Returns true if an outstanding file's actualPath has a path segment matching
// any selected party name and is not under a hidden folder type. Segment-equality
// (not prefix) so the same party name selected across projects matches all
// occurrences regardless of project ID prefix.
function outstandingFileIsVisible(file) {
const selectedGrouping = window.app.selectedGroupingFolders;
if (selectedGrouping.size === 0) return false;
if (isUnderHiddenFolderType(file.actualPath)) return false;
if (!pathIsInVisibleProject(file.actualPath)) return false;
return file.actualPath.split('/').some(seg => selectedGrouping.has(seg));
}
// Returns true if any outstanding (non-transmittal) files exist under the currently
// selected and visible grouping folders.
function hasVisibleOutstandingFiles() {
return window.app.files.some(function(f) {
if (f.folderPath !== '__outstanding__') return false;
return outstandingFileIsVisible(f);
});
}
// Returns true if the transmittal folder's path satisfies all three cascade
// layers: project visibility, party selection (any path segment matches a
// selected party name), and folder-type enablement (no segment is a
// folder-type marker that's currently disabled, regardless of where in the
// path it sits). Segment-equality matching means a party "BM" selected
// matches every "<...>/BM/<...>" path regardless of the prefix; and the
// folder-type check covers BOTH the canonical "<party>/Issued/<txn>" layout
// AND nested layouts like "<party>/<sub>/Issued/<txn>" — a deeper folder-
// type marker still triggers the cascade.
function transmittalIsUnderVisibleParty(folder) {
if (!pathIsInVisibleProject(folder.path)) return false;
if (isUnderHiddenFolderType(folder.path)) return false;
return folder.path.split('/').some(seg =>
window.app.selectedGroupingFolders.has(seg)
);
}
// Render transmittal folders (rebuilds DOM)
function renderTransmittalFolders() {
const container = document.getElementById('transmittalFoldersList');
const filter = window.app.transmittalFilter;
// Filter transmittal folders based on grouping selection and name filter
const filteredFolders = window.app.transmittalFolders.filter(folder => {
// Outstanding virtual transmittal: include if there are visible outstanding files
if (folder.path === '__outstanding__') {
if (!hasVisibleOutstandingFiles()) return false;
// Apply name filter to "Outstanding" label too
if (filter && filter.trim()) {
const terms = parseSearchTerms(filter.trim());
if (!matchesSearchTerms('outstanding', terms)) return false;
}
return true;
}
// Check name filter
let matchesFilter = true;
if (filter && filter.trim()) {
const terms = parseSearchTerms(filter.trim());
const folderText = folder.name.toLowerCase();
matchesFilter = matchesSearchTerms(folderText, terms);
}
// If no grouping folders exist at all, show all transmittal folders (flat structure)
if (window.app.groupingFolders.length === 0) {
return matchesFilter;
}
// If grouping folders exist but none are selected, show nothing
if (window.app.selectedGroupingFolders.size === 0) {
return false;
}
// Check party + folder type visibility
return matchesFilter && transmittalIsUnderVisibleParty(folder);
});
// Sort regular transmittal folders by date (newest first); Outstanding handled separately
const regularFolders = filteredFolders.filter(f => f.path !== '__outstanding__');
regularFolders.sort((a, b) => b.name.localeCompare(a.name));
const showOutstanding = filteredFolders.some(f => f.path === '__outstanding__');
// Build set of visible folder paths (for Select All and deselection logic)
const filteredPaths = new Set(filteredFolders.map(f => f.path));
// If "Select All" mode is active, auto-select all visible transmittal folders
if (window.app.selectAllTransmittals) {
window.app.selectedTransmittalFolders.clear();
filteredFolders.forEach(f => window.app.selectedTransmittalFolders.add(f.path));
} else {
// Remove selections for folders that are now filtered out
for (const selectedPath of window.app.selectedTransmittalFolders) {
if (!filteredPaths.has(selectedPath)) {
window.app.selectedTransmittalFolders.delete(selectedPath);
}
}
}
// Sync checkbox state
const checkbox = document.getElementById('selectAllTransmittalsCheckbox');
if (checkbox) checkbox.checked = window.app.selectAllTransmittals;
// Group regular folders by date
const foldersByDate = new Map();
regularFolders.forEach(folder => {
const match = folder.name.match(/^(\d{4}-\d{2}-\d{2})/);
const date = match ? match[1] : 'Unknown';
if (!foldersByDate.has(date)) {
foldersByDate.set(date, []);
}
foldersByDate.get(date).push(folder);
});
// Build HTML
let html = '';
// Outstanding virtual transmittal — pinned at top
if (showOutstanding) {
const isSelected = window.app.selectedTransmittalFolders.has('__outstanding__');
html += `
<div class="folder-item outstanding-transmittal ${isSelected ? 'selected' : ''}"
data-path="__outstanding__"
data-folder-type="transmittal"
title="Files in non-transmittal folders under selected grouping folders">
<div class="transmittal-folder-content">
<div class="transmittal-first-line outstanding-label">⋯ Outstanding</div>
</div>
</div>
`;
}
// Regular date-grouped folders
for (const [date, folders] of foldersByDate) {
const isCollapsed = window.app.collapsedDateGroups.has(date);
const folderCount = folders.length;
html += `
<div class="date-group-header" data-date="${escapeHtml(date)}">
<span class="date-group-toggle">${isCollapsed ? '▶' : '▼'}</span>
<span class="date-group-date">${escapeHtml(date)}</span>
<span class="date-group-count">(${folderCount})</span>
</div>
`;
if (!isCollapsed) {
for (const folder of folders) {
const match = folder.name.match(/^\d{4}-\d{2}-\d{2}_([^_\s]+)\s*\(([^)]+)\)\s*-\s*(.+)$/);
let firstLine = folder.name;
let secondLine = '';
if (match) {
const [, tracking, status, title] = match;
firstLine = `${tracking}${status}`;
secondLine = title;
}
html += `
<div class="folder-item ${window.app.selectedTransmittalFolders.has(folder.path) ? 'selected' : ''}"
data-path="${escapeHtml(folder.path)}"
data-folder-type="transmittal"
title="${escapeHtml(folder.path)}">
<div class="transmittal-folder-content">
<div class="transmittal-first-line">${escapeHtml(firstLine)}</div>
${secondLine ? `<div class="transmittal-second-line">${escapeHtml(secondLine)}</div>` : ''}
</div>
</div>
`;
}
}
}
if (filteredFolders.length === 0 && window.app.transmittalFilter) {
container.innerHTML = '<div class="folder-list-empty">No folders match your filter</div>';
updateFolderSelectionState('transmittalFoldersList');
window.app.modules.events.updateToggleAllIcon();
return;
}
container.innerHTML = html;
// Ensure selection state is visually reflected after DOM rebuild
updateFolderSelectionState('transmittalFoldersList');
// Update the toggle all icon to reflect current state
window.app.modules.events.updateToggleAllIcon();
}
// Update status bar
function updateStatusBar() {
const fileCountEl = document.getElementById('fileCount');
const selectedCountEl = document.getElementById('selectedCount');
// Before any directory is loaded, show a hint instead of "0 files"
if (window.app.directories.length === 0 && !window.app.isScanning) {
fileCountEl.textContent = 'Select a directory to begin';
selectedCountEl.textContent = '';
document.getElementById('scanStatus').textContent = '';
var spinner2 = document.getElementById('scanSpinner');
if (spinner2) spinner2.classList.add('hidden');
document.getElementById('downloadSelectedBtn').disabled = true;
document.getElementById('exportCsvBtn').disabled = true;
return;
}
// Count unique tracking numbers
const trackingNumbers = new Set(window.app.filteredFiles.map(f => f.trackingNumber));
const trackingCount = trackingNumbers.size;
const fileCount = window.app.filteredFiles.length;
// Count files with path errors
const pathErrorCount = window.app.filteredFiles.filter(f => f.hasPathError).length;
// Format: "X tracking numbers, Y files" + optional path error warning
let countText = `${trackingCount} tracking number${trackingCount !== 1 ? 's' : ''}, ${fileCount} file${fileCount !== 1 ? 's' : ''}`;
if (pathErrorCount > 0) {
countText += ` (⚠️ ${pathErrorCount} inaccessible)`;
}
fileCountEl.textContent = countText;
selectedCountEl.textContent = `${window.app.selectedFiles.size} selected`;
document.getElementById('scanStatus').textContent = window.app.scanProgress;
var spinner = document.getElementById('scanSpinner');
if (spinner) { spinner.classList.toggle('hidden', !window.app.isScanning); }
// Disable action buttons when nothing is selected
const noneSelected = window.app.selectedFiles.size === 0;
document.getElementById('downloadSelectedBtn').disabled = noneSelected;
document.getElementById('exportCsvBtn').disabled = noneSelected;
}
// Escape HTML for safe insertion
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Update folder selection visual state without rebuilding DOM
* This is more efficient than re-rendering when only selection changes
* @param {string} containerId - 'groupingFoldersList' or 'transmittalFoldersList'
*/
function updateFolderSelectionState(containerId) {
const container = document.getElementById(containerId);
if (!container) {
console.warn(`Container not found: ${containerId}`);
return;
}
const selectedSet = containerId === 'groupingFoldersList' ?
window.app.selectedGroupingFolders :
window.app.selectedTransmittalFolders;
// Update selected class on existing elements
container.querySelectorAll('.folder-item').forEach(item => {
const path = item.getAttribute('data-path');
if (path) {
item.classList.toggle('selected', selectedSet.has(path));
}
});
}
// Extract modifier type from revision string (e.g., "2+B1" -> "+B", "2" -> "base")
function getModifierType(revision) {
if (!revision) return 'base';
const match = revision.match(/\+([A-Za-z])/);
return match ? '+' + match[1].toUpperCase() : 'base';
}
// Collect all unique modifiers from files
function collectModifiers() {
window.app.availableModifiers.clear();
window.app.files.forEach(file => {
const modType = getModifierType(file.revision);
window.app.availableModifiers.add(modType);
});
// Default selection: 'base' (un-modified revisions) plus '+C' (comment
// markups against base). Other modifier types (+B, +D, …) are
// available in the dropdown but hidden by default — users opt them in
// via the Modifiers dropdown when they want to see scratch / draft /
// hold-style markups. Falls back to selecting whatever is available
// when neither default exists, so the table never goes empty out of
// the gate.
const defaults = ['base', '+C'];
const selected = new Set();
defaults.forEach(d => {
if (window.app.availableModifiers.has(d)) selected.add(d);
});
if (selected.size === 0) {
window.app.availableModifiers.forEach(m => selected.add(m));
}
window.app.selectedModifiers = selected;
// Update the dropdown UI
renderModifierDropdown();
}
// Render the modifier dropdown options
function renderModifierDropdown() {
const list = document.getElementById('modifierFilterList');
if (!list) return;
// Sort modifiers: "base" first, then alphabetically
const sorted = Array.from(window.app.availableModifiers).sort((a, b) => {
if (a === 'base') return -1;
if (b === 'base') return 1;
return a.localeCompare(b);
});
let html = '';
sorted.forEach(mod => {
const checked = window.app.selectedModifiers.has(mod) ? 'checked' : '';
const label = mod === 'base' ? 'Base (no modifier)' : mod;
const labelClass = mod === 'base' ? 'modifier-base' : 'modifier-type';
html += `
<div class="modifier-filter-item">
<label>
<input type="checkbox"
data-modifier="${mod}"
${checked}
onchange="toggleModifierFilter('${mod}')">
<span class="${labelClass}">${label}</span>
</label>
</div>
`;
});
list.innerHTML = html;
updateModifierSelectAll();
updateModifierButtonLabel();
}
// Toggle a specific modifier filter
function toggleModifierFilter(mod) {
if (window.app.selectedModifiers.has(mod)) {
window.app.selectedModifiers.delete(mod);
} else {
window.app.selectedModifiers.add(mod);
}
updateModifierSelectAll();
updateModifierButtonLabel();
window.app.modules.filtering.applyFilters();
}
// Toggle all modifiers
function toggleAllModifiers(selectAll) {
if (selectAll) {
window.app.selectedModifiers = new Set(window.app.availableModifiers);
} else {
window.app.selectedModifiers.clear();
}
renderModifierDropdown();
window.app.modules.filtering.applyFilters();
}
// Update the "Select All" checkbox state
function updateModifierSelectAll() {
const selectAllCheckbox = document.getElementById('modifierSelectAll');
if (selectAllCheckbox) {
selectAllCheckbox.checked = window.app.selectedModifiers.size === window.app.availableModifiers.size;
selectAllCheckbox.indeterminate = window.app.selectedModifiers.size > 0 &&
window.app.selectedModifiers.size < window.app.availableModifiers.size;
}
}
// Update button label to show filter status
function updateModifierButtonLabel() {
const btn = document.getElementById('modifierFilterBtn');
if (!btn) return;
const total = window.app.availableModifiers.size;
const selected = window.app.selectedModifiers.size;
if (selected === total) {
btn.textContent = 'Modifiers ▼';
} else if (selected === 0) {
btn.textContent = 'Modifiers (none) ▼';
} else {
btn.textContent = `Modifiers (${selected}/${total}) ▼`;
}
}
// Toggle modifier dropdown visibility
function toggleModifierDropdown() {
const dropdown = document.getElementById('modifierFilterDropdown');
dropdown.classList.toggle('hidden');
}
// Update the Folders icon button state based on active visibility toggles
function updateFolderVisibilityBtnLabel() {
// replaced by renderFolderTypeBar()
}
// Check if a file passes the modifier filter
function filePassesModifierFilter(file) {
const modType = getModifierType(file.revision);
return window.app.selectedModifiers.has(modType);
}
// Toggle filter to show only selected files
function toggleFilterSelected() {
window.app.showSelectedOnly = !window.app.showSelectedOnly;
// Update button visual state and label
const btn = document.getElementById('filterSelectedBtn');
if (window.app.showSelectedOnly) {
btn.classList.add('btn-active');
btn.textContent = 'Show All';
} else {
btn.classList.remove('btn-active');
btn.textContent = 'Filter Selected';
}
window.app.modules.filtering.applyFilters();
}
// Register with module system
window.app.modules.app = {
updateUI,
updateStatusBar,
escapeHtml,
updateFolderSelectionState,
getModifierType,
collectModifiers,
renderModifierDropdown,
toggleModifierFilter,
toggleAllModifiers,
updateModifierSelectAll,
updateModifierButtonLabel,
toggleModifierDropdown,
updateFolderVisibilityBtnLabel,
filePassesModifierFilter,
toggleFilterSelected,
isUnderHiddenFolderType,
ensureOutstandingTransmittal,
showHttpErrorState,
showUnsupportedBrowserMessage,
showProjectWarning,
dismissProjectWarning,
showEmptyState,
hideEmptyState,
addHttpSource,
scanHttpSource,
renderGroupingFolders,
renderTransmittalFolders,
renderFolderTypeBar,
toggleFolderType,
outstandingFileIsVisible,
hasVisibleOutstandingFiles,
transmittalIsUnderVisibleParty,
pathIsInVisibleProject,
renderFolderLists,
getFilteredGroupingFolders,
showProjectWarning,
dismissProjectWarning,
};
// Expose key functions on window for inline HTML handlers
window.initApp = initApp;
window.toggleFileSelection = function(id) { window.app.modules.table.toggleFileSelection(id); };
window.sortTable = function(f) { window.app.modules.table.sortTable(f); };
window.confirmTransmittal = function() { window.app.modules.dragDrop.confirmTransmittal(); };
window.toggleModifierFilter = toggleModifierFilter;
window.toggleFilterSelected = toggleFilterSelected;
window.toggleFolderType = toggleFolderType;
window.toggleGroupingFolder = function(p, r) { window.app.modules.events.toggleGroupingFolder(p, r); };
window.toggleDateGroup = function(d) { window.app.modules.events.toggleDateGroup(d); };
window.toggleAllDateGroups = function() { window.app.modules.events.toggleAllDateGroups(); };
window.selectAllVisibleFolders = function(t) { window.app.modules.events.selectAllVisibleFolders(t); };
window.removeDirectory = function(n) { window.app.modules.directory.removeDirectory(n); };
window.dismissProjectWarning = dismissProjectWarning;
window.verifyFileIntegrity = function(id) { window.app.modules.hash.verifyFileIntegrity(id); };
window.showProjectWarning = showProjectWarning;
window.dismissProjectWarning = dismissProjectWarning;
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', initApp);
})();