Standardize the two classify tabs: By-transmittal drops the party→slot→bin tree (and its multi-field "+ Transmittal" form) for a flat per-file grid that mirrors By-tracking. Each file gets ONE editable text input — its full transmittal folder path "<party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>". Committing it find-or-creates the party/slot/bin; structure stays derived, never stored. Drag-and-drop (from the source tree onto the grid): - plain drop on a routed row → the dropped files JOIN that row's folder; - ⌘/Ctrl-drop on a routed row → a prompt prefilled with that folder's path lets you edit it into a NEW transmittal the files go to (the original is untouched; an unedited path dedups via find-or-create); - drop on empty space / an unrouted row → files are added as blank rows to fill. Model (classify.js): adds a `transmittalWorkset` (parallel to trackingWorkset) plus addToTransmittalGrid / removeFromTransmittalGrid / transmittalGridKeys and setTransmittalPath(keys, path) — the single parser for "<party>/<slot>/<folder>" that also prunes any bin a re-route empties. app.js importPaths now reuses setTransmittalPath for its route axis (one parser, less duplication). Removes the now-dead tree rendering/CRUD (party/bin nodes, binForm, the bin filename editor, the bin drop zone). Tests updated to the grid model: tab render shows the folder-path input; drop join/branch/empty; edit re-routes and prunes the emptied folder; ✕ removes. 71/71 classify specs pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
736 lines
30 KiB
JavaScript
736 lines
30 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'),
|
|
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
|
|
// "<party>/<direction>/<transmittal>/<file>.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 — <party>/<direction>/<transmittal> → 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();
|
|
}
|
|
})();
|