ZDDC/classifier/js/target-tree.js
ZDDC 9ca24eb3f1 feat(classifier): By-transmittal is a per-file grid with a single folder-path input
Standardize the two classify tabs: By-transmittal drops the party→slot→bin
tree (and its multi-field "+ Transmittal" form) for a flat per-file grid that
mirrors By-tracking. Each file gets ONE editable text input — its full
transmittal folder path "<party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) -
Title>". Committing it find-or-creates the party/slot/bin; structure stays
derived, never stored.

Drag-and-drop (from the source tree onto the grid):
- plain drop on a routed row → the dropped files JOIN that row's folder;
- ⌘/Ctrl-drop on a routed row → a prompt prefilled with that folder's path
  lets you edit it into a NEW transmittal the files go to (the original is
  untouched; an unedited path dedups via find-or-create);
- drop on empty space / an unrouted row → files are added as blank rows to fill.

Model (classify.js): adds a `transmittalWorkset` (parallel to trackingWorkset)
plus addToTransmittalGrid / removeFromTransmittalGrid / transmittalGridKeys and
setTransmittalPath(keys, path) — the single parser for "<party>/<slot>/<folder>"
that also prunes any bin a re-route empties. app.js importPaths now reuses
setTransmittalPath for its route axis (one parser, less duplication).

Removes the now-dead tree rendering/CRUD (party/bin nodes, binForm, the bin
filename editor, the bin drop zone). Tests updated to the grid model: tab
render shows the folder-path input; drop join/branch/empty; edit re-routes and
prunes the emptied folder; ✕ removes. 71/71 classify specs pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:03:42 -05:00

943 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
* "<party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>" (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 thats 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 "<party>/<received|issued>/<folder>"; 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 ones transmittal folder '
+ '(<party>/<received|issued>/<YYYY-MM-DD_TN (STATUS) - Title>). 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 <input> 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 isnt in the scanned archive/MDL — youre 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 "<rev> (<status>)" 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 <project>/…, 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:<srcKey>".
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,
};
})();