/** * ZDDC Classifier — target-tree pane (Classify & Copy mode). * * Two orthogonal per-file grids (both on the shared seltable) the user maps * files onto — one editable row per file: * - "By tracking number": Tracking# / Rev (Status) / Title cells compose the * ZDDC filename (the rename). * - "By transmittal": one text input = the file's full transmittal folder path * "//" (the route); * committing it find-or-creates the party/slot/bin in classify.js. * * Structure + placements live in classify.js; everything shown here is derived, * never stored. Drops are handled per-grid (setupGridDrop / setupTransmittalDrop) * so a ⌘/Ctrl transmittal drop can branch a new folder. */ (function () { 'use strict'; var els = {}; var initialized = false; var currentTab = 'tracking'; // 'tracking' | 'transmittal' — active tab var listScanned = false; // a Load has run this session (drives the "new" badge) function init() { if (initialized) return; initialized = true; els = { trackingTab: document.getElementById('trackingTab'), transmittalTab: document.getElementById('transmittalTab'), trackingPanel: document.getElementById('trackingPanel'), transmittalPanel: document.getElementById('transmittalPanel'), trackingTree: document.getElementById('trackingTree'), transmittalTree: document.getElementById('transmittalTree'), loadWorklistBtn: document.getElementById('loadWorklistBtn'), pasteRowsBtn: document.getElementById('pasteRowsBtn'), matchNamesBtn: document.getElementById('matchNamesBtn'), clearListBtn: document.getElementById('clearListBtn'), addFilteredBtn: document.getElementById('addFilteredBtn'), renameBtn: document.getElementById('renameBtn'), trackingColsBtn: document.getElementById('trackingColsBtn'), stats: document.getElementById('classifyStats'), }; els.trackingTab.addEventListener('click', function () { showTab('tracking'); }); els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); }); if (els.loadWorklistBtn) els.loadWorklistBtn.addEventListener('click', loadWorklist); if (els.pasteRowsBtn) els.pasteRowsBtn.addEventListener('click', function () { openPasteDialog(''); }); if (els.matchNamesBtn) els.matchNamesBtn.addEventListener('click', openMatchDialog); if (els.addFilteredBtn) els.addFilteredBtn.addEventListener('click', addFilteredFiles); if (els.renameBtn) els.renameBtn.addEventListener('click', renameInPlace); if (els.clearListBtn) els.clearListBtn.addEventListener('click', function () { var list = C().getWorklist(); if (!list.length) { window.zddc.toast('No list rows to clear.', 'info'); return; } // Warn before stranding files that still need a revision: they stay // assigned (on a "pending" leaf), but the placeholder row goes away. var pending = 0; list.forEach(function (r) { if (!(r.revisionCell || '').trim()) pending += Object.keys(r.placed || {}).length; }); if (pending && !confirm(pending + ' file' + (pending === 1 ? '' : 's') + ' still need a revision. They stay assigned (a “pending” folder), but the list rows to finish them here go away. Clear anyway?')) return; C().clearWorklist(); window.zddc.toast('List rows cleared — every assignment is kept.', 'info'); }); // Ctrl-V on the By-tracking panel opens the paste dialog prefilled. if (els.trackingPanel) els.trackingPanel.addEventListener('paste', function (e) { if (currentTab !== 'tracking') return; if (e.target && e.target.closest('input, textarea')) return; // let real inputs paste var t = (e.clipboardData || window.clipboardData); var text = t ? t.getData('text') : ''; if (text) { e.preventDefault(); openPasteDialog(text); } }); if (els.trackingColsBtn) els.trackingColsBtn.addEventListener('click', openColumnChooser); setupGridDrop(els.trackingTree); setupTransmittalDrop(els.transmittalTree); C().on(render); if (window.app.modules.store && window.app.modules.store.on) { window.app.modules.store.on('files', render); } render(); } function C() { return window.app.modules.classify; } // Every scanned source file (classify mode reads the left tree, not the // selection-scoped grid). Lazy folders contribute their files once scanned. function allFiles() { var out = []; (function walk(nodes) { (nodes || []).forEach(function (n) { (n.files || []).forEach(function (f) { out.push(f); }); walk(n.children); }); })(window.app.folderTree || []); return out; } function showTab(which) { currentTab = (which === 'transmittal') ? 'transmittal' : 'tracking'; els.trackingTab.classList.toggle('active', currentTab === 'tracking'); els.transmittalTab.classList.toggle('active', currentTab === 'transmittal'); els.trackingPanel.hidden = currentTab !== 'tracking'; els.transmittalPanel.hidden = currentTab !== 'transmittal'; render(); // The source-tree Show filters are per-axis, so the visible set changes // with the active tab — re-render the left tree. reRenderSource(); } function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; } function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); } // ── render ─────────────────────────────────────────────────────────────── function render() { if (!initialized || !C().isEnabled()) return; var files = allFiles(); renderTrackingGrid(els.trackingTree); renderTransmittalGrid(els.transmittalTree); renderStats(files); } // Files in the grid whose NAME is complete (tracking + rev + title) — the // candidates for an in-place rename, regardless of transmittal. function renameableFiles() { var c = C(), out = []; c.trackingGridKeys().forEach(function (k) { var f = fileByKey(k); if (!f) return; if (!nameErrors(c.deriveTarget(f)).length) out.push(f); }); return out; } function renderStats(files) { var s = C().stats(files); if (els.stats) { els.stats.textContent = s.done + ' done · ' + s.partial + ' in progress · ' + s.none + ' unassigned · ' + s.excluded + ' excluded'; } // Copy… lives on the transmittal tab — enabled once files are fully done // (tracking leaf AND transmittal). var copyBtn = document.getElementById('copyOutputBtn'); if (copyBtn) { copyBtn.disabled = s.done === 0; copyBtn.textContent = s.done ? ('Copy ' + s.done + '…') : 'Copy…'; } // Rename… lives on the By-tracking tab — enabled once any grid file has a // complete name (transmittal not required). if (els.renameBtn) { var n = renameableFiles().length; els.renameBtn.disabled = n === 0; els.renameBtn.textContent = n ? ('Rename ' + n + '…') : 'Rename…'; } } function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } // ── name filter (the autofilter box above the target grids) ──────────── // Mirrored into each grid's own global filter (seltable.setFilter) on render. var rfTerms = []; function setNameFilter(q) { rfTerms = String(q || '').trim().toLowerCase().split(/\s+/).filter(Boolean); render(); } // ── By-tracking: flat editable grid (one row per file), on the shared // seltable — so it gets multi-sort + per-column autofilters + resizable, // persisted widths for free. Only `hidden` (the Columns ▾ chooser) is // classifier-specific; widths + sort persist under the same key via // seltable's own persistKey storage (merged, not clobbered). ─────────── var GRID_COL_META = [ { id: 'status', title: 'Status', fixed: true }, // fixed = never hidden by the chooser { id: 'orig', title: 'Original / expected name' }, { id: 'tn', title: 'Tracking number' }, { id: 'rev', title: 'Rev (status)' }, { id: 'title', title: 'Title' }, { id: 'src', title: 'Source', defaultHidden: true }, // folded in from "From a list" { id: 'latest', title: 'Latest rev', defaultHidden: true }, { id: 'x', title: '', fixed: true }, ]; // A column is hidden if the prefs say so explicitly, else by its defaultHidden. function colHidden(meta, hidden) { return (meta.id in hidden) ? !!hidden[meta.id] : !!meta.defaultHidden; } var GRID_PREFS_KEY = 'zddc.classifier.trackingCols'; function gridPrefs() { try { return JSON.parse(localStorage.getItem(GRID_PREFS_KEY)) || {}; } catch (_) { return {}; } } function saveGridPrefs(p) { try { localStorage.setItem(GRID_PREFS_KEY, JSON.stringify(p)); } catch (_) { /* private mode */ } } // A file's current identity, read from the placement model so per-field edits // keep the fields they didn't touch. function currentIdent(f) { var d = C().deriveTarget(f); return { tracking: d.tracking || '', rev: (d.revision || '') + (d.status ? ' (' + d.status + ')' : ''), title: d.title || '' }; } function gridTnWarn(tn) { tn = (tn || '').trim(); if (!tn) return ''; var n = tn.split('-').length, want = C().getTrackingFields().length; return (n < want - 1 || n > want) ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : ''; } function previewKey(key) { var f = fileByKey(key); if (f && window.app.modules.preview && window.app.modules.preview.previewFile) window.app.modules.preview.previewFile(f); } // The By-tracking grid validates the NAME only — the transmittal (path) is a // different tab, so its "not placed in a transmittal" error doesn't count here. function nameErrors(d) { return (d.errors || []).filter(function (e) { return e.indexOf('transmittal') === -1; }); } // ── unified rows: file rows + placeholder rows ───────────────────────── // A row is a boring 0-or-1-file thing: // { kind:'file', file, wl:null, id:'f:'+srcKey } // { kind:'placeholder', file:null, wl, id:'p:'+rowId } (a list row with no // file yet — drop/match a file on it and it becomes a file row) function joinName(f) { return f.originalFilename + (f.extension ? '.' + f.extension : ''); } function isFile(row) { return row.kind === 'file'; } function gridRows() { var c = C(), out = []; c.trackingGridKeys().forEach(function (k) { var f = fileByKey(k); if (f) out.push({ kind: 'file', file: f, wl: null, id: 'f:' + k }); }); c.getWorklist().forEach(function (r) { if (!Object.keys(r.placed || {}).length) out.push({ kind: 'placeholder', file: null, wl: r, id: 'p:' + r.id }); }); return out; } function sourceStr(r) { var s = r.source || {}; return [s.mdl ? 'mdl' : '', s.archive ? 'arch' : '', s.pasted ? 'pasted' : ''].filter(Boolean).join(' '); } // ── per-column cell renderers ────────────────────────────────────────── function gridStatusCell(td, f) { var c = C(), key = c.srcKeyForFile(f), d = c.deriveTarget(f), conflict = c.hasHashConflict(key); var ne = nameErrors(d), ok = !ne.length && !conflict; var badge = el('span', 'tfile__badge ' + (ok ? 'tfile__badge--ok' : 'tfile__badge--err'), conflict ? '≠' : (ne.length ? '⚠' : '✓')); badge.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content. ' : '') + (ne.length ? ne.join('; ') : 'Complete'); td.appendChild(badge); } function gridPlaceholderStatus(td) { var dot = el('span', 'tfile__badge tg-wanted', '◇'); dot.title = 'Awaiting a file — drag one onto this row, or use ⚡ Match names.'; td.appendChild(dot); } function gridOrigCell(td, f) { var key = C().srcKeyForFile(f), orig = joinName(f); var link = el('a', 'tg-orig__link', orig); link.href = '#'; link.title = 'Preview ' + orig; link.addEventListener('click', function (e) { e.preventDefault(); previewKey(key); }); td.appendChild(link); } function gridExpectedCell(td, wl) { var name = (wl.currentName || '').trim(); var span = el('span', 'tg-expected', name || '(drag a file here)'); span.title = name ? ('Expected file: ' + name + ' — drag it on, or ⚡ Match names.') : 'Drag a file onto this row to name it.'; td.appendChild(span); } // Editable cell for a FILE row → writes the file's identity (placement). function gridEditCell(td, colId, f) { var c = C(), key = c.srcKeyForFile(f), ident = currentIdent(f); var value = colId === 'tn' ? ident.tracking : colId === 'rev' ? ident.rev : ident.title; var ph = colId === 'tn' ? 'ACME-…-0001' : colId === 'rev' ? 'A (IFR)' : 'title'; var warn = colId === 'tn' ? gridTnWarn(ident.tracking) : ''; editCell(td, 'tg-input', value, ph, function (v) { var cur = currentIdent(f); // re-read so a prior edit isn't clobbered if (colId === 'tn') cur.tracking = v; else if (colId === 'rev') cur.rev = v; else cur.title = v; c.setFileIdentity(key, cur); }, warn); } // Editable cell for a PLACEHOLDER row → writes the worklist row. function gridRowEditCell(td, colId, wl) { var c = C(); if (colId === 'tn') editCell(td, 'tg-input', wl.trackingNumber, 'ACME-…-0001', function (v) { c.setRowTracking(wl.id, v); }, tnWarn(wl)); else if (colId === 'rev') editCell(td, 'tg-input', wl.revisionCell, 'A (IFR)', function (v) { c.setRevisionCell(wl.id, v); }); else editCell(td, 'tg-input', wl.title, 'title', function (v) { c.setRowTitle(wl.id, v); }); } function gridRemoveCell(td, f) { var c = C(), key = c.srcKeyForFile(f); var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove from the grid'; rm.addEventListener('click', function () { c.removeFromTrackingGrid(key); }); td.appendChild(rm); } function gridRowRemoveCell(td, wl) { var c = C(); var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove this list row (any assignments are kept)'; rm.addEventListener('click', function () { c.removeWorklistRow(wl.id); }); td.appendChild(rm); } // Build the seltable column array, dropping any the chooser has hidden. Each // column's `get` feeds sort + filter; `render` paints the cell. get/render // receive the unified row wrapper and dispatch on kind. function trackingColumns() { var hidden = (gridPrefs().hidden || {}); var defs = { status: { key: 'status', title: 'Status', cls: 'tg-status', filterable: false, get: function (r) { if (!isFile(r)) return 'awaiting'; var d = C().deriveTarget(r.file); return C().hasHashConflict(C().srcKeyForFile(r.file)) ? 'conflict' : (nameErrors(d).length ? 'incomplete' : 'ok'); }, render: function (r, td) { isFile(r) ? gridStatusCell(td, r.file) : gridPlaceholderStatus(td); } }, orig: { key: 'orig', title: 'Original / expected name', cls: 'tg-orig', get: function (r) { return isFile(r) ? joinName(r.file) : (r.wl.currentName || ''); }, render: function (r, td) { isFile(r) ? gridOrigCell(td, r.file) : gridExpectedCell(td, r.wl); } }, tn: { key: 'tn', title: 'Tracking number', cls: 'tg-tn', get: function (r) { return isFile(r) ? currentIdent(r.file).tracking : (r.wl.trackingNumber || ''); }, render: function (r, td) { isFile(r) ? gridEditCell(td, 'tn', r.file) : gridRowEditCell(td, 'tn', r.wl); } }, rev: { key: 'rev', title: 'Rev (status)', cls: 'tg-rev', get: function (r) { return isFile(r) ? currentIdent(r.file).rev : (r.wl.revisionCell || ''); }, render: function (r, td) { isFile(r) ? gridEditCell(td, 'rev', r.file) : gridRowEditCell(td, 'rev', r.wl); } }, title: { key: 'title', title: 'Title', cls: 'tg-title', get: function (r) { return isFile(r) ? currentIdent(r.file).title : (r.wl.title || ''); }, render: function (r, td) { isFile(r) ? gridEditCell(td, 'title', r.file) : gridRowEditCell(td, 'title', r.wl); } }, src: { key: 'src', title: 'Source', cls: 'worklist-src', sortable: false, get: function (r) { return isFile(r) ? '' : sourceStr(r.wl); }, render: function (r, td) { if (!isFile(r)) renderSource(r.wl, td); } }, latest: { key: 'latest', title: 'Latest rev', cls: 'tg-latest', get: function (r) { return isFile(r) ? '' : latestRevOf(r.wl.archiveRevisions); }, render: function (r, td) { td.textContent = isFile(r) ? '' : latestRevOf(r.wl.archiveRevisions); } }, x: { key: 'x', title: '', cls: 'tg-x', sortable: false, filterable: false, render: function (r, td) { isFile(r) ? gridRemoveCell(td, r.file) : gridRowRemoveCell(td, r.wl); } }, }; return GRID_COL_META.filter(function (m) { return !colHidden(m, hidden); }).map(function (m) { return defs[m.id]; }); } var trackingGrid = null, trackingColSig = ''; function colSig() { return trackingColumns().map(function (c) { return c.key; }).join(','); } function ensureTrackingGrid(container) { if (trackingGrid) return trackingGrid; var c = C(); trackingColSig = colSig(); trackingGrid = window.app.modules.seltable.create({ container: container, rows: gridRows, rowId: function (r) { return r.id; }, columns: trackingColumns(), persistKey: GRID_PREFS_KEY, // Drops are handled at the container level (setupGridDrop) so a // multi-file drop can fan out over several rows with a live indicator. }); trackingGrid.render(); return trackingGrid; } function renderTrackingGrid(container) { // Empty ↔ populated transition: tear the seltable down for the prompt, // re-create it (create-once) when rows arrive. if (!gridRows().length) { trackingGrid = null; container.textContent = ''; container.classList.remove('seltable'); container.appendChild(el('div', 'target-empty', 'No files yet — drag files in (or “⊕ Add filtered files”), or “⊞ Load…” / “⎘ Paste rows…” a list of tracking numbers and drop the matching files on. A file that’s already ZDDC-named fills in automatically.')); return; } // The Columns ▾ chooser changes which columns exist → rebuild on mismatch // (self-correcting, whichever way `hidden` was changed). if (trackingGrid && colSig() !== trackingColSig) trackingGrid = null; ensureTrackingGrid(container); // Mirror the name-filter box above the trees into the grid's global filter // (setFilter re-renders the body, so the rows are always fresh on render). trackingGrid.setFilter(rfTerms.join(' ')); } // The placeholder rows currently shown in the grid, in DISPLAY (DOM) order — // the same order the fill indicator highlights, so fill and preview agree. function visiblePlaceholderIds() { if (!els.trackingTree) return []; return Array.prototype.filter.call(els.trackingTree.querySelectorAll('.seltable__row'), function (r) { return r.dataset.id && r.dataset.id.indexOf('p:') === 0; }).map(function (r) { return r.dataset.id; }); } // Drop N dragged files onto a starting placeholder row → bind file[i] to the // i-th consecutive PLACEHOLDER row from there (Excel-style column fill). A // single file just binds to the one row. Worklist rows are looked up by id // (from the model), so a re-render between binds doesn't disturb the loop. function fillFromRow(startId, keys) { var c = C(), ids = visiblePlaceholderIds(), start = ids.indexOf(startId); if (start < 0) { var row0 = c.getWorklistRow(startId.slice(2)); if (row0) c.assignFromRow(keys, row0); return; } for (var i = 0; i < keys.length && (start + i) < ids.length; i++) { var wl = c.getWorklistRow(ids[start + i].slice(2)); if (wl) c.assignFromRow([keys[i]], wl); } } // Drag files onto the grid. Over a PLACEHOLDER row, N dragged files preview + // fill N consecutive placeholder rows (the fill indicator highlights exactly // those rows). Over empty space / a file row, they're added as new grid rows. function setupGridDrop(container) { function placeholderUnder(e) { var tr = e.target.closest && e.target.closest('.seltable__row'); return (tr && tr.dataset.id && tr.dataset.id.indexOf('p:') === 0) ? tr : null; } function clearFill() { Array.prototype.forEach.call(container.querySelectorAll('.tg-fill-target'), function (el) { el.classList.remove('tg-fill-target'); }); container.classList.remove('tg-drop-hover'); } container.addEventListener('dragover', function (e) { if (!window.app.modules.dnd.active()) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; clearFill(); var tr = placeholderUnder(e); if (tr) { var n = (window.app.modules.dnd.getDrag() || []).length || 1; var rows = Array.prototype.filter.call(container.querySelectorAll('.seltable__row'), function (r) { return r.dataset.id && r.dataset.id.indexOf('p:') === 0; }); var start = rows.indexOf(tr); for (var i = 0; i < n && (start + i) < rows.length; i++) rows[start + i].classList.add('tg-fill-target'); } else { container.classList.add('tg-drop-hover'); } }); container.addEventListener('dragleave', function (e) { if (e.target === container) clearFill(); }); container.addEventListener('drop', function (e) { e.preventDefault(); var tr = placeholderUnder(e); clearFill(); var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag(); if (!keys.length) return; if (tr) fillFromRow(tr.dataset.id, keys); else onGridDrop(keys); }); } function onGridDrop(keys) { var c = C(); c.addToTrackingGrid(keys); keys.forEach(function (k) { var f = fileByKey(k); if (!f) return; var p = window.zddc.parseFilename(f.originalFilename + (f.extension ? '.' + f.extension : '')); if (p && p.valid && p.trackingNumber) c.setFileIdentity(k, { tracking: p.trackingNumber, rev: p.revision + (p.status ? ' (' + p.status + ')' : ''), title: p.title || '' }); }); } // "Columns ▾" chooser — hide/show grid columns (status + ✕ always shown). function openColumnChooser() { var open = document.querySelector('.col-chooser'); if (open) { open.remove(); return; } var hidden = (gridPrefs().hidden || {}); var menu = el('div', 'col-chooser'); GRID_COL_META.forEach(function (col) { if (col.fixed) return; var lbl = el('label', 'col-chooser__item'); var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !colHidden(col, hidden); cb.addEventListener('change', function () { var p = gridPrefs(); p.hidden = p.hidden || {}; p.hidden[col.id] = !cb.checked; // explicit (overrides defaultHidden) saveGridPrefs(p); trackingGrid = null; // column set changed → rebuild the seltable render(); }); lbl.appendChild(cb); lbl.appendChild(document.createTextNode(' ' + col.title)); menu.appendChild(lbl); }); var r = els.trackingColsBtn.getBoundingClientRect(); menu.style.top = (r.bottom + 2) + 'px'; menu.style.left = r.left + 'px'; document.body.appendChild(menu); setTimeout(function () { function off(e) { if (!menu.contains(e.target) && e.target !== els.trackingColsBtn) { menu.remove(); document.removeEventListener('mousedown', off); } } document.addEventListener('mousedown', off); }, 0); } // ── By-transmittal: flat editable grid (one row per file), mirroring the // By-tracking grid. Each row's single text input is the file's full // transmittal folder path "//"; committing // it routes the file (classify.setTransmittalPath find-or-creates the // party/slot/bin). Drops are handled at the container level so a ⌘/Ctrl // drop can branch a new transmittal (setupTransmittalDrop). ────────────── function txPath(f) { return C().deriveTarget(f).outPath || ''; } function txStatusCell(td, f) { var ok = !!txPath(f); var badge = el('span', ok ? 'tfile__badge tfile__badge--ok' : 'tfile__badge tg-wanted', ok ? '✓' : '◇'); badge.title = ok ? ('Routed to ' + txPath(f)) : 'No transmittal folder yet — type one, or drop onto a routed row.'; td.appendChild(badge); } function txPathCell(td, f) { var c = C(), key = c.srcKeyForFile(f); editCell(td, 'tg-input', txPath(f), 'Acme/received/2026-06-18_Acme-TRN-0001 (IFC) - Title', function (v) { var err = c.setTransmittalPath([key], v); if (err) { window.zddc.toast('Transmittal not set — ' + err, 'warning'); render(); } }); } function txRemoveCell(td, f) { var c = C(), key = c.srcKeyForFile(f); var rm = el('button', 'tnode__act tg-x__btn', '✕'); rm.title = 'Remove from the transmittal grid'; rm.addEventListener('click', function () { c.removeFromTransmittalGrid(key); }); td.appendChild(rm); } function transmittalGridRows() { var out = []; C().transmittalGridKeys().forEach(function (k) { var f = fileByKey(k); if (f) out.push({ kind: 'file', file: f, id: 'f:' + k }); }); return out; } function transmittalColumns() { return [ { key: 'status', title: 'Status', cls: 'tg-status', filterable: false, get: function (r) { return txPath(r.file) ? 'ok' : 'awaiting'; }, render: function (r, td) { txStatusCell(td, r.file); } }, { key: 'orig', title: 'Original name', cls: 'tg-orig', get: function (r) { return joinName(r.file); }, render: function (r, td) { gridOrigCell(td, r.file); } }, { key: 'path', title: 'Transmittal folder', cls: 'tx-path', get: function (r) { return txPath(r.file); }, render: function (r, td) { txPathCell(td, r.file); } }, { key: 'x', title: '', cls: 'tg-x', sortable: false, filterable: false, render: function (r, td) { txRemoveCell(td, r.file); } }, ]; } var transmittalGrid = null; function ensureTransmittalGrid(container) { if (transmittalGrid) return transmittalGrid; transmittalGrid = window.app.modules.seltable.create({ container: container, rows: transmittalGridRows, rowId: function (r) { return r.id; }, columns: transmittalColumns(), persistKey: 'zddc.classifier.transmittalCols', }); transmittalGrid.render(); return transmittalGrid; } function renderTransmittalGrid(container) { if (!transmittalGridRows().length) { transmittalGrid = null; container.textContent = ''; container.classList.remove('seltable'); container.appendChild(el('div', 'target-empty', 'No files here yet — drag files in, then type each one’s transmittal folder ' + '(//). Drop onto a routed ' + 'row to put the file in that same folder; ⌘/Ctrl-drop to branch a new transmittal from it.')); return; } ensureTransmittalGrid(container); transmittalGrid.setFilter(rfTerms.join(' ')); } function setGridStatus(text) { var s = document.getElementById('scanStatus'); if (s) { s.textContent = text; s.classList.toggle('scanning', !!text); } } function setGridStatus(text) { var s = document.getElementById('scanStatus'); if (s) { s.textContent = text; s.classList.toggle('scanning', !!text); } } // "⊕ Add filtered files" — pull every file the LEFT tree filter currently // shows into the grid (across collapsed folders too); already-ZDDC-named files // fill in automatically (onGridDrop does the parse). function addFilteredFiles() { var tree = window.app.modules.tree; var files = (tree && tree.filteredFiles) ? tree.filteredFiles() : []; if (!files.length) { window.zddc.toast('No files to add — the tree filter shows none.', 'info'); return; } onGridDrop(files.map(function (f) { return C().srcKeyForFile(f); })); window.zddc.toast('Added ' + files.length + ' file' + (files.length === 1 ? '' : 's') + ' to the grid.', 'success'); } // "Rename…" — rename the grid's NAME-COMPLETE files ON DISK, in place. // DESTRUCTIVE: no backup. A renamed file is now correctly named, so it leaves // the grid (forgetFile). Resumable: already-correct files are skipped. async function renameInPlace() { var c = C(); var ready = renameableFiles(); var items = ready.map(function (f) { return { file: f, oldKey: c.srcKeyForFile(f), newName: c.deriveTarget(f).filename }; }).filter(function (x) { return x.newName && x.newName !== joinName(x.file); }); if (!items.length) { window.zddc.toast(ready.length ? 'Those files are already correctly named.' : 'No name-complete files to rename — fill in tracking number, revision and title.', 'info'); return; } var preview = items.slice(0, 4).map(function (x) { return ' ' + joinName(x.file) + ' → ' + x.newName; }).join('\n'); var msg = '⚠ RENAME ' + items.length + ' FILE' + (items.length === 1 ? '' : 'S') + ' IN PLACE — this EDITS YOUR SOURCE FILES on disk.\n\n' + 'There is NO backup and it cannot be undone. Renamed files are now correctly named, so they leave the grid.\n\n' + preview + (items.length > 4 ? ('\n …and ' + (items.length - 4) + ' more') : '') + '\n\nRename these files in place now?'; if (!confirm(msg)) return; setGridStatus('Renaming…'); var done = 0, errors = 0; for (var i = 0; i < items.length; i++) { setGridStatus('Renaming… ' + (i + 1) + '/' + items.length + ' — ' + items[i].newName); try { await window.app.modules.rename.renameTo(items[i].file, items[i].newName); c.forgetFile(items[i].oldKey); done++; } catch (e) { errors++; window.zddc.toast('Rename failed for ' + items[i].newName + ' — ' + (e.message || e), 'error'); } } setGridStatus(''); render(); reRenderSource(); window.zddc.toast('Renamed ' + done + ' file' + (done === 1 ? '' : 's') + ' in place' + (errors ? (', ' + errors + ' failed (retry)') : '') + '. Source files updated.', errors ? 'warning' : 'success'); } // An editable seltable cell: an that commits on change. `warn` is an // optional tooltip that flags (without blocking) a questionable value. function editCell(td, cls, value, placeholder, onCommit, warn) { var inp = document.createElement('input'); inp.type = 'text'; inp.className = cls + (warn ? ' is-warn' : ''); inp.value = value || ''; inp.placeholder = placeholder || ''; inp.spellcheck = false; inp.setAttribute('data-no-select', ''); if (warn) inp.title = warn; inp.addEventListener('change', function () { onCommit(inp.value.trim()); }); td.appendChild(inp); } function tnWarn(r) { var tn = (r.trackingNumber || '').trim(); if (!tn) return ''; var n = tn.split('-').length, want = C().getTrackingFields().length; return n < want - 1 || n > want ? ('Has ' + n + ' segments; the pattern expects ' + want + '.') : ''; } function renderSource(row, td) { var s = row.source || {}; if (s.mdl) td.appendChild(el('span', 'src-badge src-badge--mdl', 'MDL')); if (s.archive) td.appendChild(el('span', 'src-badge src-badge--arch', 'arch')); if (s.pasted && !s.mdl && !s.archive) { // A pasted number matching nothing known: a likely typo / a brand-new number. var isNew = listScanned; var b = el('span', 'src-badge src-badge--new', isNew ? 'new' : 'unverified'); b.title = isNew ? 'This tracking number isn’t in the scanned archive/MDL — you’re inventing it.' : 'Not checked against the archive/MDL — Load a directory to verify.'; td.appendChild(b); } else if (s.pasted) { td.appendChild(el('span', 'src-badge src-badge--pasted', 'pasted')); } } // "From a list" loader: "Load…" opens a multi-select directory tree (scoped // to the served context); every ticked directory is walked recursively into // the union of existing files + MDL deliverables, deduped by tracking number // to one row at the latest revision. Writes/alters nothing — the revision // cell is classifier-local and starts blank. function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; } // The newest combined " ()" string in a set, by revision token. function latestRevOf(revs) { var best = null, bestTok = null; (revs || []).forEach(function (r) { var tok = String(r).replace(/\s*\([^)]*\)\s*$/, '').trim(); // "A (IFR)" → "A" if (best == null || window.zddc.compareRevisions(tok, bestTok) > 0) { best = r; bestTok = tok; } }); return best || ''; } // Where is the classifier served? Decides the directory-tree roots. // 'local' → offline (file://), pick a folder. // 'all' → standalone /_apps/classifier.html, root at every accessible project. // {one:p} → under /…, root at just that project. function detectScope(pathname, hasSource, protocol) { if (!hasSource || protocol === 'file:') return 'local'; if (/^\/_apps\//.test(pathname || '')) return 'all'; var seg = (String(pathname || '').split('/').filter(Boolean)[0]) || ''; return seg ? { one: seg } : 'all'; } async function buildRoots() { var src = window.zddc && window.zddc.source; var scope = detectScope(location.pathname, !!src, location.protocol); if (scope === 'local') { if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local folder needs the File System Access API (Chromium).', 'error'); return null; } try { var dir = await window.showDirectoryPicker({ mode: 'read' }); return [{ label: dir.name || 'Selected folder', handle: dir }]; } catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return null; } } function archiveOf(rel) { if (rel.charAt(rel.length - 1) !== '/') rel += '/'; return new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive'); } if (scope === 'all') { var projects = await window.app.modules.copy.fetchAccessProjects(); if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return null; } if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return null; } return projects.map(function (p) { return { label: (p.title ? p.name + ' — ' + p.title : p.name), handle: archiveOf(p.url || ('/' + p.name + '/')) }; }); } return [{ label: scope.one, handle: archiveOf('/' + scope.one + '/') }]; } async function loadWorklist() { var roots = await buildRoots(); if (!roots) return; var picked = await window.app.modules.dirPicker.pick(roots); if (!picked || !picked.length) return; var byTn = Object.create(null); function ensure(tn) { return byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); } window.zddc.toast('Scanning selected directories…', 'info', { durationMs: 4000 }); try { for (var i = 0; i < picked.length; i++) await walkDirInto(picked[i], ensure); } catch (e) { window.zddc.toast('Reading the directories failed — ' + (e.message || e), 'error'); return; } var rows = Object.keys(byTn).map(function (tn) { var x = byTn[tn]; return { id: tn, party: x.party, trackingNumber: tn, title: x.title, inMdl: x.inMdl, archiveRevisions: Object.keys(x.revs).sort(), revisionCell: '' }; }); finishLoad(rows); } // Walk a ticked directory recursively. A dir named "mdl" (or the ticked dir // itself being an mdl folder) yields *.yaml deliverables → inMdl + title; // every other ZDDC-named file is an archive revision of its tracking number. async function walkDirInto(dirH, ensure) { var party = (dirH.name && String(dirH.name).replace(/\/$/, '')) || ''; if (party === 'mdl') return readMdlYamls(dirH, ensure); for await (var entry of dirH.values()) { var nm = String(entry.name).replace(/\/$/, ''); if (entry.kind === 'directory') { if (nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk') continue; var child = entry.getDirectoryHandle ? entry : await dirH.getDirectoryHandle(nm); if (nm === 'mdl') await readMdlYamls(child, ensure); else await walkDirInto(child, ensure); } else { var p = window.zddc.parseFilename(nm); if (p && p.valid && p.trackingNumber) { var row = ensure(p.trackingNumber); if (!row.title) row.title = p.title || ''; if (!row.party) row.party = party; row.revs[(p.revision + (p.status ? ' (' + p.status + ')' : '')).trim()] = true; } } } } async function readMdlYamls(mdlH, ensure) { for await (var ye of mdlH.values()) { var ynm = String(ye.name).replace(/\/$/, ''); if (ye.kind !== 'file' || !isRowYaml(ynm)) continue; var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ } var row = ensure(ynm.replace(/\.yaml$/i, '')); row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title; } } function finishLoad(rows) { listScanned = true; C().appendWorklist(rows); // APPEND — the list accumulates across batches showTab('tracking'); window.zddc.toast(rows.length ? ('Added ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.') : 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning'); } // ── paste + match dialogs (reuse the .copy-choice modal shell) ────────── function scratchModal(titleText, hintText) { var done = false; function close() { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); } function onKey(e) { if (e.key === 'Escape') close(); } var back = el('div', 'copy-choice__backdrop'); var box = el('div', 'copy-choice copy-choice--wide'); box.appendChild(el('h3', null, titleText)); if (hintText) box.appendChild(el('p', null, hintText)); var body = el('div', 'scratch-modal__body'); box.appendChild(body); var foot = el('div', 'copy-choice__btns'); box.appendChild(foot); back.appendChild(box); // Close on a genuine backdrop click only — not when a drag that began in // the paste textarea (selecting text) ends out on the backdrop. var pressedBackdrop = false; back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); }); back.addEventListener('click', function (e) { if (e.target === back && pressedBackdrop) close(); }); document.addEventListener('keydown', onKey); document.body.appendChild(back); return { body: body, foot: foot, close: close }; } function unassignedFiles() { var c = C(); return allFiles().filter(function (f) { var a = c.getAssignment(c.srcKeyForFile(f)); return !(a && (a.trackingNodeId || a.excluded)); }); } // Assign every exact, unambiguous (1:1) current-name match without prompting; // returns the count. Lower-confidence / ambiguous matches are left for the // user to review via "Match names". function autoAssignByName() { var c = C(), n = 0; c.proposeMatches(unassignedFiles(), c.getWorklist(), {}).forEach(function (p) { if (p.auto) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; } }); return n; } function openPasteDialog(prefill) { var c = C(); var m = scratchModal('Paste rows from Excel', 'Fixed columns, tab-separated as Excel copies: Tracking number · Rev (Status) · Title · Current name. A header row is skipped. Current name accepts a bare filename (matched against your files — exact name matches are assigned automatically) OR a full path from “⬆ Export list” (binds that exact file directly on paste).'); var ta = document.createElement('textarea'); ta.className = 'scratch-paste__ta'; ta.rows = 6; ta.spellcheck = false; ta.placeholder = 'ACME-AR-DWG-0001\tA (IFR)\tFloor plan\tIMG_4471.pdf'; ta.value = prefill || ''; m.body.appendChild(ta); var preview = el('div', 'scratch-paste__preview'); m.body.appendChild(preview); var add = el('button', 'btn btn-primary', 'Add rows'); add.disabled = true; var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close); m.foot.appendChild(add); m.foot.appendChild(cancel); var parsed = { rows: [], skipped: [] }; function refresh() { parsed = c.parsePastedRows(ta.value); preview.textContent = ''; if (parsed.rows.length) { var tbl = el('table', 'scratch-preview__table'); var head = el('tr'); ['Tracking number', 'Revision', 'Title', 'Current name'].forEach(function (h) { head.appendChild(el('th', null, h)); }); tbl.appendChild(head); parsed.rows.slice(0, 50).forEach(function (r) { var tr = el('tr'); tr.appendChild(el('td', null, r.trackingNumber)); tr.appendChild(el('td', null, r.revisionCell || '')); tr.appendChild(el('td', null, r.title || '')); tr.appendChild(el('td', null, r.currentName || '')); tbl.appendChild(tr); }); preview.appendChild(tbl); if (parsed.rows.length > 50) preview.appendChild(el('div', 'scratch-preview__more', '…and ' + (parsed.rows.length - 50) + ' more')); } parsed.skipped.forEach(function (s) { preview.appendChild(el('div', 'scratch-preview__skip', 'Line ' + s.line + ' skipped — ' + s.reason)); }); add.disabled = !parsed.rows.length; add.textContent = parsed.rows.length ? ('Add ' + parsed.rows.length + ' row' + (parsed.rows.length === 1 ? '' : 's')) : 'Add rows'; } add.addEventListener('click', function () { var n = parsed.rows.length; c.appendWorklist(parsed.rows); m.close(); showTab('tracking'); var assigned = autoAssignByName(); var msg = 'Added ' + n + ' pasted row' + (n === 1 ? '' : 's') + '.'; if (assigned) msg += ' Auto-assigned ' + assigned + ' file' + (assigned === 1 ? '' : 's') + ' by current name.'; window.zddc.toast(msg + (assigned ? ' Review the rest with ⚡ Match names.' : ''), 'success'); }); ta.addEventListener('input', refresh); refresh(); ta.focus(); } function openMatchDialog() { var c = C(); var rows = c.getWorklist(); if (!rows.length) { window.zddc.toast('Load or paste some tracking numbers first.', 'warning'); return; } var files = unassignedFiles(); if (!files.length) { window.zddc.toast('No unassigned files to match.', 'info'); return; } var m = scratchModal('Match names', 'Each unassigned file matched to a row by its “Current name” (or the tracking number in its filename). Exact matches are pre-checked; review the rest, then Assign.'); var opts = { fuzzy: false }; var fuzzyLbl = el('label', 'scratch-match__fuzzy'); var fuzzy = document.createElement('input'); fuzzy.type = 'checkbox'; fuzzyLbl.appendChild(fuzzy); fuzzyLbl.appendChild(document.createTextNode(' Looser matching (digits only)')); m.body.appendChild(fuzzyLbl); var list = el('div', 'scratch-match__list'); m.body.appendChild(list); var accept = el('button', 'btn btn-primary', 'Assign'); var cancel = el('button', 'btn btn-secondary', 'Cancel'); cancel.addEventListener('click', m.close); m.foot.appendChild(accept); m.foot.appendChild(cancel); var proposals = []; function refresh() { proposals = c.proposeMatches(files, rows, opts); list.textContent = ''; if (!proposals.length) { list.appendChild(el('div', 'scratch-preview__skip', 'No matches found.')); accept.disabled = true; accept.textContent = 'Assign'; return; } proposals.forEach(function (p, i) { var rowEl = el('label', 'scratch-match__row' + (p.auto ? '' : ' scratch-match__row--review')); var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!p.auto; // pre-check only exact 1:1 matches; opt in to the rest cb.dataset.i = i; rowEl.appendChild(cb); rowEl.appendChild(el('span', 'scratch-match__file', window.zddc.joinExtension(p.file.originalFilename, p.file.extension))); rowEl.appendChild(el('span', 'scratch-match__arrow', '→')); rowEl.appendChild(el('span', 'scratch-match__tn', p.row.trackingNumber)); var tag = el('span', 'scratch-match__conf', Math.round(p.confidence * 100) + '% · ' + (p.via === 'name' ? 'name' : 'tracking#')); rowEl.appendChild(tag); list.appendChild(rowEl); }); accept.disabled = false; accept.textContent = 'Assign checked'; } accept.addEventListener('click', function () { var n = 0; Array.prototype.forEach.call(list.querySelectorAll('input[type=checkbox]'), function (cb) { if (!cb.checked) return; var p = proposals[Number(cb.dataset.i)]; if (p) { c.assignFromRow([c.srcKeyForFile(p.file)], p.row); n++; } }); m.close(); showTab('tracking'); window.zddc.toast('Assigned ' + n + ' file' + (n === 1 ? '' : 's') + ' by name match.', n ? 'success' : 'info'); }); fuzzy.addEventListener('change', function () { opts.fuzzy = fuzzy.checked; refresh(); }); refresh(); } // ── events ───────────────────────────────────────────────────────────── function fileByKey(key) { var files = allFiles(); for (var i = 0; i < files.length; i++) { if (C().srcKeyForFile(files[i]) === key) return files[i]; } return null; } // ── By-transmittal drops ───────────────────────────────────────────────── // Handled at the container level (not seltable's per-row onRowDrop) so the // drop event's modifier key is available: // plain drop on a routed row → the dropped files JOIN that row's folder. // ⌘/Ctrl drop on a routed row → prompt, prefilled with that folder's path, // so the user edits it into a NEW transmittal the files go to (the // original folder is untouched — find-or-create dedups an unedited path). // drop on empty space / an unrouted row → just add the files as grid rows. function setupTransmittalDrop(container) { function rowUnder(e) { var tr = e.target.closest && e.target.closest('.seltable__row'); return (tr && container.contains(tr)) ? tr : null; } function clearHover() { Array.prototype.forEach.call(container.querySelectorAll('.drop-hover'), function (n) { n.classList.remove('drop-hover'); }); container.classList.remove('tg-drop-hover'); } container.addEventListener('dragover', function (e) { if (!window.app.modules.dnd.active()) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; clearHover(); var tr = rowUnder(e); if (tr) tr.classList.add('drop-hover'); else container.classList.add('tg-drop-hover'); }); container.addEventListener('dragleave', function (e) { if (e.target === container) clearHover(); }); container.addEventListener('drop', function (e) { e.preventDefault(); var tr = rowUnder(e), meta = e.metaKey || e.ctrlKey; clearHover(); var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag(); if (!keys.length) return; onTransmittalDrop(keys, tr ? tr.dataset.id : null, meta); }); } function onTransmittalDrop(keys, rowId, meta) { var c = C(), targetPath = ''; if (rowId && rowId.indexOf('f:') === 0) { var tf = fileByKey(rowId.slice(2)); if (tf) targetPath = c.deriveTarget(tf).outPath || ''; } if (targetPath) { if (meta) { var edited = prompt('New transmittal folder — edit to branch a copy, or keep to join:', targetPath); if (edited == null) return; // cancelled var err = c.setTransmittalPath(keys, edited.trim()); if (err) window.zddc.toast('Could not route — ' + err, 'warning'); } else { c.setTransmittalPath(keys, targetPath); // join the same folder } return; } c.addToTransmittalGrid(keys); // empty space / unrouted row → add blank rows to fill } // Reveal a source key's placement in the target pane (source → target). function reveal(key) { var a = C().getAssignment(key); if (!a) return; // Both tabs are per-file grids whose rows are keyed "f:". if (a.trackingNodeId) { showTab('tracking'); render(); flashNode(els.trackingTree, 'f:' + key); } else if (a.transmittalNodeId) { showTab('transmittal'); render(); flashNode(els.transmittalTree, 'f:' + key); } } function flashNode(container, id) { var node = container.querySelector('[data-id="' + id + '"]'); if (!node) return; node.scrollIntoView({ block: 'center' }); var row = node.querySelector('.tnode__row') || node; row.classList.add('reveal-flash'); setTimeout(function () { row.classList.remove('reveal-flash'); }, 1500); } window.app.modules.targetTree = { init: init, render: render, showTab: showTab, activeAxis: activeAxis, setNameFilter: setNameFilter, reveal: reveal, // test seams (pure) _detectScope: detectScope, _latestRevOf: latestRevOf, _walkDirInto: walkDirInto, }; })();