Adds an autofilter input above the source tree and above each target tree. Typing substring-matches (ANDing space-separated terms) against the full file path/name (and folder/node names) and reveals every match with the folder hierarchy leading to it — non-matching branches collapse out, matching branches auto-expand. So you can type "master deliverables list" and jump straight to it. - Source tree (tree.js): one-pass visible-set over path+name; composes with the Show Unassigned/Assigned/Excluded toggles; auto-expands to reveal hits. - Target trees (target-tree.js): tracking + transmittal nodes are filter-aware (match node names + each placed file's original/derived name); one shared query mirrored across both tab inputs. Tests: source-tree path reveal + tracking-tree node filter (classify.spec.js -> 36). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
719 lines
28 KiB
JavaScript
719 lines
28 KiB
JavaScript
/**
|
|
* 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 =
|
|
'<h2 style="margin-bottom: 0.75rem;">No permission to list this directory</h2>' +
|
|
'<p>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.</p>';
|
|
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'),
|
|
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
|
|
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
|
|
exportDatasetBtn: document.getElementById('exportDatasetBtn'),
|
|
importDatasetBtn: document.getElementById('importDatasetBtn'),
|
|
importDatasetInput: document.getElementById('importDatasetInput'),
|
|
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')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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, '_') + '.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);
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
assigned: app.dom.showAssignedCheckbox.checked,
|
|
excluded: app.dom.showExcludedCheckbox.checked,
|
|
});
|
|
}
|
|
}
|
|
[app.dom.showUnassignedCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox]
|
|
.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(); });
|
|
|
|
// 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.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();
|
|
}
|
|
})();
|