/** * ZDDC Classifier - Main Application * Spreadsheet-based file renaming with Excel-like formulas */ (function() { 'use strict'; // Global application state window.app = { // File System rootHandle: null, // Data folderTree: [], selectedFolders: new Set(), // Multi-select support lastSelectedFolderPath: null, hideCompliant: false, calculateSha256: false, // DOM elements (populated on init) dom: {}, // Modules (populated by other files) modules: {} }; /** * Initialize the application */ function init() { // Cache DOM elements + wire events first so the welcome screen // (and the HTTP-mode auto-load below) can use them. cacheDOMElements(); setupEventListeners(); // Workspace manager (renders the welcome list, owns new/open/autosave). if (app.modules.workspace) app.modules.workspace.init(); // Browser-compatibility branch: // HTTP mode (served by zddc-server) — works everywhere; the // HTTP polyfill stands in for the FS Access API. Auto-load // the directory the page lives in. // Local mode (file://) — requires FS Access API for write // access to the user-picked folder. Show the warning if // the API is missing. if (location.protocol === 'http:' || location.protocol === 'https:') { // Don't disable the picker button — even in HTTP mode the // user might want to add a local folder. But the auto-load // below means the welcome screen usually never shows. (async function () { try { var probe = await window.zddc.source.detectServerRoot(); if (probe.handle) { await openDirectory(probe.handle); return; } if (probe.status === 403) { showHttpForbiddenMessage(); return; } } catch (err) { console.warn('classifier: server-mode auto-load failed:', err); } // Server-mode probe inconclusive — fall through to welcome. if (!checkBrowserCompatibility()) { showBrowserWarning(); return; } showWelcomeScreen(); })(); return; } if (!checkBrowserCompatibility()) { showBrowserWarning(); return; } showWelcomeScreen(); } /** * Check if browser supports File System Access API. Used in local * (file://) mode only — HTTP mode runs through the HTTP polyfill, * which has no browser dependency beyond fetch. */ function checkBrowserCompatibility() { return 'showDirectoryPicker' in window; } /** * Show a clear "no permission to list" message for HTTP-mode users * who land on a path their ACL doesn't allow them to list. Distinct * from the welcome screen so the user understands why the file tree * is empty rather than wondering if they need to pick a folder. */ function showHttpForbiddenMessage() { var screen = document.getElementById('welcomeScreen'); if (!screen) return; screen.classList.remove('hidden'); var msg = document.createElement('div'); msg.className = 'classifier-forbidden-message'; msg.style.cssText = 'padding: 1.5rem; max-width: 36rem; margin: 0 auto; text-align: center;'; msg.innerHTML = '
Your account does not have read access to this folder. ' + 'You may still be able to upload files if your role allows it; ' + 'contact the document controller if you believe this is wrong.
'; screen.appendChild(msg); var addBtn = document.getElementById('addDirectoryBtn'); if (addBtn) addBtn.disabled = true; } /** * Show browser compatibility warning */ function showBrowserWarning() { const warning = document.getElementById('browserWarning'); const selectBtn = document.getElementById('addDirectoryBtn'); if (warning) { warning.classList.remove('hidden'); } if (selectBtn) { selectBtn.disabled = true; selectBtn.textContent = 'Browser Not Supported'; } } /** * Cache DOM element references */ function cacheDOMElements() { app.dom = { // Screens welcomeScreen: document.getElementById('welcomeScreen'), mainApp: document.getElementById('mainApp'), // Header buttons addDirectoryBtn: document.getElementById('addDirectoryBtn'), refreshHeaderBtn: document.getElementById('refreshHeaderBtn'), saveAllBtn: document.getElementById('saveAllBtn'), cancelAllBtn: document.getElementById('cancelAllBtn'), exportHashesBtn: document.getElementById('exportHashesBtn'), sha256Checkbox: document.getElementById('sha256Checkbox'), hideCompliantCheckbox: document.getElementById('hideCompliantCheckbox'), hideCompliantLabel: document.getElementById('hideCompliantLabel'), classifyFilters: document.getElementById('classifyFilters'), showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'), showPartialCheckbox: document.getElementById('showPartialCheckbox'), showAssignedCheckbox: document.getElementById('showAssignedCheckbox'), showExcludedCheckbox: document.getElementById('showExcludedCheckbox'), showEmptyCheckbox: document.getElementById('showEmptyCheckbox'), exportDatasetBtn: document.getElementById('exportDatasetBtn'), importDatasetBtn: document.getElementById('importDatasetBtn'), importDatasetInput: document.getElementById('importDatasetInput'), resetDatasetBtn: document.getElementById('resetDatasetBtn'), treeFilterInput: document.getElementById('treeFilterInput'), trackingFilterInput: document.getElementById('trackingFilterInput'), transmittalFilterInput: document.getElementById('transmittalFilterInput'), // Folder tree folderTree: document.getElementById('folderTree'), folderTreePane: document.getElementById('folderTreePane'), collapseTreeBtn: document.getElementById('collapseTreeBtn'), autoScrollCheckbox: document.getElementById('autoScrollCheckbox'), selectedFoldersCount: document.getElementById('selectedFoldersCount'), // Spreadsheet spreadsheet: document.getElementById('spreadsheet'), spreadsheetBody: document.getElementById('spreadsheetBody'), sha256Column: document.getElementById('sha256Column'), // Stats totalFiles: document.getElementById('totalFiles'), modifiedFiles: document.getElementById('modifiedFiles'), errorFiles: document.getElementById('errorFiles'), // Preview togglePreviewBtn: document.getElementById('togglePreviewBtn'), // Mode switch + Classify & Copy panes modeRenameBtn: document.getElementById('modeRenameBtn'), modeClassifyBtn: document.getElementById('modeClassifyBtn'), spreadsheetPane: document.getElementById('spreadsheetPane'), targetPane: document.getElementById('targetPane'), copyOutputBtn: document.getElementById('copyOutputBtn'), checkDuplicatesBtn: document.getElementById('checkDuplicatesBtn') }; } /** * Switch between "Rename" (in-place grid) and "Classify & Copy" (map files * onto target trees, copy renamed copies out). The source tree (left) stays * in both modes; only the right pane swaps. */ function setMode(mode) { const classify = mode === 'classify'; app.dom.modeRenameBtn.classList.toggle('active', !classify); app.dom.modeClassifyBtn.classList.toggle('active', classify); if (app.dom.spreadsheetPane) app.dom.spreadsheetPane.hidden = classify; if (app.dom.targetPane) app.dom.targetPane.hidden = !classify; // Mode-specific source-tree filters: "Hide Compliant" is for the rename // grid; "Hide Assigned" is for the classify workflow. if (app.dom.hideCompliantLabel) app.dom.hideCompliantLabel.hidden = classify; if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = !classify; app.modules.classify.setEnabled(classify); if (classify && app.modules.targetTree) { app.modules.targetTree.init(); app.modules.targetTree.render(); } // Re-render the source tree so its per-file markers appear/disappear. if (app.modules.tree) app.modules.tree.render(); } // ── dataset export / import (one record per file) ────────────────────── // Round-trip the classification as a flat list of files, each carrying its // full ZDDC filename (and optional transmittal). An external editor (e.g. an // AI) just sets filenames; on import the app parses each filename and // rebuilds the tracking tree (no node ids to manage). function eachSourceFile(cb) { (function walk(nodes) { (nodes || []).forEach(function (n) { (n.files || []).forEach(cb); walk(n.children); }); })(app.folderTree || []); } function exportDataset() { var c = app.modules.classify, files = []; eachSourceFile(function (f) { var key = c.srcKeyForFile(f); var a = c.getAssignment(key) || {}; var d = c.deriveTarget(f); var rec = { source: key, originalName: window.zddc.joinExtension(f.originalFilename, f.extension), filename: a.excluded ? '' : (d.filename || ''), excluded: !!a.excluded, }; if (!a.excluded && a.transmittalNodeId) { var t = c.transmittalRecord(a.transmittalNodeId); if (t) rec.transmittal = t; } files.push(rec); }); var payload = { zddcClassifierFiles: 1, exportedAt: new Date().toISOString(), _format: 'One record per input file. Set "filename" to its full ZDDC name ' + '"TRACKING_REV (STATUS) - Title.ext" — on import the app splits TRACKING on "-" and the ' + 'final "_" into nested folders, and files in shared paths share ancestors. Set ' + '"excluded": true for non-documents (filename then ignored). "transmittal" is optional: ' + '{party, slot:"received"|"issued", date:"YYYY-MM-DD", type:"TRN"|"SUB", seq, status, title}. ' + 'Classify every "source" key; do not invent files.', outputName: c.serialize().outputName || null, files: files, }; var name = 'classifier-dataset'; try { if (app.modules.workspace && typeof app.modules.workspace.activeName === 'function') { name = app.modules.workspace.activeName() || name; } } catch (_) { /* ok */ } var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = String(name).replace(/[^\w.-]+/g, '_') + '.zddc-classification.json'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } function importDataset(file) { var reader = new FileReader(); reader.onload = function () { var obj; try { obj = JSON.parse(reader.result); } catch (e) { window.zddc.toast('Import failed — not valid JSON.', 'error'); return; } if (!obj || !Array.isArray(obj.files)) { window.zddc.toast('Import failed — expected a classifier dataset with a "files" list.', 'error'); return; } var c = app.modules.classify; var hasData = c.getTrackingTree().length || c.getTransmittalTree().length || Object.keys(c.serialize().assignments || {}).length; if (hasData && !confirm('Replace the current classification with the imported dataset?')) return; c.reset(); var ok = 0, bad = 0; obj.files.forEach(function (rec) { if (!rec || !rec.source) return; var key = rec.source; if (rec.excluded) { c.setExcluded([key], true); ok++; return; } if (rec.filename) { var p = window.zddc.parseFilename(String(rec.filename).trim()); if (p && p.valid) { var stem = p.trackingNumber + '_' + p.revision + ' (' + p.status + ')'; c.place([key], c.addTrackingPath(null, c.parseFolderLevels(stem)), 'tracking'); if (p.title != null) c.setTitleOverride(key, p.title); ok++; } else { bad++; } } if (rec.transmittal && rec.transmittal.party) { var t = rec.transmittal; var pid = c.findOrAddParty(t.party); var bid = c.findOrAddTransmittalBin(pid, t.slot || 'received', { date: t.date, type: t.type || 'TRN', seq: t.seq, status: t.status, title: t.title, }); if (bid) c.place([key], bid, 'transmittal'); } }); window.zddc.toast('Imported ' + ok + ' file' + (ok === 1 ? '' : 's') + (bad ? (' — ' + bad + ' had an unparseable filename') : '') + '.', bad ? 'warning' : 'success'); }; reader.onerror = function () { window.zddc.toast('Import failed — could not read the file.', 'error'); }; reader.readAsText(file); } // Reset to a clean state: keep the scanned source tree (the raw input), but // discard every classification — trees, assignments, excludes, overrides. // Destructive and irreversible, so warn and steer the user to Export first. function resetDataset() { var c = app.modules.classify; var n = Object.keys(c.serialize().assignments || {}).length; if (!n && !c.getTrackingTree().length && !c.getTransmittalTree().length) { window.zddc.toast('Nothing to reset — already at the raw input.', 'info'); return; } if (!confirm('Reset to a clean state?\n\nThis discards ALL classifications (' + n + ' assigned file' + (n === 1 ? '' : 's') + ', plus the tracking and ' + 'transmittal trees) and returns to just the raw scanned input. Your actual ' + 'files are not touched.\n\nThis cannot be undone — Export first if you might ' + 'need this data.')) return; c.reset(); window.zddc.toast('Reset to the raw scanned input.', 'success'); } /** * Set up event listeners */ function setupEventListeners() { // Directory selection app.dom.addDirectoryBtn.addEventListener('click', handleSelectDirectory); app.dom.refreshHeaderBtn.addEventListener('click', handleRefresh); // Drag and drop on welcome screen setupWelcomeDragDrop(); // Bulk actions app.dom.saveAllBtn.addEventListener('click', handleSaveAll); app.dom.cancelAllBtn.addEventListener('click', handleCancelAll); // Export hashes app.dom.exportHashesBtn.addEventListener('click', handleExportHashes); // SHA256 toggle app.dom.sha256Checkbox.addEventListener('change', handleSha256Toggle); // Hide compliant toggle app.dom.hideCompliantCheckbox.addEventListener('change', handleHideCompliantToggle); // Classify-mode source-tree filters: show/hide unassigned, assigned, excluded. function pushClassifyFilters() { if (app.modules.tree && app.modules.tree.setShowFilters) { app.modules.tree.setShowFilters({ unassigned: app.dom.showUnassignedCheckbox.checked, partial: app.dom.showPartialCheckbox.checked, assigned: app.dom.showAssignedCheckbox.checked, excluded: app.dom.showExcludedCheckbox.checked, empty: app.dom.showEmptyCheckbox.checked, }); } } [app.dom.showUnassignedCheckbox, app.dom.showPartialCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox] .forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); }); // Collapse tree button app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree); // Workflow mode switch if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); }); if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); }); if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); }); if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); }); var mdlBtn = document.getElementById('mdlInstantiateBtn'); if (mdlBtn) mdlBtn.addEventListener('click', function () { app.modules.mdlInstantiate.open(); }); // Live source-tree filter (matches file path + name; reveals the hierarchy). if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () { if (app.modules.tree && app.modules.tree.setNameFilter) app.modules.tree.setNameFilter(this.value); }); // Target-tree filter — both tabs share one query (mirrored across inputs). function targetFilter(val) { if (app.dom.trackingFilterInput) app.dom.trackingFilterInput.value = val; if (app.dom.transmittalFilterInput) app.dom.transmittalFilterInput.value = val; if (app.modules.targetTree && app.modules.targetTree.setNameFilter) app.modules.targetTree.setNameFilter(val); } [app.dom.trackingFilterInput, app.dom.transmittalFilterInput].forEach(function (inp) { if (inp) inp.addEventListener('input', function () { targetFilter(this.value); }); }); // Dataset export / import (round-trip the classification through a JSON file). if (app.dom.exportDatasetBtn) app.dom.exportDatasetBtn.addEventListener('click', exportDataset); if (app.dom.importDatasetBtn) app.dom.importDatasetBtn.addEventListener('click', function () { app.dom.importDatasetInput.click(); }); if (app.dom.resetDatasetBtn) app.dom.resetDatasetBtn.addEventListener('click', resetDataset); if (app.dom.importDatasetInput) app.dom.importDatasetInput.addEventListener('change', function () { if (this.files && this.files[0]) importDataset(this.files[0]); this.value = ''; // allow re-importing the same file }); // Keyboard shortcuts document.addEventListener('keydown', handleKeyDown); // Resize handle setupResizeHandle(); // Re-render the source tree when classify state changes (so file dots // and placements stay in sync after a drop). Cheap no-op outside // classify mode. if (app.modules.classify) { app.modules.classify.on(function () { if (app.modules.classify.isEnabled() && app.modules.tree) app.modules.tree.render(); }); } } /** * Handle collapse/expand folder tree pane */ function handleCollapseTree() { const pane = app.dom.folderTreePane; const btn = app.dom.collapseTreeBtn; pane.classList.toggle('collapsed'); if (pane.classList.contains('collapsed')) { // Clear any inline width from resize handle pane.style.width = ''; btn.textContent = '▶'; btn.title = 'Expand folder tree'; } else { btn.textContent = '◀'; btn.title = 'Collapse folder tree'; } } /** * Set up folder tree resize handle */ function setupResizeHandle() { const handle = document.getElementById('treeResizeHandle'); const pane = document.getElementById('folderTreePane'); if (!handle || !pane) return; let isResizing = false; let startX = 0; let startWidth = 0; handle.addEventListener('mousedown', (e) => { isResizing = true; startX = e.clientX; startWidth = pane.offsetWidth; document.body.style.cursor = 'col-resize'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const delta = e.clientX - startX; const newWidth = startWidth + delta; // Respect min width only if (newWidth >= 150) { pane.style.width = newWidth + 'px'; } }); document.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; document.body.style.cursor = ''; } }); } /** * Set up drag-and-drop on the welcome screen */ function setupWelcomeDragDrop() { const screen = app.dom.welcomeScreen; if (!screen) return; ['dragenter', 'dragover'].forEach(evt => { screen.addEventListener(evt, (e) => { e.preventDefault(); screen.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach(evt => { screen.addEventListener(evt, (e) => { e.preventDefault(); screen.classList.remove('drag-over'); }); }); screen.addEventListener('drop', async (e) => { const item = e.dataTransfer.items && e.dataTransfer.items[0]; if (!item) return; const handle = await item.getAsFileSystemHandle(); if (!handle || handle.kind !== 'directory') { alert('Please drop a folder, not a file.'); return; } await openDirectory(handle); }); } /** * Handle directory selection via button click */ async function handleSelectDirectory() { try { const dirHandle = await window.showDirectoryPicker(); await openDirectory(dirHandle); } catch (err) { if (err.name !== 'AbortError') { console.error('Error selecting directory:', err); alert('Error selecting directory: ' + err.message); } } } /** * Open a directory handle and initialize the application */ // Show the main UI and initialize the per-tool modules ONCE. Shared by the // legacy rename open and the workspace open/new flows (the latter scan or // load a snapshot themselves). var shellInited = false; function enterAppShell() { hideWelcomeScreen(); showMainUI(); if (!shellInited) { shellInited = true; app.modules.spreadsheet.init(); // Subscribe to store app.modules.selection.init(); app.modules.preview.init(); // After selection so it can listen for rowfocused app.modules.resize.init(); app.modules.filter.init(); app.modules.sort.init(); app.modules.tree.setupKeyboardShortcuts(); if (app.modules.targetTree) app.modules.targetTree.init(); } if (app.dom.refreshHeaderBtn) app.dom.refreshHeaderBtn.classList.remove('hidden'); } async function openDirectory(dirHandle) { app.rootHandle = dirHandle; enterAppShell(); // Default to Classify & Copy (the primary workflow). The user can switch // to "Rename in place" via the toggle for the spreadsheet. setMode('classify'); // Now scan directory (this will trigger store updates and renders) await app.modules.scanner.scanDirectory(dirHandle); } /** * Handle Refresh button - rescan current directory */ async function handleRefresh() { if (!app.rootHandle) { alert('No directory selected'); return; } try { // A snapshot-loaded workspace handle needs its read permission // re-granted before we can enumerate it again. if (app.modules.persist && app.modules.persist.verifyPermission) { const ok = await app.modules.persist.verifyPermission(app.rootHandle, false); if (!ok) { if (window.zddc) window.zddc.toast('Permission to read the source directory was denied.', 'error'); return; } } // Clear current data app.folderTree = []; app.selectedFolders.clear(); app.lastSelectedFolderPath = null; // Reset store app.modules.store.reset(); // Rescan directory (modules already initialized, just rescan) await app.modules.scanner.scanDirectory(app.rootHandle); // For a workspace, persist the refreshed snapshot (additive: the // path-keyed map re-attaches; new files appear unassigned). if (app.modules.workspace && app.modules.workspace.onRescanned) { app.modules.workspace.onRescanned(); } } catch (err) { console.error('Error refreshing directory:', err); alert('Error refreshing directory: ' + err.message); } } /** * Handle Save All button */ async function handleSaveAll() { if (!confirm('Save all modified files?')) return; try { app.dom.saveAllBtn.disabled = true; await app.modules.spreadsheet.saveAllFiles(); } catch (err) { console.error('Error saving files:', err); alert('Error saving files: ' + err.message); } finally { app.dom.saveAllBtn.disabled = false; } } /** * Handle Cancel All button */ function handleCancelAll() { if (!confirm('Cancel all changes?')) return; app.modules.spreadsheet.cancelAllChanges(); } /** * Handle Export Hashes button */ function handleExportHashes() { app.modules.excel.exportHashes(); } /** * Handle SHA256 checkbox toggle */ function handleSha256Toggle() { app.calculateSha256 = app.dom.sha256Checkbox.checked; // Show/hide SHA256 column if (app.calculateSha256) { app.dom.sha256Column.classList.remove('hidden'); } else { app.dom.sha256Column.classList.add('hidden'); } // Re-render table app.modules.spreadsheet.render(); } /** * Handle Hide Compliant checkbox toggle */ function handleHideCompliantToggle() { app.hideCompliant = app.dom.hideCompliantCheckbox.checked; app.modules.store.setHideCompliant(app.hideCompliant); } /** * Handle keyboard shortcuts */ function handleKeyDown(e) { // Ctrl+S - Save All if (e.ctrlKey && e.key === 's') { e.preventDefault(); if (!app.dom.saveAllBtn.disabled) { handleSaveAll(); } } // Escape - Cancel editing if (e.key === 'Escape') { app.modules.spreadsheet.cancelEditing(); } } /** * Show welcome screen (empty-state overlay) */ function showWelcomeScreen() { if (app.dom.welcomeScreen) { app.dom.welcomeScreen.classList.remove('hidden'); } } /** * Hide welcome screen (empty-state overlay) */ function hideWelcomeScreen() { if (app.dom.welcomeScreen) { app.dom.welcomeScreen.classList.add('hidden'); } } /** * Show main UI (no-op: main UI is always rendered) */ function showMainUI() { // Main app is always visible; only the empty-state overlay is toggled } /** * Update stats display */ function updateStats() { const files = app.modules.store.getDisplayFiles(); const totalFiles = files.length; const modifiedFiles = files.filter(f => f.isDirty).length; const errorFiles = files.filter(f => f.error).length; app.dom.totalFiles.textContent = `${totalFiles} file${totalFiles !== 1 ? 's' : ''}`; app.dom.modifiedFiles.textContent = `${modifiedFiles} modified`; if (errorFiles > 0) { app.dom.errorFiles.textContent = `${errorFiles} error${errorFiles !== 1 ? 's' : ''}`; app.dom.errorFiles.classList.remove('hidden'); } else { app.dom.errorFiles.classList.add('hidden'); } // Enable/disable bulk action buttons app.dom.saveAllBtn.disabled = modifiedFiles === 0; app.dom.cancelAllBtn.disabled = modifiedFiles === 0; // Enable/disable export hashes button app.dom.exportHashesBtn.disabled = totalFiles === 0 || !app.calculateSha256; } // Export functions for use by other modules app.modules.app = { updateStats, setMode, enterAppShell }; // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();