(function (app) { 'use strict'; const DEBUG = false; // Set to true to enable verbose logging const dom = app.dom; const json = app.json; const util = app.util; const filesModule = app.modules.files = {}; var hasFileSystemAccess = typeof window.showDirectoryPicker === 'function'; function requireFileSystemAccess() { if (hasFileSystemAccess) { return true; } alert('This feature requires the File System Access API.\n\nPlease use a Chromium-based browser on desktop.'); return false; } function hasFiles() { return Array.isArray(app.data.files) && app.data.files.length > 0; } // Three primary-button states: 'scan', 'verify', 'publish' var _primaryIntent = null; // null = auto-detect, or explicit 'scan'|'verify'|'publish' var _primaryHandler = null; function setPrimary(intent) { _primaryIntent = intent; updateToolbars(); } function setScanningState(active) { var wrapper = document.querySelector('.table-wrapper'); if (!wrapper) { return; } if (active) { wrapper.classList.add('scanning'); } else { wrapper.classList.remove('scanning'); } } function nowMs() { if (typeof performance !== 'undefined' && typeof performance.now === 'function') { return performance.now(); } return Date.now(); } function formatDuration(ms) { const totalSeconds = Math.max(0, Math.round(ms / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; if (minutes > 0) { return minutes + 'm ' + seconds.toString().padStart(2, '0') + 's'; } return seconds + 's'; } // ── File handle permission and refresh helpers ───────────────────── async function ensureDirHandlePermission(dirHandle) { if (!dirHandle || typeof dirHandle.requestPermission !== 'function') { return; } const permissionDescriptor = { mode: 'readwrite' }; const current = await dirHandle.queryPermission(permissionDescriptor); if (current === 'granted') { return; } // Request permission (requires user gesture in most browsers) const result = await dirHandle.requestPermission(permissionDescriptor); if (result !== 'granted') { throw new Error('Read/write permission is required for the selected directory'); } } async function getFreshFile(fileHandle, fallbackData) { if (!fileHandle || typeof fileHandle.getFile !== 'function') { throw new Error('No valid file handle'); } // Check permission state before attempting to get file try { const current = await fileHandle.queryPermission({ mode: 'read' }); if (current !== 'granted') { // Try to request permission (will fail without user gesture) try { await fileHandle.requestPermission({ mode: 'read' }); } catch (permErr) { // Permission request failed (likely no user gesture), skip with warning console.warn('[transmittal] Permission request failed for file:', fileHandle.name || 'unknown', permErr); } // Check permission again after potential request const newPerm = await fileHandle.queryPermission({ mode: 'read' }); if (newPerm !== 'granted') { if (fallbackData) { // Use fallback data instead of throwing console.log('[transmittal] Permission not granted, using fallback for ' + (fallbackData.path || fallbackData.name || 'unknown file')); return fallbackData; } throw new Error('Permission denied for file access'); } } } catch (err) { // queryPermission not supported or error console.warn('[transmittal] Permission check error:', err); } try { return await fileHandle.getFile(); } catch (err) { if (err && err.name === 'NotReadableError') { if (fallbackData) { console.log('[transmittal] NotReadableError, using fallback for ' + (fallbackData.path || fallbackData.name || 'unknown file')); return fallbackData; } throw err; } throw err; } } function updateDirectoryIndicator(name) { var indicator = dom.qs('#selected-directory'); if (indicator) { indicator.textContent = name ? name : ''; } updateToolbars(); } function sortFilesInPlace(list) { if (!Array.isArray(list)) { return; } list.sort(util.compareFilesByTrackingRevision); } function buildFileHandleMap(files) { var map = {}; (files || []).forEach(function (f) { if (f.fileHandle) { var key = (f.path || f.name || '').toLowerCase(); if (key) { map[key] = f.fileHandle; } } }); return map; } function restoreFileHandles(files, handleMap) { (files || []).forEach(function (f) { var key = (f.path || f.name || '').toLowerCase(); if (key && handleMap[key]) { f.fileHandle = handleMap[key]; } }); } function selfEntryDate() { var raw = (dom.qs('#date') || {}).value || ''; var trimmed = raw.trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { return trimmed; } var d = new Date(trimmed); if (!isNaN(d.getTime())) { return d.toISOString().slice(0, 10); } return trimmed; } function buildSelfEntry() { var tracking = (dom.qs('#tracking-number') || {}).value || ''; var subject = (dom.qs('#subject') || {}).value || ''; var title = (dom.qs('#title') || {}).value || ''; var date = selfEntryDate(); var purpose = (dom.qs('#purpose') || {}).value || ''; var d = app.modules.data; var filename = (d && d.buildFileName) ? d.buildFileName({ trackingNumber: tracking, title: title, date: date, purpose: purpose }, { extension: 'html' }) : ''; return { _isSelf: true, path: filename ? ('./' + filename) : '', name: filename || '', trackingNumber: tracking, title: subject || title, revision: date, status: purpose, extension: 'html', size: 0, fileSize: 0, sha256: app.constants.SELF_HASH }; } filesModule.buildSelfEntry = buildSelfEntry; function canonicalFilePayload(entry) { const relativePath = entry.path || entry.name || ''; const filename = relativePath.split('/').pop() || entry.name || ''; const pathOnly = relativePath.substring(0, relativePath.lastIndexOf('/')) || ''; return { trackingNumber: entry.trackingNumber || '', revision: entry.revision || '', status: entry.status || '', title: entry.title || '', path: pathOnly, filename: filename, extension: entry.extension || '', sha256: entry.sha256 || '', fileSize: Number(entry.fileSize || entry.size || 0) }; } function updateFilesInJson(files) { const data = json.parse(); const envelope = { ...(data.envelope || {}) }; const payload = { ...(data.payload || {}) }; const presentation = { ...(data.presentation || {}) }; const sorted = (Array.isArray(files) ? files : []).slice().sort(util.compareFilesByTrackingRevision); // Prepend self-entry, then regular files var selfEntry = buildSelfEntry(); payload.files = [canonicalFilePayload(selfEntry)].concat(sorted.map(canonicalFilePayload)); // Clear digest when files change (invalidates signatures) envelope.digest = ''; envelope.digestedAt = ''; envelope.signatures = []; json.setData({ envelope: envelope, payload: payload, presentation: presentation }); } var ARCHIVE_DIR_NAME = '.archive'; var MAX_DIRECTORY_DEPTH = 48; function isHiddenName(name) { return !!name && name.startsWith('.'); } function shouldSkipDirectorySegment(name) { if (!name) { return false; } if (name === ARCHIVE_DIR_NAME) { return true; } return isHiddenName(name); } async function collectFilesRecursive(handle, relPath, out, depth) { const currentDepth = depth || 0; if (DEBUG) console.log('[transmittal] collectFilesRecursive:', relPath, 'depth:', currentDepth, 'kind:', handle.kind); if (currentDepth > MAX_DIRECTORY_DEPTH) { throw new Error('Directory nesting exceeds supported depth'); } if (handle.kind === 'file') { if (isHiddenName(handle.name)) { if (DEBUG) console.log('[transmittal] Skipping hidden file:', relPath); return; } if (DEBUG) console.log('[transmittal] Adding file to collection:', relPath); out.push({ handle, path: relPath, name: handle.name }); return; } if (handle.kind !== 'directory') { if (DEBUG) console.log('[transmittal] Unknown handle kind:', handle.kind, 'for', relPath); return; } if (shouldSkipDirectorySegment(handle.name) && relPath) { if (DEBUG) console.log('[transmittal] Skipping directory segment:', relPath); return; } try { if (DEBUG) console.log('[transmittal] Iterating directory:', relPath || 'root'); let childCount = 0; for await (const child of handle.values()) { childCount++; if (DEBUG) console.log('[transmittal] Found child #' + childCount + ':', child.name, 'kind:', child.kind); if (shouldSkipDirectorySegment(child.name)) { continue; } const childPath = relPath ? (relPath + '/' + child.name) : child.name; if (DEBUG) console.log('[transmittal] Recursing into:', childPath); await collectFilesRecursive(child, childPath, out, currentDepth + 1); } if (DEBUG) console.log('[transmittal] Finished iterating directory:', relPath || 'root', 'found', childCount, 'children'); } catch (err) { if (err && err.name === 'NotFoundError') { console.error('[transmittal] NotFoundError during directory traversal:', relPath || handle.name || '', { error: err, message: err.message, stack: err.stack, handleName: handle.name, handleKind: handle.kind }); return; } console.error('[transmittal] Unexpected error during directory traversal:', relPath, err); throw err; } } async function collectDirectoryEntries(rootHandle) { const entries = []; if (!rootHandle || rootHandle.kind !== 'directory') { throw new Error('Invalid directory handle provided'); } try { // Start directory scan if (DEBUG) console.log('[transmittal] Starting directory scan for:', rootHandle.name); for await (const child of rootHandle.values()) { if (shouldSkipDirectorySegment(child.name)) { continue; } const childPath = child.name; try { await collectFilesRecursive(child, childPath, entries, 1); } catch (err) { if (err && err.name === 'NotFoundError') { console.warn('[transmittal] directory entry missing', childPath, err); continue; } throw err; } } } catch (err) { if (err && err.name === 'NotFoundError') { console.error('[transmittal] NotFoundError during directory scan', { error: err, message: err.message, stack: err.stack, handle: rootHandle, handleName: rootHandle.name }); // Return empty entries instead of throwing return entries; } console.error('[transmittal] Unexpected error during directory scan', err); throw err; } return entries; } // Promote shared helpers to filesModule for use by sub-modules filesModule.nowMs = nowMs; filesModule.formatDuration = formatDuration; filesModule.updateDirectoryIndicator = updateDirectoryIndicator; filesModule.sortFilesInPlace = sortFilesInPlace; filesModule.updateFilesInJson = updateFilesInJson; filesModule.collectDirectoryEntries = collectDirectoryEntries; async function ensureDirHandle() { if (typeof window.showDirectoryPicker !== 'function') { throw new Error('File System Access API showDirectoryPicker is required'); } const handle = await window.showDirectoryPicker(); // Log the handle details for debugging if (DEBUG) { console.log('[transmittal] Directory selected:', { name: handle.name, kind: handle.kind, hasQueryPermission: typeof handle.queryPermission === 'function', hasRequestPermission: typeof handle.requestPermission === 'function' }); } async function ensureRwPermission(dirHandle) { if (!dirHandle) { throw new Error('Directory handle is undefined'); } if (typeof dirHandle.requestPermission !== 'function') { return; } const permissionDescriptor = { mode: 'readwrite' }; if (typeof dirHandle.queryPermission === 'function') { const current = await dirHandle.queryPermission(permissionDescriptor); if (current === 'granted') { return; } } const result = await dirHandle.requestPermission(permissionDescriptor); if (result !== 'granted') { throw new Error('Read/write permission is required for the selected directory'); } } await ensureRwPermission(handle); // Verify the handle is still valid after permission check if (!handle || handle.kind !== 'directory') { throw new Error('Directory handle became invalid after permission check'); } app.data.selectedDirHandle = handle; updateDirectoryIndicator(handle.name); app.state.apply(); if (DEBUG) console.log('[transmittal] Directory handle ready:', handle.name); return handle; } // Parse folder name: YYYY-MM-DD_TRACKING (STATUS) - TITLE // Wraps zddc.parseFolder, preserving this module's null-on-invalid contract. function parseFolderName(name) { var parsed = zddc.parseFolder(name); if (!parsed || !parsed.valid) { return null; } return { date: parsed.date, trackingNumber: parsed.trackingNumber, status: parsed.status, title: parsed.title }; } function populateFields(parsed) { if (!parsed) { return; } var map = { 'date': parsed.date, 'tracking-number': parsed.trackingNumber, 'purpose': parsed.status, 'subject': parsed.title }; Object.keys(map).forEach(function (id) { var el = dom.qs('#' + id); if (el && map[id]) { el.value = map[id]; } }); } // Shared scan pipeline: collect → populate rows → hash with progress // onHash(item, hash) is called per file after hashing; return value sets cell content. async function scanEntries(dirHandle, onHash) { var entries = await collectDirectoryEntries(dirHandle); entries.sort(function (a, b) { return a.path.localeCompare(b.path); }); // Phase 0: Ensure directory handle has permission await ensureDirHandlePermission(dirHandle); // Phase 1: merge with existing files var existingIndex = filesModule.buildExistingIndex(app.data.files || []); var existingPasteKeys = Object.keys(existingIndex); setScanningState(true); var hashCells = []; for (var i = 0; i < entries.length; i++) { var entry = entries[i]; try { // Check and refresh handle permission before getFile var file = await getFreshFile(entry.handle); var parsed = (zddc.parseFilename(file.name) || {}); var fileData = { path: entry.path, name: file.name, size: file.size, fileSize: file.size, sha256: '', trackingNumber: parsed.trackingNumber, title: parsed.title, revision: parsed.revision, status: parsed.status, extension: parsed.extension || zddc.splitExtension(file.name).extension, fileHandle: entry.handle }; var pasteKey = (fileData.trackingNumber || '').toLowerCase() + '|' + (fileData.revision || '').toLowerCase(); if (existingPasteKeys.indexOf(pasteKey) === -1) { app.data.files.push(fileData); var hashCell = filesModule.renderSingleRow(fileData, app.data.files.length - 1); hashCells.push({ cell: hashCell, fileData: fileData, handle: entry.handle }); } } catch (err) { console.error('[transmittal] Error reading file:', entry.path, err); } if (i % 20 === 0) { await new Promise(function (r) { setTimeout(r, 0); }); } } // Phase 2: hash each file with progress bar setScanningState(false); for (var j = 0; j < hashCells.length; j++) { var item = hashCells[j]; try { var fill = item.cell ? item.cell.querySelector('.hash-progress-fill') : null; var onProgress = fill ? function (f) { return function (loaded, total) { var pct = total > 0 ? Math.round((loaded / total) * 100) : 0; f.style.width = pct + '%'; }; }(fill) : null; var file = await getFreshFile(item.handle); var hash = await util.hashFile(file, onProgress); item.fileData.sha256 = hash; if (onHash) { onHash(item, hash); } else if (item.cell) { item.cell.textContent = util.formatShortFileHash(hash); } } catch (err) { console.error('[transmittal] Error hashing file:', item.fileData.path, err); if (item.cell) { item.cell.textContent = 'error'; } } } return hashCells; } function finalizeAfterScan() { var handleMap = buildFileHandleMap(app.data.files); updateFilesInJson(app.data.files); filesModule.render(); filesModule.loadFromJson({ filesOnly: true }); restoreFileHandles(app.data.files, handleMap); app.state.apply(); } async function selectDirectory(event) { if (DEBUG) console.log('[transmittal] ========== SELECT DIRECTORY STARTED =========='); const trigger = event && event.currentTarget instanceof HTMLElement ? event.currentTarget : null; if (trigger) { trigger.disabled = true; trigger.classList.add('opacity-60'); } try { var dirHandle = app.data.selectedDirHandle; if (!dirHandle) { dirHandle = await ensureDirHandle(); } populateFields(parseFolderName(dirHandle.name)); await scanEntries(dirHandle); finalizeAfterScan(); app.markDirty(); setPrimary('publish'); } catch (err) { if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) { if (DEBUG) console.log('[transmittal] User cancelled directory selection'); } else { console.error('[transmittal] selectDirectory failed', err); } } finally { setScanningState(false); if (trigger) { trigger.disabled = false; trigger.classList.remove('opacity-60'); } } } async function writeFileToSelectedDir(filename, contents, mime) { if (!app.data.selectedDirHandle) { throw new Error('No directory selected'); } const fileHandle = await app.data.selectedDirHandle.getFileHandle(filename, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(new Blob([contents], { type: mime || 'text/plain' })); await writable.close(); } filesModule.writeFileToSelectedDir = writeFileToSelectedDir; // ── Paste helpers ───────────────────────────────────── // Expected formats (tab-separated, 3-5 adjacent columns): // 3 cols: Tracking \t Title \t Revision+Status ("A (IFR)") // 4 cols: Tracking \t Title \t Revision \t Status // 5 cols: Tracking \t Title \t Revision \t Status \t Extension var MAX_PASTE_COLS = 5; var HEADER_RE = /^(#|tracking|number|title|revision|status|ext)/i; function isHeaderLine(cols) { if (!cols || !cols.length) { return false; } return HEADER_RE.test((cols[0] || '').trim()); } function columnsToFileRow(cols) { var tracking = (cols[0] || '').trim(); if (!tracking) { return null; } var revision = (cols[2] || '').trim(); var status = (cols[3] || '').trim(); var extension = (cols[4] || '').trim().toLowerCase(); // 3-column paste: split "A (IFR)" into revision "A" and status "IFR" if (cols.length <= 3 && revision) { var spaceIdx = revision.indexOf(' '); if (spaceIdx > 0) { status = revision.substring(spaceIdx + 1).trim().replace(/^\(+/, '').replace(/\)+$/, ''); revision = revision.substring(0, spaceIdx).trim(); } } return { trackingNumber: tracking, title: (cols[1] || '').trim(), revision: revision, status: status, extension: extension, path: '', name: '', size: 0, fileSize: 0, sha256: '' }; } // Parse plain-text tab-separated clipboard data. // Returns { rows: [...], tooWide: false } or { rows: [], tooWide: true }. function parseClipboardText(text) { var lines = (text || '').split(/\r?\n/); var rows = []; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) { continue; } var cols = line.split('\t'); if (isHeaderLine(cols)) { continue; } if (cols.length > MAX_PASTE_COLS) { return { rows: [], tooWide: true, colCount: cols.length }; } var row = columnsToFileRow(cols); if (row) { rows.push(row); } } return { rows: rows, tooWide: false }; } function pasteFileKey(trackingNumber, revision) { return (trackingNumber || '').toLowerCase() + '|' + (revision || '').toLowerCase(); } function buildExistingIndex(files) { var index = {}; for (var i = 0; i < files.length; i++) { index[pasteFileKey(files[i].trackingNumber, files[i].revision)] = i; } return index; } filesModule.buildExistingIndex = buildExistingIndex; async function handlePasteFiles(mode, clipText) { var setStatus = app.modules.data && app.modules.data.setStatus; try { var text = clipText || await navigator.clipboard.readText(); var result = parseClipboardText(text); if (result.tooWide) { if (setStatus) { setStatus('Paste has ' + result.colCount + ' columns (max ' + MAX_PASTE_COLS + '). ' + 'Copy only: Tracking, Title, Revision, [Status], [Extension]', 'error'); } return; } var rows = result.rows; if (!rows.length) { if (setStatus) { setStatus('No valid rows found. Expected tab-separated: Tracking, Title, Revision, [Status], [Ext]', 'error'); } return; } var added = 0, updated = 0; if (mode === 'new') { app.data.files = []; for (var i = 0; i < rows.length; i++) { app.data.files.push(rows[i]); added++; } } else { var index = buildExistingIndex(app.data.files || []); for (var j = 0; j < rows.length; j++) { var row = rows[j]; var key = pasteFileKey(row.trackingNumber, row.revision); if (index.hasOwnProperty(key)) { var existing = app.data.files[index[key]]; existing.title = row.title; existing.status = row.status; if (row.extension) { existing.extension = row.extension; } updated++; } else { app.data.files.push(row); added++; } } } filesModule.sortFilesInPlace(app.data.files); updateFilesInJson(app.data.files); filesModule.render(); app.state.apply(); app.markDirty(); setPrimary('verify'); var msg = mode === 'new' ? 'Replaced file list: ' + added + ' rows' : added + ' added, ' + updated + ' updated'; if (setStatus) { setStatus(msg, 'success'); } } catch (err) { console.error('[transmittal] paste failed', err); if (setStatus) { setStatus('Paste failed: ' + (err && err.message ? err.message : err), 'error'); } } } // ── Toolbar visibility per state ──────────────────── function updateToolbars() { var docState = app.state.detectState(); buildBottomBar(docState); } function createMenuItem(label, handler, options) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'dropdown-item' + ((options && options.danger) ? ' text-red-600' : ''); btn.setAttribute('role', 'menuitem'); btn.textContent = label; btn.addEventListener('click', handler); return btn; } function createSeparator(label) { var el = document.createElement('div'); el.className = 'dropdown-separator'; if (label) { el.textContent = label; } return el; } function applyPrimaryButton(btn, intent) { if (_primaryHandler) { btn.removeEventListener('click', _primaryHandler); } btn.disabled = false; btn.classList.remove('opacity-60'); if (intent === 'publish') { btn.textContent = 'Publish'; _primaryHandler = function () { document.dispatchEvent(new CustomEvent('transmittal:open-publish')); }; } else if (intent === 'verify') { btn.textContent = 'Verify Directory'; _primaryHandler = function () { if (!requireFileSystemAccess()) { return; } verifyFiles(); }; } else { btn.textContent = 'Scan Directory'; _primaryHandler = function () { if (!requireFileSystemAccess()) { return; } selectDirectory({ currentTarget: null }); }; } btn.addEventListener('click', _primaryHandler); } function buildBottomBar(docState) { var primaryBtn = dom.qs('#bottom-primary'); var dropdown = dom.qs('#bottom-dropdown'); if (!primaryBtn || !dropdown) { return; } var isPublished = docState === 'published'; var d = app.modules.data || {}; dropdown.innerHTML = ''; dropdown.classList.add('hidden'); if (isPublished) { applyPrimaryButton(primaryBtn, 'verify'); dropdown.appendChild(createMenuItem('Copy Table', function () { if (d.handleCopyTable) { d.handleCopyTable(); } })); dropdown.appendChild(createMenuItem('Copy JSON', function () { if (d.handleCopyJson) { d.handleCopyJson(); } })); dropdown.appendChild(createSeparator()); dropdown.appendChild(createMenuItem('Add Signature', function () { if (app.modules.security && app.modules.security.addSignature) { app.modules.security.addSignature(); } })); dropdown.appendChild(createMenuItem('Acknowledge Receipt', function () { if (app.modules.security && app.modules.security.addSignature) { app.modules.security.addSignature({ label: 'Received By' }); } })); dropdown.appendChild(createSeparator()); dropdown.appendChild(createMenuItem('Revise', function () { if (d.handleRevise) { d.handleRevise(); } })); dropdown.appendChild(createMenuItem('Import HTML', function () { if (d.handleImportHtml) { d.handleImportHtml(); } })); dropdown.appendChild(createMenuItem('Reset', function () { if (app.modules.reset && app.modules.reset.handleReset) { app.modules.reset.handleReset(); } }, { danger: true })); dropdown.appendChild(createSeparator()); dropdown.appendChild(createMenuItem('Create Index', function () { if (!requireFileSystemAccess()) { return; } if (filesModule.generateArchiveRedirects) { filesModule.generateArchiveRedirects().catch(function (err) { console.error('[transmittal] create-index failed', err); }); } })); } else { // Edit: respect explicit intent, or auto-detect var intent = _primaryIntent; if (!intent) { intent = hasFiles() ? 'verify' : 'scan'; } applyPrimaryButton(primaryBtn, intent); dropdown.appendChild(createMenuItem('Scan Directory', function () { if (!requireFileSystemAccess()) { return; } selectDirectory({ currentTarget: null }); })); dropdown.appendChild(createMenuItem('Verify Directory', function () { if (!requireFileSystemAccess()) { return; } verifyFiles(); })); dropdown.appendChild(createSeparator()); dropdown.appendChild(createMenuItem('Publish', function () { document.dispatchEvent(new CustomEvent('transmittal:open-publish')); })); dropdown.appendChild(createMenuItem('Save Draft', function () { if (d.handleSaveHtmlDraft) { d.handleSaveHtmlDraft(); } })); dropdown.appendChild(createMenuItem('Create Folder', async function () { var setStatus = d.setStatus; try { // Sync UI so payload reflects current form values filesModule.syncUiToJson(); var data = json.parse(); var payload = (data && data.payload) || {}; if (!payload.trackingNumber) { if (setStatus) { setStatus('Enter a tracking number first', 'error'); } return; } var folderName = d.buildFolderName(payload); // Sanitize for filesystem folderName = folderName.replace(/[<>:"/\\|?*]+/g, '_').replace(/_+/g, '_'); // Prompt for staging directory var stagingHandle = await window.showDirectoryPicker({ mode: 'readwrite' }); var newFolderHandle = await stagingHandle.getDirectoryHandle(folderName, { create: true }); // Set as selected directory for subsequent file operations app.data.selectedDirHandle = newFolderHandle; app.data.selectedDirName = folderName; if (filesModule.updateDirectoryIndicator) { filesModule.updateDirectoryIndicator(); } // Save a draft into the new folder var pub = app.modules.publish; if (pub && typeof pub.syncUiToJson === 'function' && typeof pub.buildHtmlString === 'function') { await pub.syncUiToJson({ sign: false, computeDigest: false }); var html = await pub.buildHtmlString(); var draftName = d.buildFileName( ((json.parse() || {}).payload || {}), { extension: 'html', draft: true } ); await writeFileToSelectedDir(draftName, html, 'text/html'); // Verify both the folder and draft file exist var warnings = []; try { await stagingHandle.getDirectoryHandle(folderName); } catch (_) { warnings.push('folder \"' + folderName + '\" could not be verified'); } try { await newFolderHandle.getFileHandle(draftName); } catch (_) { warnings.push('draft file \"' + draftName + '\" could not be verified'); } if (warnings.length) { if (setStatus) { setStatus('Warning: ' + warnings.join('; ') + '. Path may be too long for Windows.', 'error'); } } else if (setStatus) { setStatus('Draft saved to ' + folderName + '. Close this file and open ' + draftName + ' from the new folder.', 'success'); } } else { if (setStatus) { setStatus('Created folder: ' + folderName, 'success'); } } } catch (err) { if (err && err.name === 'AbortError') { return; } console.error('[transmittal] create-folder failed', err); if (setStatus) { setStatus('Create folder failed: ' + (err.message || err), 'error'); } } })); dropdown.appendChild(createSeparator()); dropdown.appendChild(createMenuItem('Paste New Rows', async function () { var text; try { text = await navigator.clipboard.readText(); } catch (e) { if (d.setStatus) { d.setStatus('Clipboard access denied', 'error'); } return; } var result = parseClipboardText(text); if (result.tooWide) { if (d.setStatus) { d.setStatus('Paste has ' + result.colCount + ' columns (max ' + MAX_PASTE_COLS + '). ' + 'Copy only: Tracking, Title, Revision, [Status], [Extension]', 'error'); } return; } if (!result.rows.length) { if (d.setStatus) { d.setStatus('No valid rows on clipboard', 'error'); } return; } if (confirm('Replace file list with ' + result.rows.length + ' rows from clipboard?')) { handlePasteFiles('new', text); } })); dropdown.appendChild(createMenuItem('Paste Append Rows', function () { handlePasteFiles('append'); })); dropdown.appendChild(createMenuItem('Copy Table', function () { if (d.handleCopyTable) { d.handleCopyTable(); } })); dropdown.appendChild(createMenuItem('Remove Files', function () { if (!app.data.files.length) { if (d.setStatus) { d.setStatus('File list is already empty', 'error'); } return; } if (confirm('Remove all ' + app.data.files.length + ' files from the list? Header info and remarks will be kept.')) { app.data.files = []; updateFilesInJson([]); filesModule.render(); app.state.apply(); app.markDirty(); if (d.setStatus) { d.setStatus('File list cleared', 'success'); } } })); dropdown.appendChild(createSeparator()); dropdown.appendChild(createMenuItem('Import HTML', function () { if (d.handleImportHtml) { d.handleImportHtml(); } })); dropdown.appendChild(createMenuItem('Copy JSON', function () { if (d.handleCopyJson) { d.handleCopyJson(); } })); dropdown.appendChild(createMenuItem('Paste JSON', function () { if (d.handleLoadFromClipboard) { d.handleLoadFromClipboard(); } })); dropdown.appendChild(createSeparator()); dropdown.appendChild(createMenuItem('Reset', function () { if (app.modules.reset && app.modules.reset.handleReset) { app.modules.reset.handleReset(); } }, { danger: true })); dropdown.appendChild(createSeparator()); dropdown.appendChild(createMenuItem('Create Index', function () { if (!requireFileSystemAccess()) { return; } if (filesModule.generateArchiveRedirects) { filesModule.generateArchiveRedirects().catch(function (err) { console.error('[transmittal] create-index failed', err); }); } })); } } // ── Bottom bar dropdown toggle ────────────────────── function initBottomBarToggle() { var toggle = dom.qs('#bottom-toggle'); var menu = dom.qs('#bottom-dropdown'); if (!toggle || !menu) { return; } toggle.addEventListener('click', function (e) { e.stopPropagation(); var open = !menu.classList.contains('hidden'); menu.classList.toggle('hidden', open); toggle.setAttribute('aria-expanded', String(!open)); }); menu.addEventListener('click', function () { menu.classList.add('hidden'); toggle.setAttribute('aria-expanded', 'false'); }); document.addEventListener('click', function (e) { var container = dom.qs('#bottom-menu'); if (container && !container.contains(e.target)) { menu.classList.add('hidden'); toggle.setAttribute('aria-expanded', 'false'); } }); } async function refreshDirectory() { var dirHandle = app.data.selectedDirHandle; if (!dirHandle) { return; } try { // Ensure directory handle has permission before scanning await ensureDirHandlePermission(dirHandle); // Build expected-hash lookup from current JSON var expectedHashes = {}; var currentData = json.parse(); var loadedFiles = (currentData && currentData.payload && Array.isArray(currentData.payload.files)) ? currentData.payload.files : []; loadedFiles.forEach(function (f) { var key = (f.path ? f.path + '/' : '') + f.filename; if (f.sha256) { expectedHashes[key.toLowerCase()] = f.sha256.toLowerCase(); } }); var hasExpected = Object.keys(expectedHashes).length > 0; var matchCount = 0; var mismatchCount = 0; var hashCells = await scanEntries(dirHandle, hasExpected ? function (item, hash) { if (!item.cell) { return; } var display = util.formatShortFileHash(hash); var fileKey = (item.fileData.path || item.fileData.name).toLowerCase(); var expected = expectedHashes[fileKey]; if (expected && expected === hash.toLowerCase()) { item.cell.innerHTML = '\u2713 ' + display; matchCount++; } else if (expected) { item.cell.innerHTML = '\u2717 ' + display; mismatchCount++; } else { item.cell.textContent = display; } } : null); if (hasExpected && app.modules.data && app.modules.data.setStatus) { if (mismatchCount === 0 && matchCount > 0) { app.modules.data.setStatus(matchCount + '/' + hashCells.length + ' files verified', 'success'); } else if (mismatchCount > 0) { app.modules.data.setStatus(mismatchCount + ' hash mismatch(es) \u2014 ' + matchCount + ' matched', 'error'); } } finalizeAfterScan(); if (!hasExpected) { app.markDirty(); } } catch (err) { console.error('[transmittal] Refresh failed', err); } finally { setScanningState(false); } } // ── Verify row helpers ──────────────────────────────────── function findRowByFileIndex(idx) { var cell = document.querySelector('td[data-index="' + idx + '"]'); return cell ? cell.closest('tr') : null; } function getHashCell(row) { return row ? row.querySelector('td:last-child') : null; } function setRowVerifyState(row, state) { if (!row) { return; } row.classList.remove('verify-match', 'verify-mismatch', 'verify-missing', 'verify-new', 'verify-progress'); if (state) { row.classList.add('verify-' + state); } } function clearAllVerifyStates() { var rows = document.querySelectorAll('tr.verify-match, tr.verify-mismatch, tr.verify-missing, tr.verify-new, tr.verify-progress'); rows.forEach(function (r) { r.classList.remove('verify-match', 'verify-mismatch', 'verify-missing', 'verify-new', 'verify-progress'); }); } function escapeHtml(str) { var div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function showPathDiff(trackingCell, expectedPath, actualPath) { var diffEl = document.createElement('div'); diffEl.className = 'path-diff'; var del = document.createElement('del'); del.textContent = expectedPath; var ins = document.createElement('ins'); ins.textContent = actualPath; diffEl.appendChild(del); diffEl.appendChild(document.createTextNode(' \u2192 ')); diffEl.appendChild(ins); trackingCell.appendChild(diffEl); } // Hash every directory entry upfront, before any event-loop yields. // On file:// origins in Chromium, FileSystemFileHandle.getFile() fails after // macrotask boundaries have elapsed since the handle was created. The fix is // to call getFile() + hashFile() back-to-back for each entry in one tight // sequential pass, producing a complete index before any UI yields occur. // Returns { sizeIndex, nameIndex } where each entry has hash already computed. async function buildVerifyIndex(entries, onProgress) { var sizeIndex = {}; // fileSize → [candidate] var nameIndex = {}; // "tracking\trevision" → candidate var entryList = []; // List of file metadata for later use for (var i = 0; i < entries.length; i++) { var entry = entries[i]; try { var file = await entry.handle.getFile(); var hash = await util.hashFile(file); var cand = { handle: entry.handle, path: entry.path, name: file.name, fileSize: file.size, hash: hash, matched: false }; // Store file metadata for later use entryList.push({ handle: entry.handle, path: entry.path, name: file.name, fileSize: file.size, hash: hash, parsed: (zddc.parseFilename(entry.name) || {}) }); // size index if (!sizeIndex[file.size]) { sizeIndex[file.size] = []; } sizeIndex[file.size].push(cand); // name index var parsed = (zddc.parseFilename(entry.name) || {}); if (parsed.trackingNumber) { var nkey = parsed.trackingNumber.toLowerCase() + '\t' + (parsed.revision || '').toLowerCase(); if (!nameIndex[nkey]) { nameIndex[nkey] = []; } nameIndex[nkey].push(cand); } if (onProgress) { onProgress(i + 1, entries.length); } } catch (err) { console.warn('[transmittal] verify skip entry', entry.path, err); } } return { sizeIndex: sizeIndex, nameIndex: nameIndex, entryList: entryList }; } // Find a matching directory entry by sha256 hash. function findByHash(sizeIndex, fileSize, expectedHash) { if (!expectedHash || fileSize == null) { return null; } var candidates = sizeIndex[fileSize]; if (!candidates) { return null; } var target = expectedHash.toLowerCase(); for (var c = 0; c < candidates.length; c++) { var cand = candidates[c]; if (cand.matched) { continue; } if (cand.hash && cand.hash.toLowerCase() === target) { cand.matched = true; return cand; } } return null; } // Find a matching directory entry by tracking number + revision. function findByTrackingRevision(nameIndex, trackingNumber, revision) { var key = (trackingNumber || '').toLowerCase() + '\t' + (revision || '').toLowerCase(); var candidates = nameIndex[key]; if (!candidates || !candidates.length) { return null; } for (var i = 0; i < candidates.length; i++) { if (!candidates[i].matched) { candidates[i].matched = true; return candidates[i]; } } return null; } // Count unmatched entries in a size index function countUnmatched(sizeIndex) { var count = 0; for (var size in sizeIndex) { var group = sizeIndex[size]; for (var i = 0; i < group.length; i++) { if (!group[i].matched) { count++; } } } return count; } async function verifyFiles() { var isPublished = !!app.state.published; var setStatus = app.modules.data && app.modules.data.setStatus; try { var dirHandle = app.data.selectedDirHandle; if (!dirHandle) { dirHandle = await ensureDirHandle(); } clearAllVerifyStates(); if (setStatus) { setStatus('Hashing directory\u2026', 'info'); } var entries = await collectDirectoryEntries(dirHandle); var allFiles = app.data.files || []; // Hash every file upfront before any UI yields — on file:// origins, // Chromium invalidates FileSystemFileHandle after macrotask boundaries. var idx = await buildVerifyIndex(entries); var sizeIndex = idx.sizeIndex; var nameIndex = idx.nameIndex; var entryList = idx.entryList || []; // Build pasteKey index for existing files to avoid duplicates var existingIndex = buildExistingIndex(allFiles); var existingPasteKeys = Object.keys(existingIndex); if (isPublished) { // ── Published: read-only verification by hash ── var verified = 0; var missingCount = 0; for (var p = 0; p < allFiles.length; p++) { var tf = allFiles[p]; var row = findRowByFileIndex(p); var hCell = getHashCell(row); var trackingCell = row ? row.querySelector('td[data-field="trackingNumber"]') : null; setRowVerifyState(row, 'progress'); await new Promise(function (r) { setTimeout(r, 0); }); var fileSize = tf.fileSize != null ? tf.fileSize : tf.size; var found = findByHash(sizeIndex, fileSize, tf.sha256); if (found) { verified++; tf.fileHandle = found.handle; setRowVerifyState(row, 'match'); if (hCell) { hCell.innerHTML = '\u2713 ' + escapeHtml(util.formatShortFileHash(tf.sha256)); } var expectedPath = tf.path || tf.name || ''; if (trackingCell && expectedPath && found.path && expectedPath !== found.path) { showPathDiff(trackingCell, expectedPath, found.path); } } else { missingCount++; setRowVerifyState(row, 'missing'); if (hCell) { hCell.innerHTML = '\u26A0 not found'; } } } var extra = countUnmatched(sizeIndex); var msg = verified + ' verified'; if (missingCount) { msg += ', ' + missingCount + ' missing'; } if (extra) { msg += ', ' + extra + ' extra in directory'; } if (setStatus) { setStatus(msg, missingCount ? 'error' : 'success'); } } else { // ── Edit: match rows against directory ── // Rows WITH hash+size → match by size+hash // Rows WITHOUT hash → match by tracking+revision, then populate hash/size var verified = 0; var populated = 0; var notFound = 0; var dirty = false; for (var mi = 0; mi < allFiles.length; mi++) { var mf = allFiles[mi]; var mRow = findRowByFileIndex(mi); var mhCell = getHashCell(mRow); setRowVerifyState(mRow, 'progress'); await new Promise(function (r) { setTimeout(r, 0); }); var hasHash = !!mf.sha256; var mFileSize = mf.fileSize != null ? mf.fileSize : mf.size; if (hasHash && mFileSize != null) { var foundByHash = findByHash(sizeIndex, mFileSize, mf.sha256); if (foundByHash) { mf.fileHandle = foundByHash.handle; verified++; setRowVerifyState(mRow, 'match'); if (mhCell) { mhCell.innerHTML = '\u2713 ' + escapeHtml(util.formatShortFileHash(mf.sha256)); } } else { mf.fileHandle = null; notFound++; setRowVerifyState(mRow, 'missing'); if (mhCell) { mhCell.innerHTML = '\u26A0 not found'; } } } else { var foundByName = findByTrackingRevision(nameIndex, mf.trackingNumber, mf.revision); if (foundByName) { mf.fileHandle = foundByName.handle; mf.path = foundByName.path; mf.name = foundByName.name; mf.size = foundByName.fileSize; mf.fileSize = foundByName.fileSize; mf.sha256 = foundByName.hash; mf.extension = mf.extension || zddc.splitExtension(foundByName.name).extension; populated++; dirty = true; setRowVerifyState(mRow, 'match'); if (mhCell) { mhCell.innerHTML = '\u2713 ' + escapeHtml(util.formatShortFileHash(mf.sha256)); } } else { notFound++; setRowVerifyState(mRow, 'missing'); if (mhCell) { mhCell.innerHTML = '\u26A0 not found'; } } } } if (dirty) { updateFilesInJson(allFiles); filesModule.render(); app.state.apply(); app.markDirty(); } var editMsg = ''; var parts = []; if (verified) { parts.push(verified + ' verified'); } if (populated) { parts.push(populated + ' matched'); } if (notFound) { parts.push(notFound + ' not found'); } editMsg = parts.join(', ') || 'No files to verify'; // Add new files from directory that don't match existing pasteKeys var added = 0; for (var ei = 0; ei < entryList.length; ei++) { var entryData = entryList[ei]; if (!entryData.parsed.trackingNumber && !entryData.parsed.revision) continue; // Skip files without ZDDC pattern var pasteKey = (entryData.parsed.trackingNumber || '').toLowerCase() + '|' + (entryData.parsed.revision || '').toLowerCase(); if (existingPasteKeys.indexOf(pasteKey) === -1) { // New file - use pre-hashed file data var fileData = { path: entryData.path, name: entryData.name, size: entryData.fileSize, fileSize: entryData.fileSize, sha256: '', trackingNumber: entryData.parsed.trackingNumber, title: entryData.parsed.title, revision: entryData.parsed.revision, status: entryData.parsed.status, extension: entryData.parsed.extension || zddc.splitExtension(entryData.name).extension, fileHandle: entryData.handle }; app.data.files.push(fileData); added++; } } if (added > 0) { dirty = true; updateFilesInJson(app.data.files); filesModule.render(); app.state.apply(); app.markDirty(); editMsg = (editMsg ? editMsg + ', ' : '') + added + ' new file(s) added'; } if (setStatus) { setStatus(editMsg, notFound && !added ? 'error' : 'success'); } } } catch (err) { if (err && (err.name === 'AbortError' || err.name === 'NotAllowedError')) { if (DEBUG) console.log('[transmittal] User cancelled verify'); } else { console.error('[transmittal] verifyFiles failed', err); if (setStatus) { setStatus('Verify failed: ' + (err.message || err), 'error'); } } } } document.addEventListener('transmittal:scan-directory', function () { selectDirectory({ currentTarget: dom.qs('#bottom-primary') }); }); document.addEventListener('transmittal:verify-directory', function () { verifyFiles(); }); filesModule.bindActionButtons = function bindActionButtons() { // Reveal the menu now that JS is running var bottomMenu = dom.qs('#bottom-menu'); if (bottomMenu) { bottomMenu.hidden = false; } var noJsNotice = dom.qs('#no-js-notice'); if (noJsNotice) { dom.show(noJsNotice, false); } initBottomBarToggle(); updateToolbars(); }; filesModule.updateToolbars = updateToolbars; filesModule.syncUiToJson = function syncUiToJson() { const val = function (selector) { const el = dom.qs(selector); return el ? (el.value || '') : ''; }; function toIsoDate(value) { if (!value) { return ''; } const trimmed = String(value).trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { return trimmed; } const match = trimmed.match(/^(?:[A-Za-z]+\s+)?([A-Za-z]{3,})\s+(\d{1,2}),\s*(\d{4})$/); const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; if (match) { const monthIndex = months.indexOf(match[1].slice(0, 3)); if (monthIndex >= 0) { const day = String(parseInt(match[2], 10)).padStart(2, '0'); const month = String(monthIndex + 1).padStart(2, '0'); const year = match[3]; return year + '-' + month + '-' + day; } } const date = new Date(trimmed); if (!Number.isNaN(date.getTime())) { return date.toISOString().slice(0, 10); } return trimmed; } const data = json.parse(); const envelope = { ...(data.envelope || {}) }; const presentation = { ...(data.presentation || {}) }; const payload = { version: 1, type: val('#type'), title: val('#title') || '', client: (dom.qs('#owner-name')?.textContent) || '', project: (dom.qs('#project-name')?.textContent) || '', projectNumber: (dom.qs('#project-number')?.textContent) || '', date: toIsoDate(val('#date')), trackingNumber: val('#tracking-number'), from: val('#from'), to: val('#to'), purpose: val('#purpose'), responseDue: val('#response-due'), subject: val('#subject'), remarks: val('#remarks'), files: (function () { var memFiles = Array.isArray(app.data.files) ? app.data.files : []; var sorted = memFiles.slice().sort(util.compareFilesByTrackingRevision).map(canonicalFilePayload); var self = buildSelfEntry(); return [canonicalFilePayload(self)].concat(sorted); })() }; const leftLogoEl = dom.qs('#left-logo'); const rightLogoEl = dom.qs('#right-logo'); presentation.leftLogo = leftLogoEl && leftLogoEl.src ? leftLogoEl.src : ''; presentation.rightLogo = rightLogoEl && rightLogoEl.src ? rightLogoEl.src : ''; json.setData({ envelope: envelope, payload: payload, presentation: presentation }); }; filesModule.loadFromJson = function loadFromJson(options) { const opts = options || {}; const data = json.parse(); const payload = (data && data.payload) || {}; if (!opts.filesOnly) { const assignValue = function (selector, value) { const element = dom.qs(selector); if (element) { element.value = value || ''; } }; const assignText = function (selector, value) { const element = dom.qs(selector); if (element) { element.textContent = value || ''; } }; assignValue('#type', payload.type || 'Transmittal'); var typeDisplay = dom.qs('#type-display'); if (typeDisplay) { typeDisplay.textContent = payload.type || 'Transmittal'; } assignValue('#title', payload.title || ''); assignText('#owner-name', payload.client || ''); assignText('#project-name', payload.project || ''); assignText('#project-number', payload.projectNumber || ''); assignValue('#date', payload.date || ''); assignValue('#tracking-number', payload.trackingNumber || ''); assignValue('#from', payload.from || ''); assignValue('#to', payload.to || ''); assignValue('#purpose', payload.purpose || ''); assignValue('#response-due', payload.responseDue || ''); assignValue('#subject', payload.subject || ''); const remarks = dom.qs('#remarks'); if (remarks) { remarks.value = payload.remarks || ''; } // Load logos from presentation data const presentation = (data && data.presentation) || {}; const leftLogoEl = dom.qs('#left-logo'); const rightLogoEl = dom.qs('#right-logo'); if (leftLogoEl && presentation.leftLogo) { leftLogoEl.src = presentation.leftLogo; } if (rightLogoEl && presentation.rightLogo) { rightLogoEl.src = presentation.rightLogo; } } var SELF_HASH = app.constants.SELF_HASH; const files = Array.isArray(payload.files) ? payload.files.filter(function (f) { return f.sha256 !== SELF_HASH; }) : []; app.data.files = files.map(function (entry) { const pathOnly = entry.path || ''; const filename = entry.filename || ''; const relativePath = pathOnly ? (pathOnly + '/' + filename) : filename; // When filename is empty (e.g., from pasted files), reconstruct from trackingNumber let baseName = filename || relativePath.split('/').pop() || ''; if (!baseName && entry.trackingNumber) { baseName = zddc.joinExtension(entry.trackingNumber, entry.extension || ''); } const fileSize = entry.fileSize || entry.size || 0; return { path: relativePath, name: baseName, size: fileSize, fileSize: fileSize, sha256: entry.sha256 || '', trackingNumber: entry.trackingNumber || '', title: entry.title || '', revision: entry.revision || '', status: entry.status || '', extension: zddc.splitExtension(baseName).extension }; }); sortFilesInPlace(app.data.files); // Don't call updateFilesInJson here - it clears digest/signatures // The files are already in the JSON, we're just loading them into app.data.files updateDirectoryIndicator(app.data.selectedDirHandle ? app.data.selectedDirHandle.name : ''); // Render the table to populate it with file data filesModule.render(); if (!opts.filesOnly && app.modules.markdown && typeof app.modules.markdown.refresh === 'function') { app.modules.markdown.refresh(); } // Apply field visibility after loading data to ensure UI stays in sync if (!opts.filesOnly && app.modules.visibility && typeof app.modules.visibility.applyFieldVisibility === 'function') { app.modules.visibility.applyFieldVisibility(); } }; app.registerInit(function () { updateDirectoryIndicator(app.data.selectedDirHandle ? app.data.selectedDirHandle.name : ''); filesModule.bindActionButtons(); filesModule.setupTableEditing(); }); // Auto-load when served by zddc-server: the page lives at // /<...>/Staging//transmittal.html and that folder IS the // working transmittal. Build an HTTP polyfill handle for it, // assign it as the selected directory, and run the same scan // pipeline the "Add Directory" button does. // // A 403 on the listing probe means the user can't list this folder — // transmittal needs `r` at minimum, so show a clear message rather // than silently leaving the editor empty. app.registerInit(async function () { if (typeof location === 'undefined') { return; } if (location.protocol !== 'http:' && location.protocol !== 'https:') { return; } if (app.data.selectedDirHandle) { return; } try { var probe = await window.zddc.source.detectServerRoot(); if (probe.handle) { app.data.selectedDirHandle = probe.handle; updateDirectoryIndicator(probe.handle.name); // Run the same flow as the "Add Directory" button, minus // the click-event plumbing — selectDirectory will skip the // picker because selectedDirHandle is already set. await selectDirectory({ currentTarget: null }); return; } if (probe.status === 403) { console.warn('[transmittal] no permission to list directory; transmittal needs `r` at minimum'); updateDirectoryIndicator('— no permission to list this directory —'); } } catch (err) { console.warn('[transmittal] HTTP auto-load failed:', err); } }); })(window.transmittalApp);