/**
* 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). Same parser the By-transmittal grid uses.
if (leading.length >= 1) {
var terr = c.setTransmittalPath([oldPath], leading.join('/'));
if (terr) note(terr); else didTransmittal = true;
}
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();
}
})();