/** * 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 = '

No permission to list this directory

' + '

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'), exportListBtn: document.getElementById('exportListBtn'), exportPathsBtn: document.getElementById('exportPathsBtn'), importPathsBtn: document.getElementById('importPathsBtn'), importPathsInput: document.getElementById('importPathsInput'), 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. */ // There is only one surface now (the classify grid + transmittal tree); the // old Rename-in-place spreadsheet was folded into the By-tracking grid's // "Rename…" action. setMode is kept as a no-arg enabler for back-compat with // the workspace/open flows that call it. function setMode() { if (app.dom.targetPane) app.dom.targetPane.hidden = false; if (app.dom.classifyFilters) app.dom.classifyFilters.hidden = false; app.modules.classify.setEnabled(true); if (app.modules.targetTree) { app.modules.targetTree.init(); app.modules.targetTree.render(); } // Re-render the source tree so its per-file markers appear. 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 || []); } // CSV cell quoting (RFC4180): quote when the value holds a comma, quote, or // newline; embedded quotes are doubled. function csvCell(s) { s = (s == null ? '' : String(s)); return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } // Minimal RFC4180-ish CSV parser → array of rows of string cells. Handles // quoted fields with embedded commas/quotes/newlines (titles may contain // commas). CRLF/CR are normalized to LF. function parseCsv(text) { var rows = [], row = [], field = '', inQ = false, i = 0; text = String(text == null ? '' : text).replace(/\r\n?/g, '\n'); for (; i < text.length; i++) { var ch = text[i]; if (inQ) { if (ch === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else { inQ = false; } } else { field += ch; } } else if (ch === '"') { inQ = true; } else if (ch === ',') { row.push(field); field = ''; } else if (ch === '\n') { row.push(field); rows.push(row); row = []; field = ''; } else { field += ch; } } if (field !== '' || row.length) { row.push(field); rows.push(row); } return rows; } // Trigger a client-side download of `text` as `name`. function downloadText(text, name, mime) { var blob = new Blob([text], { type: mime || 'text/plain' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 10000); } // Import a 2-column CSV (old path, new path) — e.g. an AI-classified list. // MERGE semantics: only files named in the CSV are touched; others keep their // current classification. Each new path // "///.ext" drives two axes — the // filename sets the tracking number (rename) and the leading segments route a // transmittal. Either axis can apply independently; per-row problems are // collected and offered as a downloadable errors CSV (the list can be huge). function importPaths(file) { var reader = new FileReader(); reader.onload = function () { var rows = parseCsv(reader.result); if (!rows.length) { window.zddc.toast('Import failed — the CSV is empty.', 'error'); return; } var c = app.modules.classify; // Old path must resolve to a real scanned file (srcKey set). var valid = Object.create(null); eachSourceFile(function (f) { valid[c.srcKeyForFile(f)] = true; }); var imported = 0, errors = []; rows.forEach(function (cells, idx) { var oldPath = (cells[0] || '').trim(); var newPath = (cells[1] || '').trim(); // Tolerate a header row (first row whose first cell isn't a file). if (idx === 0 && !valid[oldPath] && /^(old|path|source|from)\b/i.test(oldPath)) return; if (!oldPath && !newPath) return; // blank line if (!oldPath) { errors.push([oldPath, newPath, 'missing old path']); return; } if (!valid[oldPath]) { errors.push([oldPath, newPath, 'no such file in the current scan']); return; } if (!newPath) { errors.push([oldPath, newPath, 'missing new path']); return; } var segs = newPath.split('/').filter(function (s) { return s !== ''; }); if (!segs.length) { errors.push([oldPath, newPath, 'empty new path']); return; } var filename = segs[segs.length - 1]; var leading = segs.slice(0, -1); var didTracking = false, didTransmittal = false, rowErr = ''; function note(m) { rowErr = rowErr ? rowErr + '; ' + m : m; } // Axis 1 — filename → tracking tree (the rename). var p = window.zddc.parseFilename(filename); if (p && p.valid) { var stem = p.trackingNumber + '_' + p.revision + ' (' + p.status + ')'; c.place([oldPath], c.addTrackingPath(null, c.parseFolderLevels(stem)), 'tracking'); if (p.title != null) c.setTitleOverride(oldPath, p.title); didTracking = true; } else { note('filename is not a valid ZDDC name "' + filename + '"'); } // Axis 2 — // → transmittal tree (the route). if (leading.length >= 3) { var party = leading[0]; var slot = leading[1].toLowerCase(); var folder = leading.slice(2).join('/'); if (slot !== 'issued' && slot !== 'received') { note('direction must be "issued" or "received", got "' + leading[1] + '"'); } else { var pf = window.zddc.parseFolder(folder); if (pf && pf.valid) { var tnParts = pf.trackingNumber.split('-'); var seq = tnParts.pop(), type = tnParts.pop(); var bid = c.findOrAddTransmittalBin(c.findOrAddParty(party), slot, { date: pf.date, type: type || 'TRN', seq: seq || '', status: pf.status, title: pf.title, }); if (bid) { c.place([oldPath], bid, 'transmittal'); didTransmittal = true; } else note('could not create the transmittal folder'); } else { note('transmittal folder is not a valid ZDDC folder name "' + folder + '"'); } } } else if (leading.length >= 1) { note('to route a transmittal the new path needs ///'); } if (didTracking || didTransmittal) imported++; if (rowErr) errors.push([oldPath, newPath, rowErr]); }); if (errors.length) { var elines = ['old path,new path,reason']; errors.forEach(function (e) { elines.push(csvCell(e[0]) + ',' + csvCell(e[1]) + ',' + csvCell(e[2])); }); downloadText(elines.join('\n'), 'classifier-import-errors.csv', 'text/csv'); } window.zddc.toast('Imported ' + imported + ' file' + (imported === 1 ? '' : 's') + (errors.length ? (' — ' + errors.length + ' row' + (errors.length === 1 ? '' : 's') + ' had problems (downloaded classifier-import-errors.csv)') : '') + '.', errors.length ? '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(); // (The old Rename-in-place spreadsheet — Save All / Cancel All / SHA256 / // Export hashes — was removed; its rename is now the By-tracking "Rename…".) if (app.dom.hideCompliantCheckbox) 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); }); // Export the filtered file list (path + file TSV) for the Excel round-trip. if (app.dom.exportListBtn) app.dom.exportListBtn.addEventListener('click', function () { if (app.modules.tree && app.modules.tree.exportFilteredList) app.modules.tree.exportFilteredList(); }); // Collapse tree button app.dom.collapseTreeBtn.addEventListener('click', handleCollapseTree); 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(); }); // 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.exportPathsBtn) app.dom.exportPathsBtn.addEventListener('click', function () { if (app.modules.tree && app.modules.tree.exportPathList) app.modules.tree.exportPathList(); }); if (app.dom.importPathsBtn) app.dom.importPathsBtn.addEventListener('click', function () { app.dom.importPathsInput.click(); }); if (app.dom.resetDatasetBtn) app.dom.resetDatasetBtn.addEventListener('click', resetDataset); if (app.dom.importPathsInput) app.dom.importPathsInput.addEventListener('change', function () { if (this.files && this.files[0]) importPaths(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.preview.init(); // file preview (click a row / original-name link) 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(); setMode(); // the single classify surface // 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) { // (Spreadsheet Ctrl+S / Escape handlers removed with the Rename-in-place // pane. The By-tracking grid commits edits on change.) } /** * 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() { if (!app.dom.totalFiles) return; // spreadsheet pane removed — nothing to update 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(); } })();