@ -1402,7 +1402,7 @@ body.is-elevated::after {
/* Folder Item */
.folder-item {
display: flex;
align-items: center;
align-items: flex-start; /* toggle/icon sit on the name line; count drops below */
padding: 0.5rem;
cursor: pointer;
border-radius: var(--radius);
@ -1487,8 +1487,15 @@ body.is-elevated::after {
color: var(--text-muted);
}
.folder-name {
/* Name + count stacked vertically (count below the name, not right-aligned). */
.folder-namebox {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.folder-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -1497,7 +1504,6 @@ body.is-elevated::after {
.folder-count {
font-size: 11px;
color: var(--text-muted);
margin-left: 0.5rem;
}
.folder-children {
@ -1919,7 +1925,15 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.trev__inner .tcell__name { color: var(--primary); }
.tcell__preview { text-decoration: none; cursor: pointer; }
.tcell__preview:hover { text-decoration: underline; }
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; }
/* The hover-only node controls must NOT reserve column width (they're invisible
normally). Float them over the right of the cell instead of leaving them in
flow — .tcell__inner is sticky, so it's the positioning context — so each
column sizes to its value alone. */
.ttable .tnode__actions {
position: absolute; right: 0.25rem; top: 50%; transform: translateY(-50%);
margin-left: 0; padding-left: 0.25rem; background: var(--bg); pointer-events: none;
}
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; pointer-events: auto; }
.ttable .drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
.ttable__file { padding: 0.1rem 0.4rem; }
.ttable__drop { color: var(--text-muted); font-style: italic; font-size: 0.75rem; }
@ -1936,6 +1950,36 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
.tfile__badge--ok { color: var(--success, #16a34a); }
.tfile__badge--err { color: var(--danger); }
/* ── By-tracking flat editable grid (one row per file) ──────────────────── */
.ttable--grid { width: auto; }
.ttable--grid td.tg-td { padding: 0.1rem 0.35rem; vertical-align: middle; }
.ttable--grid th.tg-th { white-space: nowrap; } /* .column-resizer (spreadsheet.css) sits in the sticky th */
.tg-input {
width: 100%; min-width: 4rem; box-sizing: border-box;
padding: 0.12rem 0.3rem; border: 1px solid transparent; border-radius: var(--radius);
background: transparent; color: var(--text); font: inherit; font-size: 0.8rem;
}
.tg-input:hover { border-color: var(--border); }
.tg-input:focus { border-color: var(--primary); background: var(--bg); outline: none; }
.tg-tn .tg-input { font-family: var(--mono, monospace); }
.tg-input.is-warn { border-color: var(--warning, #b8860b); }
.tg-orig__link { color: var(--text-muted); white-space: nowrap; text-decoration: none; cursor: pointer; }
.tg-orig__link:hover { text-decoration: underline; }
.tg-status, .tg-x { text-align: center; }
.tg-x__btn { opacity: 0.5; }
.tg-row:hover .tg-x__btn { opacity: 1; }
.tg-row--err .tg-status { color: var(--danger); }
.tg-drop-hover { outline: 2px dashed var(--primary); outline-offset: -3px; background: var(--primary-light); }
/* "Columns ▾" chooser menu */
.col-chooser {
position: fixed; z-index: 9600; background: var(--bg);
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 6px 18px rgba(0,0,0,0.18); padding: 0.3rem; min-width: 11rem;
}
.col-chooser__item { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.4rem; font-size: 0.83rem; cursor: pointer; border-radius: var(--radius); }
.col-chooser__item:hover { background: var(--bg-hover); }
/**
* Spreadsheet Styles
* Table, cells, editing, and row states
@ -2421,7 +2465,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
< / svg >
< div class = "header-title-group" >
< span class = "app-header__title" > ZDDC Classifier< / span >
< span class = "build-timestamp" > < span style = "color:red;font-weight:bold" > v0.0.27-beta · 2026-06-15 14:57:53 · 8473ed3 < / span > < / span >
< span class = "build-timestamp" > < span style = "color:red;font-weight:bold" > v0.0.27-beta · 2026-06-16 04:53:19 · 2b32ace < / span > < / span >
< / div >
< button id = "addDirectoryBtn" class = "btn btn-primary" > Use Local Directory< / button >
< button id = "refreshHeaderBtn" class = "btn btn-secondary hidden" title = "Refresh and rescan directory" aria-label = "Refresh" style = "font-size:1.1rem;" > ⟳< / button >
@ -2579,11 +2623,11 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
< div class = "target-body" >
< section id = "trackingPanel" class = "target-panel" >
< div class = "target-panel__toolbar" >
< button id = " addTrackingRootBtn" class = "btn btn-sm btn-secondary" > + Root folder < / button >
< span class = "target-hint" > Folders join with “-” into the tracking number; the leaf folder is the revision — name it like “A (IFR)” .< / span >
< button id = " trackingColsBtn" class = "btn btn-sm btn-secondary" title = "Show or hide columns" > Columns ▾ < / button >
< span class = "target-hint" > Drag files in, then type each one’ s tracking number, revision (e.g. “A (IFR)”), and title. A file that’ s already ZDDC-named fills in automatically. Columns are hideable + resizable .< / span >
< / div >
< input type = "search" id = "trackingFilterInput" class = "tree-filter target-filter" spellcheck = "false"
placeholder="Filter the tracking tree…" aria-label="Filter tracking tree ">
placeholder="Filter the grid…" aria-label="Filter the tracking grid ">
< div id = "trackingTree" class = "target-tree" > < / div >
< / section >
< section id = "transmittalPanel" class = "target-panel" hidden >
@ -7474,6 +7518,7 @@ X.B(E,Y);return E}return J}())
outputName: null, // remembered output directory display name
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
worklist: [], // "From a list" scratch rows: [ { id, trackingNumber, title, revisionCell, source, archiveRevisions } ]
trackingWorkset: Object.create(null), // srcKeys shown as rows in the By-tracking grid (set: key->true)
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
@ -7834,6 +7879,7 @@ X.B(E,Y);return E}return J}())
worklist: state.worklist.map(function (r) {
return { id: r.id, party: r.party, trackingNumber: r.trackingNumber, title: r.title, revisionCell: r.revisionCell, source: r.source, archiveRevisions: r.archiveRevisions };
}),
trackingWorkset: Object.keys(state.trackingWorkset),
};
}
function load(obj) {
@ -7844,6 +7890,8 @@ X.B(E,Y);return E}return J}())
state.outputName = obj.outputName || null;
state.config = normalizeConfig(obj.config);
state.worklist = (Array.isArray(obj.worklist) ? obj.worklist : []).map(normalizeRow);
state.trackingWorkset = Object.create(null);
(Array.isArray(obj.trackingWorkset) ? obj.trackingWorkset : []).forEach(function (k) { state.trackingWorkset[k] = true; });
rebuildIndex();
migrateLegacyMdl(obj.worklist); // BEFORE anything can prune; materializes old mdl placements
notify();
@ -7875,10 +7923,57 @@ X.B(E,Y);return E}return J}())
function reset() {
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
state.outputName = null;
state.trackingWorkset = Object.create(null);
rebuildIndex();
notify();
}
// ── By-tracking grid (one editable row per file) ─────────────────────────
// The grid is a flat presentation over the tracking-tree placement model.
// `trackingWorkset` tracks files put on the grid so a dropped file shows as a
// row before it has a tracking number; a file with a real tracking placement
// (named here OR via the "From a list" tab) is always a row too.
function addToTrackingGrid(keys) {
var changed = false;
(keys || []).forEach(function (k) { if (!state.trackingWorkset[k]) { state.trackingWorkset[k] = true; changed = true; } });
if (changed) notify();
}
function removeFromTrackingGrid(key) {
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
delete state.trackingWorkset[key];
place([key], null, 'tracking');
if (old) pruneEmptyTrackingChain(old);
notify();
}
function trackingGridKeys() {
var set = Object.create(null);
Object.keys(state.trackingWorkset).forEach(function (k) { set[k] = true; });
Object.keys(state.assignments).forEach(function (k) { if (state.assignments[k].trackingNodeId) set[k] = true; });
return Object.keys(set);
}
// Re-materialize a file's tracking placement from a full identity. The caller
// passes ALL three fields (current values for the ones it didn't edit), read
// from deriveTarget — so this module needs no file objects. A blank revision
// lands on the PENDING_REV placeholder leaf (incomplete until set); a blank
// tracking number clears the placement (the row stays, unfilled).
function setFileIdentity(key, ident) {
ident = ident || {};
var tracking = (ident.tracking == null ? '' : String(ident.tracking)).trim();
var rev = (ident.rev == null ? '' : String(ident.rev)).trim();
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
if (tracking) {
var leaf = addTrackingPath(null, parseFolderLevels(tracking + '_' + (rev || PENDING_REV)));
place([key], leaf, 'tracking');
if (old & & old !== leaf) pruneEmptyTrackingChain(old);
} else {
place([key], null, 'tracking');
if (old) pruneEmptyTrackingChain(old);
}
setTitleOverride(key, ident.title || '');
state.trackingWorkset[key] = true;
notify();
}
// ── pattern config ───────────────────────────────────────────────────────
function normalizeConfig(c) {
var d = defaultConfig();
@ -8302,6 +8397,9 @@ X.B(E,Y);return E}return J}())
transmittalRecord: transmittalRecord,
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
// By-tracking grid
addToTrackingGrid: addToTrackingGrid, removeFromTrackingGrid: removeFromTrackingGrid,
trackingGridKeys: trackingGridKeys, setFileIdentity: setFileIdentity,
setWorklist: setWorklist, appendWorklist: appendWorklist, clearWorklist: clearWorklist,
getWorklist: getWorklist, getWorklistRow: getWorklistRow,
assignFromRow: assignFromRow, unassignRowFile: unassignRowFile,
@ -10308,18 +10406,24 @@ X.B(E,Y);return E}return J}())
if (agg === 'excluded') item.classList.add('excluded');
}
// Folder name
// Name + counts stacked vertically: the count badge sits BELOW the name
// rather than right-aligned on the row.
const namebox = document.createElement('div');
namebox.className = 'folder-namebox';
const name = document.createElement('span');
name.className = 'folder-name';
name.textContent = folder.name;
item.appendChild(name);
namebox .appendChild(name);
// Subfolder / file counts (immediate). Greyed via the row's .scanning
// class until the subtree is fully scanned.
const count = document.createElement('span');
count.className = 'folder-count';
populateCount(count, folder);
item.appendChild(count);
namebox.appendChild(count);
item.appendChild(namebox);
// Extract button for ZIP roots
if (folder.isZipRoot) {
@ -10975,7 +11079,9 @@ X.B(E,Y);return E}return J}())
btns.appendChild(go); btns.appendChild(cancel);
box.appendChild(h); box.appendChild(p); box.appendChild(treeWrap); box.appendChild(btns);
back.appendChild(box);
back.addEventListener('click', function (e) { if (e.target === back) finish([]); });
var pressedBackdrop = false;
back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); });
back.addEventListener('click', function (e) { if (e.target === back & & pressedBackdrop) finish([]); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
@ -11092,7 +11198,7 @@ X.B(E,Y);return E}return J}())
matchNamesBtn: document.getElementById('matchNamesBtn'),
clearListBtn: document.getElementById('clearListBtn'),
hideAssignedToggle: document.getElementById('hideAssignedToggle'),
addTrackingRootBtn: document.getElementById('addTrackingRoot Btn'),
trackingColsBtn: document.getElementById('trackingCols Btn'),
addPartyBtn: document.getElementById('addPartyBtn'),
stats: document.getElementById('classifyStats'),
};
@ -11127,22 +11233,16 @@ X.B(E,Y);return E}return J}())
var text = t ? t.getData('text') : '';
if (text) { e.preventDefault(); openPasteDialog(text); }
});
els.addTrackingRootBtn.addEventListener('click', function () {
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
+ 'Brace patterns expand: BMB-{PM,EL}-{0001-0002,0005}_A (IFR)', '');
addFoldersFromPattern(null, name);
});
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.trackingTree.addEventListener('click', onTrackingClick);
els.transmittalTree.addEventListener('click', onTransmittalClick);
els.trackingTree.addEventListener('change', onFileNameChange);
els.transmittalTree.addEventListener('change', onFileNameChange);
setupDropZone (els.trackingTree, 'tracking' );
setupGrid Drop(els.trackingTree);
setupDropZone(els.transmittalTree, 'transmittal');
C().on(render);
@ -11200,28 +11300,13 @@ X.B(E,Y);return E}return J}())
function activeAxis() { return currentTab === 'transmittal' ? 'transmittal' : 'tracking'; }
function reRenderSource() { if (window.app.modules.tree & & window.app.modules.tree.render) window.app.modules.tree.render(); }
// Expand a brace pattern into folder names and create them (confirming a
// multi-create first). parentId null = root folders. See expandFolderPattern.
function addFoldersFromPattern(parentId, raw) {
if (!raw || !raw.trim()) return;
var names = C().expandFolderPattern(raw);
if (!names.length) return;
if (names.length > 1) {
var shown = names.slice(0, 8).join('\n');
if (names.length > 8) shown += '\n…and ' + (names.length - 8) + ' more';
if (!confirm('Create ' + names.length + ' folders?\n\n' + shown)) return;
}
// Each expanded name is parsed into nested tracking levels (split on
// "-", final "_" splits the leaf rev), reusing shared ancestors.
names.forEach(function (nm) { C().addTrackingPath(parentId, C().parseFolderLevels(nm)); });
}
// ── render ───────────────────────────────────────────────────────────────
function render() {
if (!initialized || !C().isEnabled()) return;
var files = allFiles();
var placed = buildPlaced(files);
renderTrackingInto(els.trackingTree, C().getTrackingTree(), placed.tracking );
renderTrackingGrid(els.trackingTree);
renderTransmittalInto(els.transmittalTree, C().getTransmittalTree(), placed.transmittal);
renderWorklist(placed.byTracking);
renderStats(files);
@ -11307,165 +11392,173 @@ X.B(E,Y);return E}return J}())
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
}
// ── By-tracking: merged-cell table ──────────────────────────────────────
// The positional hierarchy reads left-to-right as columns (one per configured
// field), ancestor cells span their descendants' rows, and the revision (the
// leaf) gets its own aligned column. Each placed file is a row.
// ── By-tracking: flat editable grid (one row per file) ──────────────────
var GRID_COLS = [
{ id: 'status', title: '', cls: 'tg-status', fixed: true },
{ id: 'orig', title: 'Original name', cls: 'tg-orig' },
{ id: 'tn', title: 'Tracking number', cls: 'tg-tn' },
{ id: 'rev', title: 'Rev (status)', cls: 'tg-rev' },
{ id: 'title', title: 'Title', cls: 'tg-title' },
{ id: 'x', title: '', cls: 'tg-x', fixed: true },
];
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 node is a revision leaf when its name ends in a "(STATUS)" we recognise —
// tracking field codes never carry a parenthesised status, so this cleanly
// separates "0001" (a SEQ field) from "A (IFR)" (a revision).
function revStatusOf(name) {
var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || '');
return (m & & window.zddc.isValidStatus(m[1])) ? m[1] : null;
}
function isRevisionLeaf(node) {
return !(node.children || []).length & & revStatusOf(node.name) != null;
}
// Flatten the tree into rows: { path:[fieldNodes], rev:revNode|null, file }.
function buildTrackingRows(nodes, placedMap) {
var rows = [];
function emit(path, rev, files) {
var fs = (files & & files.length) ? files : [null];
fs.forEach(function (f) { rows.push({ path: path, rev: rev, file: f }); });
}
function walk(node, ancestors) {
var placed = placedMap[node.id] || [];
if (isRevisionLeaf(node)) { emit(ancestors, node, placed); return; }
var myPath = ancestors.concat(node); // node is a tracking field segment
if (placed.length) emit(myPath, null, placed); // files dropped on a partial number
var kids = node.children || [];
if (kids.length) kids.forEach(function (c) { walk(c, myPath); });
else if (!placed.length) emit(myPath, null, []); // empty leaf = drop target
}
nodes.forEach(function (n) { walk(n, []); });
return rows;
}
function rowMatches(row) {
if (!rfActive()) return true;
if (row.file & & fileRowMatches(row.file)) return true;
if (row.rev & & rfHit(row.rev.name)) return true;
for (var i = 0; i < row.path.length ; i + + ) { if ( rfHit ( row . path [ i ] . name ) ) return true ; }
return false;
}
function fieldCellContent(node) {
var inner = el('div', 'tcell__inner');
inner.appendChild(el('span', 'tcell__name', node.name));
inner.appendChild(nodeActions([
{ act: 'add', label: '+ ', title: 'Add child segment / revision' },
{ act: 'rename', label: '✎', title: 'Rename' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
return inner;
}
function revCellContent(node, placedMap) {
var inner = el('div', 'tcell__inner trev__inner');
// The revision name doubles as a preview link for its placed file (the
// common case is one file per revision). No count bubble.
var files = placedMap[node.id] || [];
if (files.length) {
var link = el('a', 'tcell__name tcell__preview', node.name);
link.href = '#';
link.dataset.previewKey = C().srcKeyForFile(files[0]);
link.title = 'Preview ' + files[0].originalFilename + (files[0].extension ? '.' + files[0].extension : '');
inner.appendChild(link);
} else {
inner.appendChild(el('span', 'tcell__name', node.name));
}
inner.appendChild(nodeActions([
{ act: 'rename', label: '✎', title: 'Rename revision' },
{ act: 'del', label: '🗑', title: 'Delete' },
]));
return inner;
}
// A placed-file cell: editable ZDDC name + validation badge; the original
// filename is on hover, not shown inline. Reuses .tfile/.tfile__name so the
// delegated preview + name-edit handlers apply.
function fileCellContent(f) {
// 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);
var conflict = C().hasHashConflict(d.key); // same name, different bytes
var bad = d.errors.length || conflict;
var row = el('div', 'tfile' + (bad ? ' tfile--err' : ''));
row.dataset.key = d.key;
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
var name = el('input', 'tfile__name' + (bad ? ' tfile__name--err' : ''));
name.type = 'text';
name.value = d.filename || '';
name.placeholder = '(incomplete)';
name.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content — fix before copying · ' : '')
+ (d.errors.length ? d.errors.join('; ') + ' · ' : '') + 'original: ' + orig;
row.appendChild(name);
row.appendChild(el('span', 'tfile__badge' + (bad ? ' tfile__badge--err' : ' tfile__badge--ok'),
conflict ? '≠' : (d.errors.length ? '⚠' : '✓')));
return row;
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; }); }
function renderTrackingInto(container, nodes, placedMap) {
function renderTrackingGrid(container) {
container.textContent = '';
if (!nodes.length) {
container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.'));
var c = C();
var files = c.trackingGridKeys().map(fileByKey).filter(Boolean)
.filter(function (f) { return !rfActive() || fileRowMatches(f); });
if (!files.length) {
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches.'
: 'No files yet — drag files here from the left, then type each one’ s tracking number, revision, and title. A file that’ s already ZDDC-named fills in automatically.'));
return;
}
var rows = buildTrackingRows(nodes, placedMap).filter(rowMatches);
if (!rows.length) {
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches in the tracking tree.' : 'No tracking numbers yet.'));
return;
}
var fields = C().getTrackingFields();
var maxPath = rows.reduce(function (m, r) { return Math.max(m, r.path.length); }, 0);
var nCols = Math.max(fields.length, maxPath);
var prefs = gridPrefs(), hidden = prefs.hidden || {}, widths = prefs.widths || {};
var cols = GRID_COLS.filter(function (col) { return !hidden[col.id]; });
function cellId(row, col) {
if (col < nCols ) { var n = row.path[col]; return n ? n . id : null ; }
return row.rev ? row.rev.id : null; // col === nCols → revision
}
// Rowspan run starting at row i for column col (0 = covered from above).
function spanAt(col, i) {
var id = cellId(rows[i], col);
if (id == null) return 1;
if (i > 0 & & cellId(rows[i - 1], col) === id) return 0;
var span = 1;
for (var j = i + 1; j < rows.length ; j + + ) { if ( cellId ( rows [ j ] , col ) = = = id ) span + + ; else break ; }
return span;
}
var table = el('table', 'ttable');
var table = el('table', 'ttable ttable--grid');
var thead = el('thead'), htr = el('tr');
for (var c = 0; c < nCols ; c + + ) {
htr.appendChild(el('th', 'ttable__fh', fields[c] ? fields[c].name + (fields[c].optional ? ' ?' : '') : '·'));
}
htr.appendChild(el('th', 'ttable__rh', 'REVISION'));
htr.appendChild(el('th', 'ttable__fileh', 'Files'));
cols.forEach(function (col) {
var th = el('th', 'tg-th ' + col.cls, col.title);
th.dataset.col = col.id;
if (widths[col.id]) { th.style.width = th.style.minWidth = th.style.maxWidth = widths[col.id] + 'px'; }
htr.appendChild(th);
});
thead.appendChild(htr); table.appendChild(thead);
var tbody = el('tbody');
rows.forEach(function (row, i) {
var tr = el('tr');
for (var col = 0; col < nCols ; col + + ) {
var span = spanAt(col, i);
if (span === 0) continue; // merged from the row above
var node = row.path[col] || null;
var td = el('td', 'ttable__cell' + (node ? '' : ' ttable__cell--empty'));
if (span > 1) td.rowSpan = span;
if (node) { td.dataset.id = node.id; td.appendChild(fieldCellContent(node)); }
files.forEach(function (f) {
var key = c.srcKeyForFile(f), d = c.deriveTarget(f);
var bad = nameErrors(d).length || c.hasHashConflict(key);
var tr = el('tr', 'tg-row' + (bad ? ' tg-row--err' : ''));
tr.dataset.key = key;
cols.forEach(function (col) {
var td = el('td', 'tg-td ' + col.cls);
buildGridCell(col.id, td, f, key);
tr.appendChild(td);
}
var rspan = spanAt(nCols, i);
if (rspan !== 0) {
var rtd = el('td', 'ttable__rev' + (row.rev ? '' : ' ttable__cell--empty'));
if (rspan > 1) rtd.rowSpan = rspan;
if (row.rev) { rtd.dataset.id = row.rev.id; rtd.appendChild(revCellContent(row.rev, placedMap)); }
tr.appendChild(rtd);
}
var ftd = el('td', 'ttable__file');
if (row.file) ftd.appendChild(fileCellContent(row.file));
else ftd.appendChild(el('span', 'ttable__drop', 'drop a file here'));
tr.appendChild(ftd);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
if (window.app.modules.resize & & window.app.modules.resize.init) window.app.modules.resize.init(table, persistColWidths);
}
function buildGridCell(colId, td, f, key) {
var c = C(), d = c.deriveTarget(f), conflict = c.hasHashConflict(key);
if (colId === 'status') {
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); return;
}
if (colId === 'orig') {
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
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); return;
}
if (colId === 'x') {
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); return;
}
// editable: tn / rev / title
var 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) : '';
var inp = el('input', 'tg-input' + (warn ? ' is-warn' : ''));
inp.type = 'text'; inp.value = value || ''; inp.placeholder = ph; inp.spellcheck = false;
if (warn) inp.title = warn;
inp.addEventListener('change', function () {
var cur = currentIdent(f); // re-read so a prior edit isn't clobbered
if (colId === 'tn') cur.tracking = inp.value.trim();
else if (colId === 'rev') cur.rev = inp.value.trim();
else cur.title = inp.value;
c.setFileIdentity(key, cur);
});
td.appendChild(inp);
}
function persistColWidths(table) {
var p = gridPrefs(); p.widths = p.widths || {};
Array.prototype.forEach.call(table.querySelectorAll('thead th[data-col]'), function (th) { p.widths[th.dataset.col] = Math.round(th.offsetWidth); });
saveGridPrefs(p);
}
// Drag files onto the grid → add as rows; auto-fill any already ZDDC-named.
function setupGridDrop(container) {
container.addEventListener('dragover', function (e) {
if (!window.app.modules.dnd.active()) return;
e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; container.classList.add('tg-drop-hover');
});
container.addEventListener('dragleave', function (e) { if (e.target === container) container.classList.remove('tg-drop-hover'); });
container.addEventListener('drop', function (e) {
container.classList.remove('tg-drop-hover');
e.preventDefault();
var keys = window.app.modules.dnd.getDrag(); window.app.modules.dnd.clearDrag();
if (keys.length) 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_COLS.forEach(function (col) {
if (col.fixed) return;
var lbl = el('label', 'col-chooser__item');
var cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !hidden[col.id];
cb.addEventListener('change', function () {
var p = gridPrefs(); p.hidden = p.hidden || {};
if (cb.checked) delete p.hidden[col.id]; else p.hidden[col.id] = true;
saveGridPrefs(p); 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
@ -11775,7 +11868,11 @@ X.B(E,Y);return E}return J}())
var body = el('div', 'scratch-modal__body'); box.appendChild(body);
var foot = el('div', 'copy-choice__btns'); box.appendChild(foot);
back.appendChild(box);
back.addEventListener('click', function (e) { if (e.target === back) close(); });
// 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 };
@ -11946,40 +12043,6 @@ X.B(E,Y);return E}return J}())
if (parsed.title != null) C().setTitleOverride(tf.dataset.key, parsed.title);
// place/setTitleOverride fire classify.notify → re-render.
}
// Collapse/expand a node and its whole subtree (ctrl/cmd-click a toggle).
function setSubtreeCollapsed(nodeId, collapse) {
var node = C().getNode(nodeId);
if (!node) return;
(function walk(n) {
if ((n.children || []).length) { if (collapse) collapsed[n.id] = true; else delete collapsed[n.id]; }
(n.children || []).forEach(walk);
})(node);
}
function onTrackingClick(e) {
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
if (!btn) return;
var act = btn.dataset.act;
var id = closestNodeId(btn);
if (act === 'toggle') {
var collapse = !collapsed[id];
if (e.ctrlKey || e.metaKey) setSubtreeCollapsed(id, collapse);
else if (collapse) collapsed[id] = true; else delete collapsed[id];
render();
return;
}
if (act === 'add') {
var name = prompt('Child folder name (next tracking segment, or a leaf revision like "A (IFR)").\n'
+ 'Brace patterns expand: {PM,EL,EM}-MOM-{0001-0002,0005}_A (IFR)', '');
addFoldersFromPattern(id, name);
} else if (act === 'rename') {
var node = C().getNode(id);
var nn = prompt('Rename folder:', node ? node.name : '');
if (nn & & nn.trim()) C().renameNode(id, nn.trim());
} else if (act === 'del') {
if (confirm('Delete this folder and everything under it? Files placed here become unassigned.')) C().deleteNode(id);
}
}
function onTransmittalClick(e) {
if (previewFromTarget(e)) return;
var btn = e.target.closest('[data-act]');
@ -12071,25 +12134,10 @@ X.B(E,Y);return E}return J}())
var keys = window.app.modules.dnd.getDrag();
window.app.modules.dnd.clearDrag();
if (!keys.length) return;
if (axis === 'tracking') placeTrackingDrop(keys, t.id);
else C().place(keys, t.id, axis);
C().place(keys, t.id, axis);
});
}
// Tracking drop: if the target is already a complete leaf, assign directly;
// otherwise prompt for the remaining levels (parsed + nested under it) so a
// file can be dropped on an existing partial tracking number and completed.
function placeTrackingDrop(keys, nodeId) {
if (C().trackingNodeComplete(nodeId)) { C().place(keys, nodeId, 'tracking'); return; }
var label = C().trackingPathLabel(nodeId);
var input = prompt('Dropping under "' + label + '".\n'
+ 'Add the remaining tracking levels (e.g. "0001_0 (IFU)"), or leave blank to drop here:', '');
if (input === null) return; // cancelled
var levels = C().parseFolderLevels(input.trim());
var target = levels.length ? C().addTrackingPath(nodeId, levels) : nodeId;
C().place(keys, target, 'tracking');
}
// Reveal a source key's placement in the target pane (source → target).
function reveal(key) {
var a = C().getAssignment(key);
@ -12453,7 +12501,9 @@ X.B(E,Y);return E}return J}())
row.appendChild(go); row.appendChild(cancel);
box.appendChild(h); box.appendChild(p); box.appendChild(sel); box.appendChild(row);
back.appendChild(box);
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
var pressedBackdrop = false;
back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); });
back.addEventListener('click', function (e) { if (e.target === back & & pressedBackdrop) finish(null); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
});
@ -12486,7 +12536,9 @@ X.B(E,Y);return E}return J}())
row.appendChild(btn('Cancel', 'btn-secondary', null));
box.appendChild(h); box.appendChild(p); box.appendChild(row);
back.appendChild(box);
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
var pressedBackdrop = false;
back.addEventListener('mousedown', function (e) { pressedBackdrop = (e.target === back); });
back.addEventListener('click', function (e) { if (e.target === back & & pressedBackdrop) finish(null); });
document.addEventListener('keydown', onKey);
document.body.appendChild(back);
});
@ -14804,32 +14856,39 @@ X.B(E,Y);return E}return J}())
let resizingColumn = null;
let startX = 0;
let startWidth = 0;
let activeTable = null;
let activeOnResize = null;
/**
* Initialize column resizing
* Initialize column resizing on a table. Defaults to the rename-in-place
* spreadsheet when no table is passed (back-compatible). onResize(table) is
* called after each drag ends, so a caller can persist the new widths.
*/
function init() {
const table = window.app.dom.spreadsheet;
function init(table, onResize) {
table = table || (window.app.dom & & window.app.dom.spreadsheet);
if (!table) return;
const headers = table.querySelectorAll('thead th');
headers.forEach(th => {
// Skip if resize handle already exists
if (th.querySelector('.column-resizer')) return;
// Add resize handle
const resizer = document.createElement('div');
resizer.className = 'column-resizer';
th.appendChild(resizer);
// Mouse down on resizer
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
resizingColumn = th;
startX = e.pageX;
startWidth = th.offsetWidth;
activeTable = table;
activeOnResize = onResize || null;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
@ -14857,6 +14916,8 @@ X.B(E,Y);return E}return J}())
resizingColumn = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (activeOnResize & & activeTable) { try { activeOnResize(activeTable); } catch (_) { /* ignore */ } }
activeTable = null; activeOnResize = null;
}
// Export module