ZDDC/archive/js/app.js
ZDDC c95f07966d feat(tools,build): in-flight HTML-tool reworks and build-infra updates
Bundles a stretch of in-progress work across the SPA tools so the
tree returns to a coherent shippable state ahead of cutting a new
zddc-server stable image:

- landing: substantial rework of the project picker (sortable/filterable
  table, presets refactor, ?projects= filter, ?v= channel propagation,
  loading/error states)
- archive: presets cleanup, source.js refactor, filtering/url-state
  alignment with the landing page
- mdedit: file-system module split, resizer, file-tree improvements,
  base/toc styling tweaks
- transmittal/classifier: small template touch-ups for shared chrome
- shared: build-lib.sh helpers, new favicon.svg
- bootstrap, build.sh: pick up the channel-aware install/track zip
  generation
- tests: new landing.spec.js, expanded archive/mdedit/build-label specs
- docs: CLAUDE.md picks up the zddc-server section and freshens the
  alpha-build exception note
- regenerated artifacts: install.zip, track-{alpha,beta,stable}.zip,
  *_alpha.html — these are produced by `sh build.sh` and per project
  convention are committed alongside the source changes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:52:27 -05:00

974 lines
41 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
async function autoConnectHttpSource() {
var href = window.location.href;
// Strip query string and fragment
href = href.split('?')[0].split('#')[0];
// Strip the filename to get the directory
var lastSlash = href.lastIndexOf('/');
var baseUrl = (lastSlash >= 0) ? href.substring(0, lastSlash + 1) : 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).
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; }));
}
}
} 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-content');
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('app');
app.innerHTML = `
<div class="empty-state">
<div class="empty-state-content">
<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 any path segment of the transmittal folder matches a selected
// party name AND the segment immediately after it (if it's a folder-type name)
// is in enabledFolderTypes. Segment-equality matching means a party "BM" selected
// matches every "<...>/BM/<...>" path regardless of the prefix.
function transmittalIsUnderVisibleParty(folder) {
if (!pathIsInVisibleProject(folder.path)) return false;
const parts = folder.path.split('/');
for (let i = 0; i < parts.length; i++) {
if (!window.app.selectedGroupingFolders.has(parts[i])) continue;
// i-th segment is a selected party. The segment after is either a
// folder-type marker (Issued/Received/MDL/Incoming) or the transmittal
// folder itself.
const next = parts[i + 1];
if (!next) return true;
const folderType = next.toLowerCase();
if (window.app.FOLDER_TYPE_NAMES.includes(folderType)) {
return window.app.enabledFolderTypes.has(folderType);
}
return true;
}
return false;
}
// 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);
})();