ZDDC — Zero Day Document Control. A file-naming convention plus five single-file HTML tools (archive, transmittal, classifier, mdedit, landing) and an optional Go HTTP server (zddc-server) with ACL and a virtual archive index. Self-contained, offline-capable, dependency-free. See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the build/release/architecture detail, bootstrap/README.md for the two-level deployment install pattern, and zddc/README.md for the HTTP server.
592 lines
23 KiB
JavaScript
592 lines
23 KiB
JavaScript
(function() {
|
|
'use strict';
|
|
// Event handling
|
|
|
|
// Set up all event listeners
|
|
function setupEventListeners() {
|
|
// Header buttons
|
|
document.getElementById('addDirectoryBtn').addEventListener('click', () => window.app.modules.directory.addDirectory());
|
|
document.getElementById('refreshHeaderBtn').addEventListener('click', () => window.app.modules.directory.refreshDirectories());
|
|
|
|
// Content area buttons
|
|
document.getElementById('filterSelectedBtn').addEventListener('click', () => window.app.modules.app.toggleFilterSelected());
|
|
document.getElementById('downloadSelectedBtn').addEventListener('click', () => window.app.modules.export.downloadSelected());
|
|
document.getElementById('exportCsvBtn').addEventListener('click', () => window.app.modules.export.exportCSV());
|
|
|
|
// Search and filter inputs
|
|
document.getElementById('groupingFilter').addEventListener('input', (e) => {
|
|
window.app.groupingFilter = e.target.value;
|
|
e.target.classList.toggle('filter-active', e.target.value.length > 0);
|
|
window.app.modules.app.updateUI();
|
|
window.app.modules.filtering.applyFilters();
|
|
window.app.modules.urlState.push();
|
|
});
|
|
|
|
document.getElementById('transmittalFilter').addEventListener('input', (e) => {
|
|
window.app.transmittalFilter = e.target.value;
|
|
e.target.classList.toggle('filter-active', e.target.value.length > 0);
|
|
window.app.modules.app.updateUI();
|
|
window.app.modules.filtering.applyFilters(); // Re-filter files when transmittal filter changes
|
|
window.app.modules.urlState.push();
|
|
});
|
|
|
|
// Select All Grouping Folders checkbox
|
|
document.getElementById('selectAllGroupingCheckbox').addEventListener('change', (e) => {
|
|
window.app.selectAllGroupingFolders = e.target.checked;
|
|
window.app.modules.app.renderGroupingFolders();
|
|
window.app.modules.app.renderTransmittalFolders();
|
|
window.app.modules.filtering.applyFilters();
|
|
});
|
|
|
|
// Folder type toggle bar — global click delegation
|
|
document.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('.folder-type-toggle');
|
|
if (btn) {
|
|
const type = btn.getAttribute('data-type');
|
|
if (type) window.app.modules.app.toggleFolderType(type);
|
|
}
|
|
});
|
|
|
|
// Select All Transmittals checkbox
|
|
document.getElementById('selectAllTransmittalsCheckbox').addEventListener('change', (e) => {
|
|
window.app.selectAllTransmittals = e.target.checked;
|
|
window.app.modules.app.renderTransmittalFolders();
|
|
window.app.modules.filtering.applyFilters();
|
|
});
|
|
|
|
// Modifier filter dropdown
|
|
document.getElementById('modifierFilterBtn').addEventListener('click', () => window.app.modules.app.toggleModifierDropdown());
|
|
document.getElementById('modifierSelectAll').addEventListener('change', (e) => {
|
|
window.app.modules.app.toggleAllModifiers(e.target.checked);
|
|
});
|
|
|
|
// Close modifier dropdown when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
const container = document.querySelector('.modifier-filter-container');
|
|
const dropdown = document.getElementById('modifierFilterDropdown');
|
|
if (container && dropdown && !container.contains(e.target)) {
|
|
dropdown.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Select all visible files checkbox
|
|
document.getElementById('selectAllVisibleCheckbox').addEventListener('change', (e) => {
|
|
e.stopPropagation();
|
|
window.app.modules.table.toggleSelectAllVisible(e.target.checked);
|
|
});
|
|
|
|
// Reset filters button
|
|
document.getElementById('resetFiltersBtn').addEventListener('click', () => window.app.modules.filtering.clearFilters());
|
|
|
|
// Column filters — delegated from thead
|
|
const thead = document.querySelector('thead');
|
|
if (thead) {
|
|
thead.addEventListener('input', (e) => {
|
|
if (e.target.matches('.column-filter[data-filter-field]')) {
|
|
const field = e.target.getAttribute('data-filter-field');
|
|
const raw = e.target.value.trim();
|
|
window.app.columnFilters[field] = raw;
|
|
window.app.columnFilterASTs[field] = zddc.filter.parse(raw);
|
|
|
|
// Add/remove filter-active class based on non-empty value
|
|
if (raw) {
|
|
e.target.classList.add('filter-active');
|
|
} else {
|
|
e.target.classList.remove('filter-active');
|
|
}
|
|
|
|
window.app.modules.filtering.applyFilters();
|
|
window.app.modules.urlState.push();
|
|
}
|
|
});
|
|
thead.addEventListener('keydown', (e) => {
|
|
if (!e.target.matches('.column-filter[data-filter-field]')) return;
|
|
if (e.key === 'Escape') {
|
|
e.target.value = '';
|
|
e.target.classList.remove('filter-active');
|
|
const field = e.target.getAttribute('data-filter-field');
|
|
window.app.columnFilters[field] = '';
|
|
window.app.columnFilterASTs[field] = null;
|
|
window.app.modules.filtering.applyFilters();
|
|
window.app.modules.urlState.push();
|
|
e.preventDefault();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const inputs = Array.from(thead.querySelectorAll('.column-filter'));
|
|
const idx = inputs.indexOf(e.target);
|
|
if (idx !== -1) {
|
|
inputs[(idx + 1) % inputs.length].focus();
|
|
}
|
|
}
|
|
});
|
|
thead.addEventListener('click', (e) => {
|
|
if (e.target.matches('.column-filter')) {
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Table sorting
|
|
document.querySelectorAll('.sortable').forEach(th => {
|
|
th.querySelector('.th-content').addEventListener('click', () => {
|
|
const field = th.getAttribute('data-field');
|
|
window.app.modules.table.sortTable(field);
|
|
});
|
|
});
|
|
|
|
// Initialize column resize
|
|
window.app.modules.table.initializeColumnResize();
|
|
|
|
// Modal close buttons
|
|
document.querySelectorAll('.modal-close').forEach(btn => {
|
|
btn.addEventListener('click', closeModal);
|
|
});
|
|
|
|
// Modal backdrop clicks
|
|
document.querySelectorAll('.modal-backdrop').forEach(backdrop => {
|
|
backdrop.addEventListener('click', closeModal);
|
|
});
|
|
|
|
// Drop modal buttons
|
|
const dropModal = document.getElementById('dropModal');
|
|
dropModal.querySelector('.modal-cancel').addEventListener('click', closeModal);
|
|
dropModal.querySelector('.modal-confirm').addEventListener('click', () => window.app.modules.dragDrop.confirmTransmittal());
|
|
|
|
// Drag and drop (local mode only — requires write access)
|
|
if (window.app.sourceMode === 'local') {
|
|
window.app.modules.dragDrop.setupDragAndDrop();
|
|
}
|
|
|
|
// Multi-select for folders
|
|
setupFolderMultiSelect();
|
|
|
|
// Date group toggle handlers
|
|
setupDateGroupToggles();
|
|
|
|
// Grouping section collapse toggle
|
|
setupGroupingToggle();
|
|
|
|
// Resizable panes
|
|
setupResizablePanes();
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', handleKeyboardShortcuts);
|
|
}
|
|
|
|
|
|
|
|
// Handle grouping filter
|
|
function handleGroupingFilter(e) {
|
|
window.app.groupingFilter = e.target.value;
|
|
window.app.modules.app.renderGroupingFolders();
|
|
// Re-render transmittal folders as they depend on grouping selection
|
|
window.app.modules.app.renderTransmittalFolders();
|
|
// Re-filter files based on updated folder selections
|
|
window.app.modules.filtering.applyFilters();
|
|
}
|
|
|
|
// Handle transmittal filter
|
|
function handleTransmittalFilter(e) {
|
|
window.app.transmittalFilter = e.target.value;
|
|
window.app.modules.app.renderTransmittalFolders();
|
|
// Re-filter files based on updated folder selections
|
|
window.app.modules.filtering.applyFilters();
|
|
}
|
|
|
|
// Close modal
|
|
function closeModal(e) {
|
|
const modal = e.target.closest('.modal');
|
|
if (modal) {
|
|
modal.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
// Handle keyboard shortcuts
|
|
function handleKeyboardShortcuts(e) {
|
|
// Escape closes modals
|
|
if (e.key === 'Escape') {
|
|
document.querySelectorAll('.modal:not(.hidden)').forEach(modal => {
|
|
modal.classList.add('hidden');
|
|
});
|
|
}
|
|
|
|
// Ctrl+A selects all visible files
|
|
if (e.ctrlKey && e.key === 'a' && e.target.tagName !== 'INPUT') {
|
|
e.preventDefault();
|
|
toggleSelectAll();
|
|
}
|
|
|
|
// F5 refreshes
|
|
if (e.key === 'F5') {
|
|
e.preventDefault();
|
|
window.app.modules.directory.refreshDirectories();
|
|
}
|
|
}
|
|
|
|
// Utility: Debounce function
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Multi-select handling for folder lists
|
|
function setupFolderMultiSelect() {
|
|
let lastSelectedGroupingIndex = -1;
|
|
let lastSelectedTransmittalIndex = -1;
|
|
|
|
// Handle grouping folders
|
|
const groupingList = document.getElementById('groupingFoldersList');
|
|
groupingList.addEventListener('click', (e) => {
|
|
const result = handleFolderClick(e, window.app.selectedGroupingFolders, lastSelectedGroupingIndex);
|
|
if (result !== undefined) {
|
|
lastSelectedGroupingIndex = result;
|
|
// Turn off "Select All" mode when user manually selects
|
|
if (window.app.selectAllGroupingFolders) {
|
|
window.app.selectAllGroupingFolders = false;
|
|
document.getElementById('selectAllGroupingCheckbox').checked = false;
|
|
}
|
|
// Update selection state first
|
|
window.app.modules.app.updateFolderSelectionState('groupingFoldersList');
|
|
// Then update transmittal folder list based on new selection
|
|
window.app.modules.app.renderTransmittalFolders();
|
|
window.app.modules.filtering.applyFilters(); // Re-filter files
|
|
// Check presets dirty state
|
|
if (window.app.modules.presets) {
|
|
window.app.modules.presets.checkDirty();
|
|
}
|
|
// Reset transmittal index since list may have changed
|
|
lastSelectedTransmittalIndex = -1;
|
|
}
|
|
});
|
|
|
|
// Handle transmittal folders
|
|
const transmittalList = document.getElementById('transmittalFoldersList');
|
|
transmittalList.addEventListener('click', (e) => {
|
|
const result = handleFolderClick(e, window.app.selectedTransmittalFolders, lastSelectedTransmittalIndex);
|
|
if (result !== undefined) {
|
|
lastSelectedTransmittalIndex = result;
|
|
// Turn off "Select All" mode when user manually selects
|
|
if (window.app.selectAllTransmittals) {
|
|
window.app.selectAllTransmittals = false;
|
|
document.getElementById('selectAllTransmittalsCheckbox').checked = false;
|
|
}
|
|
// Update selection state without rebuilding DOM
|
|
window.app.modules.app.updateFolderSelectionState('transmittalFoldersList');
|
|
window.app.modules.filtering.applyFilters(); // Update file display
|
|
}
|
|
});
|
|
|
|
// Handle Ctrl+A for folder lists
|
|
groupingList.addEventListener('keydown', (e) => {
|
|
if (e.ctrlKey && e.key === 'a') {
|
|
e.preventDefault();
|
|
selectAllVisibleFolders('grouping');
|
|
}
|
|
});
|
|
|
|
transmittalList.addEventListener('keydown', (e) => {
|
|
if (e.ctrlKey && e.key === 'a') {
|
|
e.preventDefault();
|
|
selectAllVisibleFolders('transmittal');
|
|
}
|
|
});
|
|
|
|
// Make lists focusable
|
|
groupingList.setAttribute('tabindex', '0');
|
|
transmittalList.setAttribute('tabindex', '0');
|
|
}
|
|
|
|
/**
|
|
* Handle folder click with multi-select support (Shift/Ctrl)
|
|
* @param {Event} e - Click event
|
|
* @param {Set} selectedSet - Set of selected folder paths
|
|
* @param {number} lastIndex - Index of last clicked item
|
|
* @returns {number|undefined} Current index if valid click, undefined otherwise
|
|
*/
|
|
function handleFolderClick(e, selectedSet, lastIndex) {
|
|
const folderItem = e.target.closest('.folder-item');
|
|
if (!folderItem) return undefined;
|
|
|
|
const path = folderItem.getAttribute('data-path');
|
|
if (!path) return undefined;
|
|
|
|
const container = folderItem.parentElement;
|
|
const items = Array.from(container.children);
|
|
const currentIndex = items.indexOf(folderItem);
|
|
|
|
if (e.shiftKey && lastIndex !== -1 && lastIndex < items.length) {
|
|
// Shift+click: select range from last to current
|
|
e.preventDefault();
|
|
const start = Math.min(lastIndex, currentIndex);
|
|
const end = Math.max(lastIndex, currentIndex);
|
|
|
|
if (!e.ctrlKey) {
|
|
selectedSet.clear();
|
|
}
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
const itemPath = items[i]?.getAttribute('data-path');
|
|
if (itemPath) {
|
|
selectedSet.add(itemPath);
|
|
}
|
|
}
|
|
} else if (e.ctrlKey || e.metaKey) {
|
|
// Ctrl+click: toggle individual selection
|
|
e.preventDefault();
|
|
if (selectedSet.has(path)) {
|
|
selectedSet.delete(path);
|
|
} else {
|
|
selectedSet.add(path);
|
|
}
|
|
} else {
|
|
// Regular click: clear and select single item
|
|
selectedSet.clear();
|
|
selectedSet.add(path);
|
|
}
|
|
|
|
return currentIndex;
|
|
}
|
|
|
|
/**
|
|
* Toggle expand/collapse state of a grouping folder
|
|
* @param {string} path - Folder path to toggle
|
|
* @param {boolean} recursive - If true, also toggle all descendants
|
|
*/
|
|
function toggleGroupingFolder(path, recursive) {
|
|
const isCurrentlyCollapsed = window.app.collapsedGroupingFolders.has(path);
|
|
|
|
if (recursive) {
|
|
// Get all descendant folder paths
|
|
const descendants = window.app.groupingFolders
|
|
.filter(f => f.path.startsWith(path + '/'))
|
|
.map(f => f.path);
|
|
|
|
if (isCurrentlyCollapsed) {
|
|
// Expand this folder and all descendants
|
|
window.app.collapsedGroupingFolders.delete(path);
|
|
descendants.forEach(p => window.app.collapsedGroupingFolders.delete(p));
|
|
} else {
|
|
// Collapse this folder and all descendants
|
|
window.app.collapsedGroupingFolders.add(path);
|
|
descendants.forEach(p => window.app.collapsedGroupingFolders.add(p));
|
|
}
|
|
} else {
|
|
// Just toggle this folder
|
|
if (isCurrentlyCollapsed) {
|
|
window.app.collapsedGroupingFolders.delete(path);
|
|
} else {
|
|
window.app.collapsedGroupingFolders.add(path);
|
|
}
|
|
}
|
|
|
|
window.app.modules.app.renderGroupingFolders();
|
|
}
|
|
|
|
// Select all visible folders
|
|
function selectAllVisibleFolders(folderType) {
|
|
const container = folderType === 'grouping' ?
|
|
document.getElementById('groupingFoldersList') :
|
|
document.getElementById('transmittalFoldersList');
|
|
|
|
const selectedSet = folderType === 'grouping' ?
|
|
window.app.selectedGroupingFolders :
|
|
window.app.selectedTransmittalFolders;
|
|
|
|
selectedSet.clear();
|
|
|
|
const items = container.querySelectorAll('.folder-item');
|
|
items.forEach(item => {
|
|
const path = item.getAttribute('data-path');
|
|
if (path) {
|
|
selectedSet.add(path);
|
|
}
|
|
});
|
|
|
|
if (folderType === 'grouping') {
|
|
// Update UI to reflect grouping changes
|
|
window.app.modules.app.updateUI();
|
|
window.app.modules.filtering.applyFilters();
|
|
} else {
|
|
// For transmittal folders, just update selection state
|
|
window.app.modules.app.updateFolderSelectionState('transmittalFoldersList');
|
|
window.app.modules.filtering.applyFilters();
|
|
}
|
|
}
|
|
|
|
// Setup date group toggle handlers
|
|
function setupDateGroupToggles() {
|
|
// Toggle all dates button
|
|
const toggleAllBtn = document.getElementById('toggleAllDatesBtn');
|
|
if (toggleAllBtn) {
|
|
toggleAllBtn.addEventListener('click', toggleAllDateGroups);
|
|
}
|
|
|
|
// Individual date group headers (using event delegation)
|
|
const transmittalList = document.getElementById('transmittalFoldersList');
|
|
transmittalList.addEventListener('click', (e) => {
|
|
const header = e.target.closest('.date-group-header');
|
|
if (header) {
|
|
const date = header.getAttribute('data-date');
|
|
if (date) {
|
|
toggleDateGroup(date);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Toggle a single date group
|
|
function toggleDateGroup(date) {
|
|
if (window.app.collapsedDateGroups.has(date)) {
|
|
window.app.collapsedDateGroups.delete(date);
|
|
} else {
|
|
window.app.collapsedDateGroups.add(date);
|
|
}
|
|
window.app.modules.app.renderTransmittalFolders();
|
|
updateToggleAllIcon();
|
|
}
|
|
|
|
// Toggle all date groups
|
|
function toggleAllDateGroups() {
|
|
const headers = document.querySelectorAll('.date-group-header');
|
|
const allDates = Array.from(headers).map(h => h.getAttribute('data-date')).filter(Boolean);
|
|
|
|
// If all are collapsed, expand all. Otherwise, collapse all.
|
|
const allCollapsed = allDates.length > 0 && allDates.every(date => window.app.collapsedDateGroups.has(date));
|
|
|
|
if (allCollapsed) {
|
|
// Expand all
|
|
window.app.collapsedDateGroups.clear();
|
|
} else {
|
|
// Collapse all
|
|
allDates.forEach(date => window.app.collapsedDateGroups.add(date));
|
|
}
|
|
|
|
window.app.modules.app.renderTransmittalFolders();
|
|
updateToggleAllIcon();
|
|
}
|
|
|
|
// Update the toggle all icon based on current state
|
|
function updateToggleAllIcon() {
|
|
const icon = document.getElementById('toggleAllDatesIcon');
|
|
if (!icon) return;
|
|
|
|
const headers = document.querySelectorAll('.date-group-header');
|
|
const allDates = Array.from(headers).map(h => h.getAttribute('data-date')).filter(Boolean);
|
|
const allCollapsed = allDates.length > 0 && allDates.every(date => window.app.collapsedDateGroups.has(date));
|
|
|
|
icon.textContent = allCollapsed ? '▶' : '▼';
|
|
}
|
|
|
|
// Setup grouping section collapse toggle
|
|
function setupGroupingToggle() {
|
|
const toggleBtn = document.getElementById('toggleGroupingBtn');
|
|
const groupingSection = document.getElementById('groupingSection');
|
|
const icon = document.getElementById('toggleGroupingIcon');
|
|
|
|
if (toggleBtn && groupingSection && icon) {
|
|
toggleBtn.addEventListener('click', () => {
|
|
groupingSection.classList.toggle('collapsed');
|
|
icon.textContent = groupingSection.classList.contains('collapsed') ? '▶' : '▼';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Setup resizable panes
|
|
function setupResizablePanes() {
|
|
// Resize nav sections (vertical divider between grouping and transmittal)
|
|
const navSectionsHandle = document.querySelector('[data-resize="nav-sections"]');
|
|
if (navSectionsHandle) {
|
|
let isResizing = false;
|
|
let startY = 0;
|
|
let startHeight = 0;
|
|
let groupingSection = null;
|
|
|
|
navSectionsHandle.addEventListener('mousedown', (e) => {
|
|
isResizing = true;
|
|
startY = e.clientY;
|
|
groupingSection = document.getElementById('groupingSection');
|
|
startHeight = groupingSection.offsetHeight;
|
|
navSectionsHandle.classList.add('resizing');
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!isResizing) return;
|
|
|
|
const deltaY = e.clientY - startY;
|
|
const newHeight = startHeight + deltaY;
|
|
|
|
// Set min/max heights
|
|
if (newHeight >= 100 && newHeight <= window.innerHeight - 250) {
|
|
groupingSection.style.flex = 'none';
|
|
groupingSection.style.height = newHeight + 'px';
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
if (isResizing) {
|
|
isResizing = false;
|
|
navSectionsHandle.classList.remove('resizing');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Resize nav pane (horizontal divider between nav and content)
|
|
const navPaneHandle = document.querySelector('[data-resize="nav-pane"]');
|
|
if (navPaneHandle) {
|
|
let isResizing = false;
|
|
let startX = 0;
|
|
let startWidth = 0;
|
|
let navPane = null;
|
|
|
|
navPaneHandle.addEventListener('mousedown', (e) => {
|
|
isResizing = true;
|
|
startX = e.clientX;
|
|
navPane = document.getElementById('navigationPane');
|
|
startWidth = navPane.offsetWidth;
|
|
navPaneHandle.classList.add('resizing');
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!isResizing) return;
|
|
|
|
const deltaX = e.clientX - startX;
|
|
const newWidth = startWidth + deltaX;
|
|
|
|
// Set min/max widths
|
|
if (newWidth >= 200 && newWidth <= window.innerWidth - 400) {
|
|
navPane.style.width = newWidth + 'px';
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
if (isResizing) {
|
|
isResizing = false;
|
|
navPaneHandle.classList.remove('resizing');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
window.app.modules.events = {
|
|
setupEventListeners,
|
|
handleFolderClick,
|
|
toggleGroupingFolder,
|
|
selectAllVisibleFolders,
|
|
setupDateGroupToggles,
|
|
toggleDateGroup,
|
|
toggleAllDateGroups,
|
|
updateToggleAllIcon,
|
|
setupGroupingToggle,
|
|
setupResizablePanes
|
|
};
|
|
|
|
})();
|