ZDDC/classifier/js/spreadsheet.js
ZDDC ea385b5366 Initial commit
ZDDC — Zero Day Document Control. A file-naming convention plus five
single-file HTML tools (archive, transmittal, classifier, mdedit,
landing) and an optional Go HTTP server (zddc-server) with ACL and a
virtual archive index. Self-contained, offline-capable, dependency-free.

See README.md for an overview, AGENTS.md and ARCHITECTURE.md for the
build/release/architecture detail, bootstrap/README.md for the
two-level deployment install pattern, and zddc/README.md for the
HTTP server.
2026-04-27 11:05:47 -05:00

942 lines
34 KiB
JavaScript

/**
* Spreadsheet Module
* Handles table rendering, cell editing, and file operations
*/
(function() {
'use strict';
let editingCell = null;
let editingInput = null;
/**
* Render spreadsheet from store
*/
function render() {
const tbody = window.app.dom.spreadsheetBody;
tbody.innerHTML = '';
// Get files from store (already filtered and sorted)
const files = window.app.modules.store.getDisplayFiles();
// Render rows
if (files.length === 0) {
const message = window.app.modules.store.getDisplayFiles().length === 0
? '<h3>No files to display</h3><p>Select one or more folders from the tree to view files</p>'
: '<h3>No files match filters</h3><p>Adjust or clear filters to see files</p>';
tbody.innerHTML = `
<tr>
<td colspan="10" class="spreadsheet-empty">
${message}
</td>
</tr>
`;
return;
}
files.forEach((file, index) => {
const row = createRow(file, index);
tbody.appendChild(row);
});
// Update UI
window.app.modules.app.updateStats();
updateSortIndicators();
// Calculate SHA256 if enabled
if (window.app.calculateSha256) {
calculateSha256ForAll();
}
}
/**
* Update sort indicators
*/
function updateSortIndicators() {
const sortColumns = window.app.modules.store.getSortColumns();
const headers = window.app.dom.spreadsheet.querySelectorAll('thead th');
headers.forEach(th => {
const indicator = th.querySelector('.sort-indicator');
if (!indicator) return;
const columnName = th.className.replace('col-', '');
const sortIndex = sortColumns.findIndex(s => s.column === columnName);
if (sortIndex >= 0) {
const sort = sortColumns[sortIndex];
const arrow = sort.direction === 'asc' ? '▲' : '▼';
const priority = sortColumns.length > 1 ? (sortIndex + 1) : '';
indicator.textContent = ` ${arrow}${priority}`;
indicator.style.display = 'inline';
} else {
indicator.textContent = '';
indicator.style.display = 'none';
}
});
}
/**
* Create a table row for a file
*/
function createRow(file, index) {
const row = document.createElement('tr');
row.dataset.index = index;
row.dataset.folderPath = file.folderPath; // Store folder path for highlighting
// Add state classes
if (file.isDirty) row.classList.add('modified');
if (file.error) row.classList.add('error');
// Highlight folder on hover
row.addEventListener('mouseenter', () => {
highlightFolder(file.folderPath);
});
row.addEventListener('mouseleave', () => {
clearFolderHighlight();
});
// Row number
row.appendChild(createCell('row-num', index + 1, false));
// Original filename — plain text (selectable/copyable, no link)
const originalCell = createCell('original', file.originalFilename, false);
row.appendChild(originalCell);
// Extension — hyperlink to open the file
const extCell = createCell('extension', '', false);
const extLink = document.createElement('a');
extLink.className = 'cell-link';
extLink.textContent = file.extension;
extLink.title = 'Click to open file';
extLink.style.cursor = 'pointer';
extLink.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
openFile(file);
});
extCell.appendChild(extLink);
row.appendChild(extCell);
// Parse original filename to get ZDDC components (always)
// Pass full filename (name + extension) so the regex can match the .ext suffix
const parsed = zddc.parseFilename(zddc.joinExtension(file.originalFilename, file.extension)) || {};
// Fill any empty fields from parsed filename (per-field, not all-or-nothing)
// Must happen before computeNewFilename so all fields are available
if (!file.trackingNumber) file.trackingNumber = parsed.trackingNumber || '';
if (!file.revision) file.revision = parsed.revision || '';
if (!file.status) file.status = parsed.status || '';
if (!file.title) file.title = parsed.title || '';
// New filename: show only if it would actually change the file
const computedFilename = file.manualFilename || computeNewFilename(file, index);
const originalFullName = zddc.joinExtension(file.originalFilename, file.extension);
const wouldChange = computedFilename !== originalFullName;
const newFilenameDisplay = wouldChange ? computedFilename : '';
const newFilenameCell = createEditableCell('newFilename', newFilenameDisplay, index);
// Use computedFilename (not newFilenameDisplay) for validation
const newFilename = computedFilename;
if (!file.manualFilename) {
newFilenameCell.classList.add('computed');
}
// Validate and show errors
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
newFilenameCell.classList.add('validation-error');
newFilenameCell.title = validation.errors.join('; ');
} else if (validation.warnings.length > 0) {
newFilenameCell.classList.add('validation-warning');
newFilenameCell.title = validation.warnings.join('; ');
}
// Only show action buttons if row is dirty
if (file.isDirty) {
const actions = document.createElement('span');
actions.className = 'inline-actions';
const saveBtn = document.createElement('button');
saveBtn.className = 'btn-inline btn-save';
saveBtn.textContent = '✓';
saveBtn.title = 'Save';
saveBtn.addEventListener('click', (e) => {
e.stopPropagation();
saveFile(index);
});
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-inline btn-cancel';
cancelBtn.textContent = '✗';
cancelBtn.title = 'Clear all fields';
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
cancelFile(index);
});
actions.appendChild(saveBtn);
actions.appendChild(cancelBtn);
newFilenameCell.appendChild(actions);
}
row.appendChild(newFilenameCell);
// For each field: use file value (already populated above) for display
const displayTracking = file.trackingNumber || '';
const displayRevision = file.revision || '';
const displayStatus = file.status || '';
const displayTitle = file.title || '';
const trackingCell = createEditableCell('trackingNumber', displayTracking, index);
const revisionCell = createEditableCell('revision', displayRevision, index);
const statusCell = createEditableCell('status', displayStatus, index);
const titleCell = createEditableCell('title', displayTitle, index);
// Gray = field value matches what the original filename parses to (no change)
// Blue = field value differs from the parsed original (would produce a different filename)
if (displayTracking === (parsed.trackingNumber || '')) trackingCell.classList.add('auto-populated');
else trackingCell.classList.add('field-changed');
if (displayRevision === (parsed.revision || '')) revisionCell.classList.add('auto-populated');
else revisionCell.classList.add('field-changed');
if (displayStatus === (parsed.status || '')) statusCell.classList.add('auto-populated');
else statusCell.classList.add('field-changed');
if (displayTitle === (parsed.title || '')) titleCell.classList.add('auto-populated');
else titleCell.classList.add('field-changed');
row.appendChild(trackingCell);
row.appendChild(revisionCell);
row.appendChild(statusCell);
row.appendChild(titleCell);
// SHA256 (if enabled)
if (window.app.calculateSha256) {
const sha256Cell = createCell('sha256', file.sha256 || 'calculating...', false);
if (!file.sha256) {
sha256Cell.classList.add('sha256-calculating');
}
row.appendChild(sha256Cell);
}
return row;
}
/**
* Create a table cell
*/
function createCell(className, content, editable = false) {
const td = document.createElement('td');
td.className = `col-${className}`;
td.textContent = content;
return td;
}
/**
* Create an editable cell
*/
function createEditableCell(columnName, value, rowIndex) {
const td = document.createElement('td');
td.className = `col-${columnName} cell-editable`;
td.textContent = value;
// Double-click to edit
td.addEventListener('dblclick', (e) => {
e.stopPropagation();
startEditing(td, columnName, rowIndex);
});
return td;
}
/**
* Start editing a cell
*/
function startEditing(cell, columnName, rowIndex) {
// Cancel any existing edit
if (editingCell) {
cancelEditing();
}
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
const currentValue = file[columnName] || '';
// Clear any cell selection
if (window.app.modules.selection) {
window.app.modules.selection.clearSelection();
}
// Store references
editingCell = { cell, columnName, rowIndex, originalValue: currentValue };
// Save original content and make cell contenteditable
cell.dataset.originalContent = cell.innerHTML;
cell.contentEditable = 'true';
cell.classList.add('editing');
editingInput = cell;
// Set content (text only for editing)
cell.textContent = currentValue;
// Focus and select all
cell.focus();
// Select all text — guard against cell being detached from document
// (can happen if a re-render fires between dblclick and this point)
if (document.contains(cell)) {
const range = document.createRange();
range.selectNodeContents(cell);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
// Event listeners
cell.addEventListener('blur', handleBlur, { once: true });
cell.addEventListener('keydown', handleKeyDown);
}
/**
* Handle blur event
*/
function handleBlur() {
// Small delay to allow click events to fire first
setTimeout(() => finishEditing(), 100);
}
/**
* Handle keydown in contenteditable
*/
function handleKeyDown(e) {
if (e.key === 'Enter') {
// Enter exits edit mode
e.preventDefault();
finishEditing();
} else if (e.key === 'Escape') {
// Escape undoes and exits edit mode
e.preventDefault();
cancelEditing();
} else if (e.key === 'Tab') {
// Tab/Shift+Tab moves to next/prev cell
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
const shiftKey = e.shiftKey;
finishEditingQuiet(); // Don't trigger store update
if (rowIndex !== undefined) {
if (shiftKey) {
moveToPreviousCell(rowIndex, columnName);
} else {
moveToNextCell(rowIndex, columnName);
}
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
finishEditingQuiet();
if (rowIndex !== undefined) {
moveUpRow(rowIndex, columnName);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const { rowIndex, columnName } = editingCell || {};
finishEditingQuiet();
if (rowIndex !== undefined) {
moveDownRow(rowIndex, columnName);
}
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
// Allow normal cursor movement within cell
e.stopPropagation();
}
}
/**
* Finish editing and save value
*/
function finishEditing() {
if (!editingCell || !editingInput) return;
const { cell, columnName, rowIndex } = editingCell;
const newValue = editingInput.textContent.trim();
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
const oldValue = file[columnName] || '';
// Remove contenteditable
editingInput.contentEditable = 'false';
editingInput.classList.remove('editing');
editingInput.removeEventListener('keydown', handleKeyDown);
// Update file data if changed
if (newValue !== oldValue) {
// Special handling for newFilename column
if (columnName === 'newFilename') {
if (newValue) {
window.app.modules.store.updateFileField(rowIndex, 'manualFilename', newValue);
} else {
window.app.modules.store.updateFile(rowIndex, { manualFilename: null });
}
} else {
window.app.modules.store.updateFileField(rowIndex, columnName, newValue);
}
const updatedFile = window.app.modules.store.getDisplayFiles()[rowIndex];
validateFile(updatedFile);
}
editingCell = null;
editingInput = null;
}
/**
* Finish editing without triggering store update (for Tab/Arrow navigation)
*/
function finishEditingQuiet() {
if (!editingCell || !editingInput) return;
const { columnName, rowIndex } = editingCell;
const newValue = editingInput.textContent.trim();
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
// Remove contenteditable
editingInput.contentEditable = 'false';
editingInput.classList.remove('editing');
editingInput.removeEventListener('keydown', handleKeyDown);
// Update file object directly (no store notification)
if (file) {
if (columnName === 'newFilename') {
file.manualFilename = newValue || null;
} else {
file[columnName] = newValue;
}
file.isDirty = true;
}
editingCell = null;
editingInput = null;
}
/**
* Cancel editing without saving
*/
function cancelEditing() {
if (!editingCell) return;
const { rowIndex } = editingCell;
const files = window.app.modules.store.getDisplayFiles();
const file = files[rowIndex];
if (!file) return;
// Clear editing state
editingCell = null;
editingInput = null;
// Re-render the row
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
if (row) {
const newRow = createRow(file, rowIndex);
row.replaceWith(newRow);
}
}
/**
* Move to next editable cell
*/
function moveToNextCell(rowIndex, currentColumn) {
const columns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
const currentIndex = columns.indexOf(currentColumn);
if (currentIndex < columns.length - 1) {
// Next column in same row
const nextColumn = columns[currentIndex + 1];
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
const nextCell = row.querySelector(`.col-${nextColumn}`);
if (nextCell) {
startEditing(nextCell, nextColumn, rowIndex);
}
} else if (rowIndex < window.app.modules.store.getDisplayFiles().length - 1) {
// First column of next row
const nextColumn = columns[0];
const nextRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex + 1}"]`);
const nextCell = nextRow.querySelector(`.col-${nextColumn}`);
if (nextCell) {
startEditing(nextCell, nextColumn, rowIndex + 1);
}
}
}
/**
* Move to previous editable cell
*/
function moveToPreviousCell(rowIndex, currentColumn) {
const columns = ['newFilename', 'trackingNumber', 'revision', 'status', 'title'];
const currentIndex = columns.indexOf(currentColumn);
if (currentIndex > 0) {
// Previous column in same row
const prevColumn = columns[currentIndex - 1];
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex}"]`);
const prevCell = row.querySelector(`.col-${prevColumn}`);
if (prevCell) {
startEditing(prevCell, prevColumn, rowIndex);
}
} else if (rowIndex > 0) {
// Last column of previous row
const prevColumn = columns[columns.length - 1];
const prevRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex - 1}"]`);
const prevCell = prevRow.querySelector(`.col-${prevColumn}`);
if (prevCell) {
startEditing(prevCell, prevColumn, rowIndex - 1);
}
}
}
/**
* Move up one row, same column
*/
function moveUpRow(rowIndex, currentColumn) {
if (rowIndex > 0) {
const prevRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex - 1}"]`);
const cell = prevRow.querySelector(`.col-${currentColumn}`);
if (cell) {
startEditing(cell, currentColumn, rowIndex - 1);
}
}
}
/**
* Move down one row, same column
*/
function moveDownRow(rowIndex, currentColumn) {
if (rowIndex < window.app.modules.store.getDisplayFiles().length - 1) {
const nextRow = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${rowIndex + 1}"]`);
const cell = nextRow.querySelector(`.col-${currentColumn}`);
if (cell) {
startEditing(cell, currentColumn, rowIndex + 1);
}
}
}
function computeNewFilename(file) {
return window.app.modules.utils.computeNewFilename(file);
}
/**
* Validate a file
*/
function validateFile(file) {
const newFilename = computeNewFilename(file, 0);
const validation = window.app.modules.validator.validateFilename(newFilename);
file.validation = validation;
file.error = !validation.isValid;
}
/**
* Open file in new tab
*/
async function openFile(file) {
try {
let blob;
if (file.isVirtual) {
// Virtual file from ZIP - get from cache
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
const arrayBuffer = await zipEntry.async('arraybuffer');
const mimeType = getMimeType(file.extension);
blob = new Blob([arrayBuffer], { type: mimeType });
} else {
blob = await file.handle.getFile();
}
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Clean up URL after a delay
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (err) {
console.error('Error opening file:', err);
alert('Cannot open file: ' + err.message);
}
}
function getMimeType(extension) {
return window.app.modules.utils.getMimeType(extension);
}
/**
* Save a single file
*/
async function saveFile(index, skipValidation = false) {
const files = window.app.modules.store.getDisplayFiles();
const file = files[index];
if (!file.isDirty) {
return;
}
// Virtual files (from ZIPs) cannot be renamed - must extract first
if (file.isVirtual) {
alert('Cannot rename files inside ZIP archives.\nExtract the ZIP first to rename files.');
return;
}
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${index}"]`);
if (!row) {
console.error(`Row not found for index ${index}`);
return;
}
row.classList.add('saving');
try {
const newFilename = computeNewFilename(file, index);
const currentFilename = zddc.joinExtension(file.originalFilename, file.extension);
// Check if already has correct name
if (currentFilename === newFilename) {
row.classList.remove('saving');
// Just clear dirty flag and fields
file.isDirty = false;
file.error = false;
delete file.manualFilename;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
const newRow = createRow(file, index);
row.replaceWith(newRow);
window.app.modules.app.updateStats();
return;
}
// Validate filename
if (!skipValidation) {
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
const errors = validation.errors.join('\n');
const confirmed = confirm(
`⚠️ Warning: Filename is not ZDDC compliant!\n\n` +
`Errors:\n${errors}\n\n` +
`Current filename: ${newFilename}\n\n` +
`Do you want to save it anyway?`
);
if (!confirmed) {
row.classList.remove('saving');
return;
}
}
}
// Request write permission for the folder
const folderPermission = await file.folderHandle.queryPermission({ mode: 'readwrite' });
if (folderPermission !== 'granted') {
const granted = await file.folderHandle.requestPermission({ mode: 'readwrite' });
if (granted !== 'granted') {
throw new Error('Write permission denied');
}
}
// Rename by copying to new name and deleting old (more reliable than move)
const oldFilename = zddc.joinExtension(file.originalFilename, file.extension);
try {
// Get fresh handle for old file
const oldHandle = await file.folderHandle.getFileHandle(oldFilename);
// Read the file content
const fileData = await oldHandle.getFile();
// Create new file with new name
const newHandle = await file.folderHandle.getFileHandle(newFilename, { create: true });
const writable = await newHandle.createWritable();
await writable.write(fileData);
await writable.close();
// Delete old file
await file.folderHandle.removeEntry(oldFilename);
// Update file handle
file.handle = newHandle;
} catch (err) {
console.error(`Failed to rename file:`, err);
throw err;
}
// Update file data directly (don't trigger store notification during batch save)
file.originalFilename = zddc.splitExtension(newFilename).name;
file.isDirty = false;
file.error = false;
file.manualFilename = null;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
file.autoPopulated = false;
// Update row UI
row.classList.remove('saving');
const newRow = createRow(file, index);
row.replaceWith(newRow);
} catch (err) {
console.error('Error saving file:', err);
file.error = true;
file.errorMessage = err.message;
row.classList.remove('saving');
row.classList.add('error');
// Re-throw so caller can handle
throw err;
}
}
/**
* Cancel/Clear all fields for a single file
*/
function cancelFile(index) {
// Clear all fields through store
window.app.modules.store.updateFile(index, {
trackingNumber: '',
revision: '',
status: '',
title: '',
manualFilename: null,
isDirty: false,
error: false,
validation: null,
autoPopulated: false
});
}
/**
* Save all modified files (only ZDDC-compliant ones)
*/
async function saveAllFiles() {
const files = window.app.modules.store.getDisplayFiles();
const modifiedFiles = files
.map((file, index) => ({ file, index }))
.filter(({ file }) => file.isDirty);
if (modifiedFiles.length === 0) {
alert('No modified files to save.');
return;
}
let successCount = 0;
let skippedCount = 0;
let errorCount = 0;
const errors = [];
const skipped = [];
for (let i = 0; i < modifiedFiles.length; i++) {
const { file, index } = modifiedFiles[i];
try {
// Add small delay between operations to prevent race conditions
if (i > 0) {
await new Promise(resolve => setTimeout(resolve, 200));
}
// Validate before saving
const newFilename = computeNewFilename(file, index);
const currentFilename = zddc.joinExtension(file.originalFilename, file.extension);
const validation = window.app.modules.validator.validateFilename(newFilename);
if (!validation.isValid) {
// Skip non-compliant files in Save All
skippedCount++;
skipped.push(`${zddc.joinExtension(file.originalFilename, file.extension)}: ${validation.errors[0]}`);
continue;
}
// Check if already has correct name
if (currentFilename === newFilename) {
// Just clear dirty flag
file.isDirty = false;
file.error = false;
delete file.manualFilename;
file.trackingNumber = '';
file.revision = '';
file.status = '';
file.title = '';
successCount++;
continue;
}
// Save with validation already done - ensure properly awaited
try {
await saveFile(index, true);
successCount++;
} catch (saveErr) {
console.error(`Error saving file ${index}:`, saveErr);
errorCount++;
errors.push(`${zddc.joinExtension(file.originalFilename, file.extension)}: ${saveErr.message}`);
// Add delay after errors to let filesystem stabilize
await new Promise(resolve => setTimeout(resolve, 300));
}
} catch (err) {
console.error(`Error processing file ${index}:`, err);
errorCount++;
errors.push(`${file.originalFilename}${file.extension}: ${err.message}`);
}
}
// Trigger store notification to update UI after all saves
window.app.modules.store.notify('files');
let message = `Saved ${successCount} compliant file(s).`;
if (skippedCount > 0) {
message += `\n\n⚠️ Skipped ${skippedCount} non-compliant file(s):`;
message += `\n${skipped.slice(0, 3).join('\n')}`;
if (skipped.length > 3) {
message += `\n... and ${skipped.length - 3} more`;
}
message += `\n\nUse individual save buttons (✓) to save non-compliant files.`;
}
if (errorCount > 0) {
message += `\n\n${errorCount} error(s):`;
message += `\n${errors.slice(0, 3).join('\n')}`;
if (errors.length > 3) {
message += `\n... and ${errors.length - 3} more`;
}
}
alert(message);
}
/**
* Cancel all changes
*/
function cancelAllChanges() {
const files = window.app.modules.store.getDisplayFiles();
files.forEach((file, index) => {
if (file.isDirty) {
cancelFile(index);
}
});
}
/**
* Calculate SHA256 for all files
*/
async function calculateSha256ForAll() {
const files = window.app.modules.store.getDisplayFiles();
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.sha256) {
calculateSha256(file, i);
}
}
}
/**
* Calculate SHA256 for a single file
*/
async function calculateSha256(file, index) {
try {
let hashHex;
if (file.isVirtual) {
// Virtual file from ZIP
const cached = window.app.modules.scanner.getZipCache(file.zipPath);
if (!cached) throw new Error('ZIP not found in cache');
const zipEntry = cached.zip.file(file.zipEntryPath);
if (!zipEntry) throw new Error('File not found in ZIP');
const buffer = await zipEntry.async('arraybuffer');
hashHex = await zddc.crypto.sha256Hex(buffer);
} else {
const fileObj = await file.handle.getFile();
hashHex = await zddc.crypto.sha256File(fileObj);
}
file.sha256 = hashHex;
// Update cell
const row = window.app.dom.spreadsheetBody.querySelector(`tr[data-index="${index}"]`);
if (row) {
const sha256Cell = row.querySelector('.col-sha256');
if (sha256Cell) {
sha256Cell.textContent = hashHex.substring(0, 16) + '...';
sha256Cell.title = hashHex;
sha256Cell.classList.remove('sha256-calculating');
}
}
} catch (err) {
console.error('Error calculating SHA256:', err);
}
}
/**
* Highlight folder in tree when hovering over file
*/
function highlightFolder(folderPath) {
if (!folderPath) return;
// Find folder in tree
const folderTree = document.getElementById('folderTree');
if (!folderTree) return;
// Find the folder item by data-path attribute
const folderItem = folderTree.querySelector(`[data-path="${folderPath}"]`);
if (!folderItem) return;
// Add highlight class
folderItem.classList.add('folder-hover-highlight');
// Scroll into view if autoscroll is enabled
const autoScrollCheckbox = document.getElementById('autoScrollCheckbox');
if (autoScrollCheckbox && autoScrollCheckbox.checked) {
folderItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
/**
* Clear folder highlight
*/
function clearFolderHighlight() {
const folderTree = document.getElementById('folderTree');
if (!folderTree) return;
// Remove all highlights
const highlighted = folderTree.querySelectorAll('.folder-hover-highlight');
highlighted.forEach(el => el.classList.remove('folder-hover-highlight'));
}
/**
* Initialize spreadsheet - subscribe to store
*/
function init() {
// Subscribe to store changes (only call this after DOM is ready)
window.app.modules.store.on('files', render);
}
// Export module
window.app.modules.spreadsheet = {
init,
render,
computeNewFilename,
saveAllFiles,
cancelAllChanges,
cancelEditing
};
})();