ZDDC/classifier/js/target-tree.js
ZDDC 054cf2d79b feat(classifier): multi-select source files + drag to fill a contiguous block of rows
A contiguous run of drawings on the left commonly maps to a contiguous set of
rows on the right, so make that a single gesture.

- Left tree: ctrl/cmd-click toggles a source file into a multi-selection,
  shift-click ranges from the anchor, ctrl-shift-click adds a range (over the
  visible file order). Selected files get a highlight and drag together, in
  top-to-bottom order. A plain click still previews.
- By-tracking grid: dropping N dragged files onto a placeholder row fills the N
  consecutive placeholder rows from there (file[i] → row[i], Excel-style column
  fill). Drops are now handled at the grid-container level so the dragover shows
  a live indicator outlining EXACTLY the rows that will be updated
  (.tg-fill-target). Dropping over empty space / a file row still just adds the
  files as new rows. Fill walks the rows in DOM (display) order and looks each
  worklist row up by id from the model, so a re-render between binds can't
  disturb the loop.

Test: a 3-file drop on the first of three placeholders fills m1/m2/m3 in order
and consumes all three placeholders. 70 classify green.

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

1120 lines
61 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).
*
* Renders the two orthogonal target trees the user maps files onto:
* - "By tracking number": folders that join with "-" into the tracking
* number; the leaf folder ("A (IFR)") is the revision+status.
* - "By transmittal": <party>/{received,issued}/<transmittal folder>.
*
* Structure here, placements in classify.js. Drag-and-drop assignment is wired
* in source-dnd.js / phase 3; this module owns rendering + folder/bin CRUD and
* shows the derived filename for each placed file.
*/
(function () {
'use strict';
var SLOTS = ['received', 'issued'];
var els = {};
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
var openForm = null; // { partyId, slot } when a bin form is open
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'),
addPartyBtn: document.getElementById('addPartyBtn'),
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);
els.addPartyBtn.addEventListener('click', function () {
var name = prompt('Party name (also the transmittal-number prefix):', '');
if (name && name.trim()) C().addParty(name.trim());
});
els.transmittalTree.addEventListener('click', onTransmittalClick);
els.transmittalTree.addEventListener('change', onFileNameChange);
setupGridDrop(els.trackingTree);
setupDropZone(els.transmittalTree, 'transmittal');
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;
}
// One pass: group files by the node they're placed in, per axis.
function buildPlaced(files) {
var c = C(), byT = {}, byX = {}, byTn = {};
files.forEach(function (f) {
var a = c.getAssignment(c.srcKeyForFile(f));
if (!a) return;
if (a.trackingNodeId) {
(byT[a.trackingNodeId] = byT[a.trackingNodeId] || []).push(f);
// Also index by tracking NUMBER so a "From a list" row can show
// the files placed under it (a row is a tracking number, not a node).
var tn = c.deriveTarget(f).tracking;
if (tn) (byTn[tn] = byTn[tn] || []).push(f);
}
if (a.transmittalNodeId) (byX[a.transmittalNodeId] = byX[a.transmittalNodeId] || []).push(f);
});
return { tracking: byT, transmittal: byX, byTracking: byTn };
}
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();
var placed = buildPlaced(files);
renderTrackingGrid(els.trackingTree);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
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;
}
function nodeActions(extra) {
var wrap = el('span', 'tnode__actions');
(extra || []).forEach(function (a) {
var b = el('button', 'tnode__act', a.label);
b.dataset.act = a.act;
b.title = a.title || '';
wrap.appendChild(b);
});
return wrap;
}
// Placed files inside a transmittal bin. Each row is draggable (drag onto
// another bin to MOVE it) and carries an ✕ to remove it from the transmittal.
function fileList(files) {
var box = el('div', 'tnode__files');
files.forEach(function (f) {
var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
row.dataset.key = d.key;
row.draggable = true;
row.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''));
orig.title = 'Drag to another transmittal to move · click to preview';
row.appendChild(orig);
row.appendChild(el('span', 'tfile__arrow', '→'));
// Editable derived filename — edit it to re-file the item.
var name = el('input', 'tfile__name' + (d.errors.length ? ' tfile__name--err' : ''));
name.type = 'text';
name.value = d.filename || '';
name.placeholder = '(incomplete)';
name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item';
row.appendChild(name);
var rm = el('button', 'tnode__act tfile__remove', '✕');
rm.dataset.act = 'untransmit';
rm.title = 'Remove from this transmittal';
row.appendChild(rm);
box.appendChild(row);
});
return box;
}
// ── name filter (the autofilter box above the target trees) ────────────
var rfTerms = [];
function setNameFilter(q) {
rfTerms = String(q || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
render();
}
function rfActive() { return rfTerms.length > 0; }
function rfHit(text) {
if (!rfTerms.length) return true;
var t = String(text || '').toLowerCase();
for (var i = 0; i < rfTerms.length; i++) { if (t.indexOf(rfTerms[i]) === -1) return false; }
return true;
}
// A placed-file row matches on its original name or its derived ZDDC name.
function fileRowMatches(f) {
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
}
// ── 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);
}
// Transmittal tree
function renderTransmittalInto(container, parties, placedMap) {
container.textContent = '';
if (!parties.length) {
container.appendChild(el('div', 'target-empty', 'No parties yet — “+ Party” to start.'));
return;
}
parties.forEach(function (p) { var e = partyNode(p, placedMap); if (e) container.appendChild(e); });
if (rfActive() && !container.children.length) {
container.appendChild(el('div', 'target-empty', 'No matches in the transmittal tree.'));
}
}
function partyNode(party, placedMap) {
var partyMatch = rfHit(party.name);
var slotEls = [], anyBin = false;
SLOTS.forEach(function (slot) {
var slotNode = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
var sw = el('div', 'tslot');
sw.dataset.party = party.id;
sw.dataset.slot = slot;
var sr = el('div', 'tslot__row');
sr.appendChild(el('span', 'tslot__name', slot));
var addBtn = el('button', 'tnode__act', '+ Transmittal');
addBtn.dataset.act = 'addbin';
sr.appendChild(addBtn);
sw.appendChild(sr);
if (openForm && openForm.partyId === party.id && openForm.slot === slot) {
sw.appendChild(binForm(party.id, slot));
}
(slotNode ? slotNode.children : []).forEach(function (bin) {
var be = binNode(bin, placedMap, partyMatch);
if (be) { sw.appendChild(be); anyBin = true; }
});
slotEls.push(sw);
});
if (rfActive() && !partyMatch && !anyBin) return null;
var wrap = el('div', 'tnode tnode--party');
wrap.dataset.id = party.id;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__icon', '🏢'));
row.appendChild(el('span', 'tnode__name', party.name));
row.appendChild(nodeActions([
{ act: 'rename-party', label: '✎', title: 'Rename party' },
{ act: 'del-party', label: '🗑', title: 'Delete party' },
]));
wrap.appendChild(row);
slotEls.forEach(function (sw) { wrap.appendChild(sw); });
return wrap;
}
function binNode(bin, placedMap, ancMatched) {
var matched = ancMatched || rfHit(bin.name || '');
var placed = placedMap[bin.id] || [];
var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed;
if (rfActive() && !matched && !shownFiles.length) return null;
var wrap = el('div', 'tnode tnode--bin');
wrap.dataset.id = bin.id;
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([
{ act: 'rename-bin', label: '✎', title: 'Rename transmittal' },
{ act: 'del', label: '🗑', title: 'Delete transmittal' },
]));
wrap.appendChild(row);
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
return wrap;
}
var STATUSES = ['---', 'IFA', 'IFB', 'IFC', 'IFD', 'IFI', 'IFP', 'IFR', 'IFU', 'REC', 'RSA', 'RSB', 'RSC', 'RSD', 'RSI', 'TBD'];
function binForm(partyId, slot) {
var form = el('div', 'binform');
form.dataset.party = partyId;
form.dataset.slot = slot;
var date = el('input', 'binform__date'); date.type = 'date';
try { date.value = new Date().toISOString().slice(0, 10); } catch (_) { /* ok */ }
var type = document.createElement('select'); type.className = 'binform__type';
['TRN', 'SUB'].forEach(function (t) { var o = el('option', null, t); o.value = t; type.appendChild(o); });
var seq = el('input', 'binform__seq'); seq.type = 'text'; seq.placeholder = 'seq (e.g. 0007)';
var status = document.createElement('select'); status.className = 'binform__status';
STATUSES.forEach(function (s) { var o = el('option', null, s); o.value = s; status.appendChild(o); });
var title = el('input', 'binform__title'); title.type = 'text'; title.placeholder = 'title (optional)';
var add = el('button', 'btn btn-sm btn-primary', 'Add'); add.dataset.act = 'binadd';
var cancel = el('button', 'btn btn-sm btn-secondary', 'Cancel'); cancel.dataset.act = 'bincancel';
[date, type, seq, status, title, add, cancel].forEach(function (n) { form.appendChild(n); });
return form;
}
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 closestNodeId(target) {
var n = target.closest('[data-id]');
return n ? n.dataset.id : null;
}
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;
}
// Click a placed-file row (anywhere but its editable name) → preview it.
function previewFromTarget(e) {
// Preview link on a revision cell (its placed file).
var pl = e.target.closest('[data-preview-key]');
if (pl) {
e.preventDefault();
var pf = fileByKey(pl.dataset.previewKey);
if (pf && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(pf);
}
return true;
}
if (e.target.closest('[data-act]')) return false; // action button — not a preview
if (e.target.closest('.tfile__name')) return false;
var tf = e.target.closest('.tfile');
if (!tf || !tf.dataset.key) return false;
var f = fileByKey(tf.dataset.key);
if (f && window.app.modules.preview && window.app.modules.preview.previewFile) {
window.app.modules.preview.previewFile(f);
}
return true;
}
// Edited a placed-file's ZDDC filename → re-derive its tracking placement
// (creating the folder path if needed) + its title override.
function onFileNameChange(e) {
var input = e.target.closest('.tfile__name');
if (input) commitFilenameEdit(input);
}
function commitFilenameEdit(input) {
var tf = input.closest('.tfile');
if (!tf || !tf.dataset.key) return;
var parsed = window.zddc.parseFilename((input.value || '').trim());
if (!parsed || !parsed.valid) {
window.zddc.toast('Not a valid ZDDC filename — expected "TRACKING_REV (STATUS) - Title.ext".', 'warning');
render(); // restore the derived value
return;
}
var stem = parsed.trackingNumber + '_' + parsed.revision + ' (' + parsed.status + ')';
var leaf = C().addTrackingPath(null, C().parseFolderLevels(stem));
C().place([tf.dataset.key], leaf, 'tracking');
if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title);
// place/setTitleOverride fire classify.notify → re-render.
}
function onTransmittalClick(e) {
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
if (act === 'addbin') {
var slotEl = btn.closest('.tslot');
openForm = { partyId: slotEl.dataset.party, slot: slotEl.dataset.slot };
render();
return;
}
if (act === 'untransmit') {
var tf = btn.closest('.tfile');
if (tf && tf.dataset.key) C().place([tf.dataset.key], null, 'transmittal');
return;
}
if (act === 'rename-bin') {
var bid = closestNodeId(btn);
var bn = C().getNode(bid);
var nn = prompt('Rename transmittal (this becomes its folder name):', bn ? bn.name : '');
if (nn && nn.trim()) C().renameNode(bid, nn.trim());
return;
}
if (act === 'bincancel') { openForm = null; render(); return; }
if (act === 'binadd') {
var form = btn.closest('.binform');
var meta = {
date: form.querySelector('.binform__date').value,
type: form.querySelector('.binform__type').value,
seq: form.querySelector('.binform__seq').value.trim(),
status: form.querySelector('.binform__status').value,
title: form.querySelector('.binform__title').value.trim(),
};
if (!meta.date || !meta.seq) { window.zddc.toast('Transmittal needs at least a date and a sequence number.', 'warning'); return; }
C().addTransmittalBin(form.dataset.party, form.dataset.slot, meta);
openForm = null; // render() fires from classify.notify()
return;
}
var id = closestNodeId(btn);
if (act === 'rename-party') {
var node = C().getNode(id);
var nn = prompt('Rename party (re-derives its transmittal numbers):', node ? node.name : '');
if (nn && nn.trim()) C().renameNode(id, nn.trim());
} else if (act === 'del-party') {
if (confirm('Delete this party and all its transmittals? Files placed there become unassigned.')) C().deleteNode(id);
} else if (act === 'del') {
if (confirm('Delete this transmittal? Files placed here become unassigned.')) C().deleteNode(id);
}
}
// ── drop targets ───────────────────────────────────────────────────────
// Resolve the drop target under an event:
// tracking → any folder node (.tnode)
// transmittal → a transmittal bin only (.tnode--bin)
function dropTarget(target, axis) {
if (axis === 'transmittal') {
var bin = target.closest('.tnode--bin');
if (!bin || !bin.dataset.id) return null;
return { id: bin.dataset.id, row: bin.querySelector('.tnode__row') || bin };
}
var cell = target.closest('.ttable__cell[data-id], .ttable__rev[data-id]');
if (!cell) return null;
return { id: cell.dataset.id, row: cell };
}
function clearHover(container) {
var hot = container.querySelectorAll('.drop-hover');
for (var i = 0; i < hot.length; i++) hot[i].classList.remove('drop-hover');
}
function setupDropZone(container, axis) {
container.addEventListener('dragover', function (e) {
if (!window.app.modules.dnd.active()) return;
var t = dropTarget(e.target, axis);
clearHover(container);
if (!t) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
t.row.classList.add('drop-hover');
});
container.addEventListener('dragleave', function (e) {
if (e.target === container) clearHover(container);
});
container.addEventListener('drop', function (e) {
var t = dropTarget(e.target, axis);
clearHover(container);
if (!t) return;
e.preventDefault();
var keys = window.app.modules.dnd.getDrag();
window.app.modules.dnd.clearDrag();
if (!keys.length) return;
C().place(keys, t.id, axis);
});
}
// Reveal a source key's placement in the target pane (source → target).
function reveal(key) {
var a = C().getAssignment(key);
if (!a) return;
if (a.trackingNodeId) {
showTab('tracking'); collapsed = {}; render();
flashNode(els.trackingTree, a.trackingNodeId);
} else if (a.transmittalNodeId) {
showTab('transmittal'); render();
flashNode(els.transmittalTree, a.transmittalNodeId);
}
}
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,
};
})();