GET / Accept:application/json changed shape in the May-2026 reshape: it returns listing.FileInfo entries (directory names carry a trailing '/', and the array can include non-directory entries) instead of the legacy ProjectInfo array (bare names). archive.html's multi-project mode (?projects=A,B) intersected those server names against the projectFilter parsed from the URL — which is slash-free — so every listed project missed the intersection, projectFilter emptied, the "you don't have access" banner showed, and nothing scanned: empty projects dropdown, no parties/transmittal folders. Normalise serverNames (and the projectTitles keys) to bare directory names and filter the listing to is_dir entries before intersecting. The scan in source.js already uses the slash-free projectFilter directly, so this single normalization restores the whole flow. Verified headless against a 2-project fixture, old vs new binary: old -> projectFilter [], no-access warning, no parties rendered; new -> projectFilter [182246,197072], no warning, ACME/BETACO parties rendered. Reaches prod via the next zddc-server release (archive.html is //go:embed'd). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1014 lines
44 KiB
JavaScript
1014 lines
44 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() {
|
|
// "Use 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') {
|
|
// GET / Accept: application/json returns listing.FileInfo
|
|
// entries (not the legacy ProjectInfo shape): directory
|
|
// names carry a trailing "/", and the listing can include
|
|
// non-directory entries. Normalise to bare directory names
|
|
// so they match the slash-free projectFilter parsed from
|
|
// ?projects= (url-state.js). Without this, every URL-listed
|
|
// project misses the intersection below → "no access"
|
|
// banner + empty scan.
|
|
var bareName = function (p) { return p.name.replace(/\/+$/, ''); };
|
|
var isProjectDir = function (p) { return p.is_dir === true || /\/$/.test(p.name); };
|
|
var projectEntries = serverProjects.filter(isProjectDir);
|
|
serverNames = new Set(projectEntries.map(bareName));
|
|
var titles = {};
|
|
projectEntries.forEach(function (p) {
|
|
if (p && typeof p.title === 'string' && p.title) {
|
|
titles[bareName(p)] = 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);
|
|
|
|
})();
|