Header gets a Rename / Classify & Copy switch. In Classify & Copy mode the spreadsheet pane is replaced by a tabbed target pane (By tracking number / By transmittal), while the source tree stays on the left. - target-tree.js: renders both trees from classify state; tracking-folder create/rename/delete (leaf folders styled as the revision); party CRUD + per-slot inline transmittal-bin form (date + TRN/SUB + seq + optional status/title); shows the derived filename + a validation badge for each placed file; live header stats (done / in progress / unassigned / excluded). - app.js setMode(): swaps panes, toggles classify mode, re-renders both trees. - 3 UI smoke tests added to classify.spec.js (12 total green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
532 lines
18 KiB
JavaScript
532 lines
18 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();
|
|
|
|
// 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'),
|
|
|
|
// 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')
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// 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'); });
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
|
|
// Resize handle
|
|
setupResizeHandle();
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
async function openDirectory(dirHandle) {
|
|
app.rootHandle = dirHandle;
|
|
|
|
// Hide welcome screen and show main UI
|
|
hideWelcomeScreen();
|
|
showMainUI();
|
|
|
|
// Initialize modules BEFORE scanning (so they're ready for store updates)
|
|
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();
|
|
|
|
// Now scan directory (this will trigger store updates and renders)
|
|
await app.modules.scanner.scanDirectory(dirHandle);
|
|
|
|
// Show refresh button now that a directory is loaded
|
|
if (app.dom.refreshHeaderBtn) { app.dom.refreshHeaderBtn.classList.remove('hidden'); }
|
|
}
|
|
|
|
/**
|
|
* Handle Refresh button - rescan current directory
|
|
*/
|
|
async function handleRefresh() {
|
|
if (!app.rootHandle) {
|
|
alert('No directory selected');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 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);
|
|
|
|
} 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
|
|
};
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|