ZDDC/classifier/js/classify.js
ZDDC 2b32aced6d feat(classifier): By Tracking Number is now a flat editable grid (one row per file)
Replace the merged-cell positional table (one column per tracking-number segment,
hierarchy via shared ancestors, built by creating folders) with a plain editable
spreadsheet: one row per file, with the tracking number, the rev (status), and
the title as three separate editable columns. Columns are hideable + resizable.

The storage model is unchanged — a file's tracking identity is still its
placement in the tracking-folder tree. The grid is a flat presentation + inline-
edit layer over it; editing a cell re-materializes the placement via the existing
path (addTrackingPath → place(…,'tracking') → setTitleOverride), generalized to
per-field.

- classify.js: `trackingWorkset` (serialized) so a dropped file is a row before
  it has a number; `addToTrackingGrid`/`removeFromTrackingGrid`/`trackingGridKeys`
  (union with files that have a tracking placement — incl. ones named via "From a
  list"); `setFileIdentity(key, {tracking, rev, title})` re-files + prunes the old
  leaf; blank tracking = an unfilled row, blank rev = a PENDING_REV leaf.
- target-tree.js: `renderTrackingGrid` (Status badge · Original name preview ·
  Tracking number · Rev (status) · Title · ✕); drag onto the grid adds rows and
  auto-fills any file whose own name already parses as ZDDC; a "Columns ▾" chooser
  + drag-resize (resize.js, now parameterized) persisted to localStorage. The
  status badge validates the NAME only (the transmittal is a different tab).
  Removed the merged-cell machinery + per-node CRUD (+ Root folder, ✎/🗑, brace
  expansion) and the now-dead drop-on-node path.
- template/css: tracking toolbar → Columns chooser + hint; flat-grid + chooser CSS.

Tests: replaced the merged-cell/+Root-folder/drop-on-leaf/filename-edit tests with
grid tests (render, drop+auto-fill, per-cell re-file, filter, hide/persist,
preview link). Suite 342 green.

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

961 lines
48 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 — "Classify & Copy" state model.
*
* The non-destructive workflow: the source directory is read-only; the user
* maps each source file onto two orthogonal target trees, and a later copy
* step writes renamed copies into a separate output directory.
*
* - Tracking tab (→ filename), POSITIONAL:
* tracking number = the file's ancestor folder names joined with '-'
* revision (+status) = its immediate parent folder, named "REV (STATUS)"
* title = derived from the original filename
* → TRACKING_REV (STATUS) - TITLE.ext
* - Transmittal tab (→ output path):
* <party>/{issued,received}/<YYYY-MM-DD_TN (STATUS) - TITLE>/
*
* This module is the single source of truth: placements live in `assignments`
* keyed by source-relative path (so they survive a re-pick); the trees define
* structure only. All target values are DERIVED, never stored.
*/
(function () {
'use strict';
// ── unique ids ───────────────────────────────────────────────────────────
var _idSeq = 0;
function uid() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
try { return window.crypto.randomUUID(); } catch (_) { /* non-secure ctx */ }
}
return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36);
}
// Per-workspace tracking-number PATTERN config. Drives the By-tracking
// table columns + (later) revision-modifier menus. Editable by the user.
var DEFAULT_FIELDS = [
{ name: 'ORIG', optional: false },
{ name: 'PHASE', optional: false },
{ name: 'PROJECT', optional: false },
{ name: 'AREA', optional: false },
{ name: 'DISC', optional: false },
{ name: 'TYPE', optional: false },
{ name: 'SEQ', optional: false },
{ name: 'SUFFIX', optional: true },
];
var DEFAULT_STATUSES = (window.zddc && window.zddc.STATUSES) ? window.zddc.STATUSES.slice() : ['---'];
var DEFAULT_MODIFIERS = ['B', 'C', 'N', 'Q'];
function defaultConfig() {
return {
trackingFields: DEFAULT_FIELDS.map(function (f) { return { name: f.name, optional: !!f.optional }; }),
statuses: DEFAULT_STATUSES.slice(),
modifiers: DEFAULT_MODIFIERS.slice(),
};
}
// ── state ────────────────────────────────────────────────────────────────
var state = {
enabled: false, // classify mode on/off
assignments: {}, // srcKey -> { trackingNodeId, transmittalNodeId, excluded, titleOverride }
trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children)
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
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 }
var nodeIndex = {};
// Transient (not serialized): srcKeys flagged by the copy audit as a
// same-name/different-content conflict. Cleared whenever a placement changes.
var hashConflicts = {};
function setHashConflicts(map) { hashConflicts = map || {}; notify(); }
function hasHashConflict(key) { return !!hashConflicts[key]; }
function clearHashConflicts() { hashConflicts = {}; }
// ── pub/sub ──────────────────────────────────────────────────────────────
var listeners = [];
function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; }
var notifyScheduled = false;
function notify() {
// Coalesce bursts (a group-drop touches many keys) into one render.
// Listeners include the target/source re-renders AND the workspace
// autosave (workspace.js subscribes) — persistence is not this
// module's concern.
if (notifyScheduled) return;
notifyScheduled = true;
Promise.resolve().then(function () {
notifyScheduled = false;
for (var i = 0; i < listeners.length; i++) {
try { listeners[i](); } catch (e) { console.error('classify listener', e); }
}
});
}
// ── source keys + title derivation ───────────────────────────────────────
function stripRoot(p) {
var i = (p || '').indexOf('/');
return i < 0 ? '' : p.slice(i + 1);
}
// Stable key for a file: its path relative to the picked root (root segment
// dropped), so re-picking the same directory re-attaches the same map.
function srcKeyForFile(file) {
var rel = stripRoot(file.folderPath || '');
var fn = zddc.joinExtension(file.originalFilename, file.extension);
return rel ? rel + '/' + fn : fn;
}
// Default title: if the original name already parses as ZDDC, reuse its
// title; otherwise the cleaned stem (originalFilename is the stem already).
function defaultTitle(file) {
var full = zddc.joinExtension(file.originalFilename, file.extension);
var parsed = zddc.parseFilename(full);
if (parsed && parsed.valid && parsed.title) return parsed.title;
return (file.originalFilename || '').trim();
}
// Parse a leaf folder label "A (IFR)" → { revision, status }. No parens →
// the whole label is the revision and status is blank.
var LEAF_RE = /^(.*?)\s*\(([^)]+)\)\s*$/;
function parseLeafLabel(name) {
var m = (name || '').match(LEAF_RE);
if (m) return { revision: m[1].trim(), status: m[2].trim() };
return { revision: (name || '').trim(), status: '' };
}
// ── assignments ──────────────────────────────────────────────────────────
function assignmentFor(key) {
var a = state.assignments[key];
if (!a) {
a = { trackingNodeId: null, transmittalNodeId: null, excluded: false, titleOverride: null };
state.assignments[key] = a;
}
return a;
}
// Read-only: returns the existing entry or null (no side effects).
function getAssignment(key) { return state.assignments[key] || null; }
function cleanAssignment(key) {
var a = state.assignments[key];
if (a && !a.trackingNodeId && !a.transmittalNodeId && !a.excluded && !a.titleOverride) {
delete state.assignments[key];
}
}
// Place keys onto a node along one axis ('tracking' | 'transmittal').
// nodeId null clears that axis. (The "From a list" tab also produces
// 'tracking' placements — see assignFromRow.)
function place(keys, nodeId, axis) {
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
keys.forEach(function (k) {
var a = assignmentFor(k);
a[field] = nodeId || null;
a.excluded = false; // placing un-excludes
cleanAssignment(k);
});
clearHashConflicts(); // a placement changed → stale conflict flags
notify();
}
function setExcluded(keys, excluded) {
keys.forEach(function (k) {
var a = assignmentFor(k);
a.excluded = !!excluded;
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
cleanAssignment(k);
});
clearHashConflicts();
notify();
}
// Forget any assignment for these source keys (e.g. when a .zip flips
// between single-file and folder mode and the old keys cease to exist).
function dropAssignments(keys) {
var changed = false;
(keys || []).forEach(function (k) {
if (state.assignments[k]) { delete state.assignments[k]; changed = true; }
});
if (changed) notify();
}
function setTitleOverride(key, title) {
var a = assignmentFor(key);
a.titleOverride = title && title.trim() ? title.trim() : null;
cleanAssignment(key);
notify();
}
// ── node index ───────────────────────────────────────────────────────────
function rebuildIndex() {
nodeIndex = {};
(function walkTracking(nodes, parent) {
(nodes || []).forEach(function (n) {
nodeIndex[n.id] = { node: n, kind: 'tracking', parent: parent };
walkTracking(n.children, n);
});
})(state.trackingTree, null);
(state.transmittalTree || []).forEach(function (party) {
nodeIndex[party.id] = { node: party, kind: 'party', parent: null };
(party.children || []).forEach(function (slot) {
nodeIndex[slot.id] = { node: slot, kind: 'slot', parent: party };
(slot.children || []).forEach(function (bin) {
nodeIndex[bin.id] = { node: bin, kind: 'transmittal', parent: slot };
});
});
});
// Scratch-list rows are NOT placement targets — drops materialize real
// tracking-tree nodes (assignFromRow), so the list isn't indexed here.
}
function getNode(id) { return nodeIndex[id] ? nodeIndex[id].node : null; }
function infoFor(id) { return nodeIndex[id] || null; }
// Ancestor name chain for a tracking node (root → node inclusive).
function trackingChain(info) {
var names = [];
var cur = info;
while (cur && cur.kind === 'tracking') {
names.unshift(cur.node.name);
cur = cur.parent ? infoFor(cur.parent.id) : null;
}
return names;
}
// ── tracking tree ops ────────────────────────────────────────────────────
function addTrackingNode(parentId, name) {
var node = { id: uid(), name: (name || 'new').trim() || 'new', children: [] };
if (parentId) {
var info = infoFor(parentId);
if (!info || info.kind !== 'tracking') return null;
info.node.children.push(node);
} else {
state.trackingTree.push(node);
}
rebuildIndex();
notify();
return node.id;
}
// ── transmittal tree ops ─────────────────────────────────────────────────
function addParty(name) {
var party = { id: uid(), kind: 'party', name: (name || 'Party').trim() || 'Party', children: [] };
state.transmittalTree.push(party);
rebuildIndex();
notify();
return party.id;
}
function ensureSlot(party, slot) {
var existing = (party.children || []).filter(function (s) { return s.slot === slot; })[0];
if (existing) return existing;
var node = { id: uid(), kind: 'slot', slot: slot, name: slot, children: [] };
party.children.push(node);
return node;
}
// Create a transmittal bin. meta = { date, type:'TRN'|'SUB', seq, status?, title? }.
// The folder name follows the folder grammar; party node name doubles as the
// transmittal-number prefix (so its tracking is "<party>-<type>-<seq>").
function addTransmittalBin(partyId, slot, meta) {
var info = infoFor(partyId);
if (!info || info.kind !== 'party') return null;
var slotNode = ensureSlot(info.node, slot);
var bin = { id: uid(), kind: 'transmittal', name: transmittalFolderName(info.node.name, meta), meta: meta };
slotNode.children.push(bin);
rebuildIndex();
notify();
return bin.id;
}
function transmittalFolderName(partyName, meta) {
var tn = [partyName, meta.type, meta.seq].filter(Boolean).join('-');
var status = meta.status && zddc.isValidStatus(meta.status) ? meta.status : '---';
var title = (meta.title && meta.title.trim()) || (meta.type === 'SUB' ? 'Submittal' : 'Transmittal');
return zddc.formatFolder({ date: meta.date, trackingNumber: tn, status: status, title: title });
}
// ── shared node ops ──────────────────────────────────────────────────────
function renameNode(id, name) {
var info = infoFor(id);
if (!info) return;
if (info.kind === 'slot') return; // slots are fixed
info.node.name = (name || '').trim() || info.node.name;
if (info.kind === 'party') {
// Party rename re-derives child transmittal folder names (prefix).
(info.node.children || []).forEach(function (slot) {
(slot.children || []).forEach(function (bin) {
bin.name = transmittalFolderName(info.node.name, bin.meta);
});
});
}
rebuildIndex();
notify();
}
// Delete a node (and descendants). Any placement referencing a removed node
// is cleared so no file points at a ghost.
function deleteNode(id) {
var info = infoFor(id);
if (!info) return;
var removed = {};
(function collect(n) {
removed[n.id] = true;
(n.children || []).forEach(collect);
})(info.node);
if (info.kind === 'tracking') {
removeFrom(info.parent ? info.parent.children : state.trackingTree, id);
} else if (info.kind === 'party') {
removeFrom(state.transmittalTree, id);
} else if (info.kind === 'transmittal') {
removeFrom(info.parent.children, id); // info.parent is the slot node
}
// Clear dangling placements.
Object.keys(state.assignments).forEach(function (k) {
var a = state.assignments[k];
if (a.trackingNodeId && removed[a.trackingNodeId]) a.trackingNodeId = null;
if (a.transmittalNodeId && removed[a.transmittalNodeId]) a.transmittalNodeId = null;
cleanAssignment(k);
});
rebuildIndex();
notify();
}
function removeFrom(arr, id) {
for (var i = 0; i < arr.length; i++) { if (arr[i].id === id) { arr.splice(i, 1); return; } }
}
// ── derive target ────────────────────────────────────────────────────────
// Compute the full target for a file from its placements. Pure; returns
// { tracking, revision, status, title, extension, filename, outPath,
// party, slot, transmittalFolder, complete, excluded, errors:[] }.
function deriveTarget(file) {
var key = srcKeyForFile(file);
var a = state.assignments[key] || {};
var out = {
key: key,
tracking: '', revision: '', status: '',
title: (a.titleOverride && a.titleOverride.trim()) || defaultTitle(file),
extension: file.extension || '',
filename: '', outPath: '',
party: '', slot: '', transmittalFolder: '',
trackingLeaf: false, excluded: !!a.excluded, errors: [],
};
if (out.excluded) return out;
// Axis 1 — NAME, always the tracking tree. The "From a list" tab drops
// also produce tracking-tree placements (assignFromRow), so there is a
// single name origin.
if (a.trackingNodeId) {
var ti = infoFor(a.trackingNodeId);
if (ti && ti.kind === 'tracking') {
var chain = trackingChain(ti); // [root … node]
out.tracking = chain.slice(0, -1).join('-'); // ancestors only
var leaf = parseLeafLabel(ti.node.name);
out.revision = leaf.revision;
out.status = leaf.status;
out.trackingLeaf = (ti.node.children || []).length === 0;
if (!out.tracking) out.errors.push('tracking number is empty — the file needs at least one ancestor folder');
if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"');
if (!out.status) out.errors.push('parent folder has no "(STATUS)" — name it like "A (IFR)"');
if (!out.trackingLeaf) out.errors.push('not in a leaf folder yet');
}
} else {
out.errors.push('no tracking number assigned');
}
// Axis 2 — transmittal → output path.
if (a.transmittalNodeId) {
var xi = infoFor(a.transmittalNodeId);
if (xi && xi.kind === 'transmittal') {
// bin → slot → party (nodeIndex stores parent as a NODE)
var slotInfo = xi.parent ? infoFor(xi.parent.id) : null;
out.slot = slotInfo ? slotInfo.node.slot : '';
out.party = slotInfo && slotInfo.parent ? slotInfo.parent.name : '';
out.transmittalFolder = xi.node.name;
if (out.party && out.slot && out.transmittalFolder) {
out.outPath = out.party + '/' + out.slot + '/' + out.transmittalFolder;
}
}
} else {
out.errors.push('not placed in a transmittal');
}
out.filename = zddc.formatFilename({
trackingNumber: out.tracking, revision: out.revision,
status: out.status, title: out.title, extension: out.extension,
});
if (!out.filename && out.errors.length === 0) out.errors.push('incomplete name');
out.complete = !!(out.filename && out.outPath && out.errors.length === 0);
return out;
}
// Per-file classification state for the left-tree markers.
// 'excluded' | 'done' | 'tracking' | 'transmittal' | 'partial' | 'none'
function fileState(file) {
var a = state.assignments[srcKeyForFile(file)];
if (!a) return 'none';
if (a.excluded) return 'excluded';
var t = !!a.trackingNodeId, x = !!a.transmittalNodeId;
if (t && x) {
var d = deriveTarget(file);
return d.complete ? 'done' : 'partial';
}
if (t) return 'tracking';
if (x) return 'transmittal';
return 'none';
}
function stats(allFiles) {
var s = { total: 0, excluded: 0, none: 0, partial: 0, done: 0 };
(allFiles || []).forEach(function (f) {
s.total++;
var st = fileState(f);
if (st === 'excluded') s.excluded++;
else if (st === 'done') s.done++;
else if (st === 'none') s.none++;
else s.partial++; // tracking | transmittal | partial
});
return s;
}
// ── serialize / load ─────────────────────────────────────────────────────
function serialize() {
return {
assignments: state.assignments,
trackingTree: state.trackingTree,
transmittalTree: state.transmittalTree,
outputName: state.outputName,
config: state.config,
// Strip the transient row→keys hint (`placed`) — it's rebuilt as
// drops happen and would otherwise bloat every autosave.
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) {
if (!obj) return;
state.assignments = obj.assignments || {};
state.trackingTree = obj.trackingTree || [];
state.transmittalTree = obj.transmittalTree || [];
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();
}
// Pre-"From a list" workspaces stored a separate `mdlNodeId` axis pointing at
// a row id. Materialize each into a real tracking placement so the
// classification survives the model change, then drop the dead fields.
function migrateLegacyMdl(rawRows) {
var byOldId = Object.create(null);
(rawRows || []).forEach(function (r) { if (r && r.id) byOldId[r.id] = r; });
Object.keys(state.assignments).forEach(function (k) {
var a = state.assignments[k];
if (!a) return;
if (a.mdlNodeId) {
var r = byOldId[a.mdlNodeId];
if (!a.trackingNodeId && r && r.trackingNumber) {
var leaf = addTrackingPath(null, parseFolderLevels(r.trackingNumber + '_' + (r.revisionCell || PENDING_REV)));
a.trackingNodeId = leaf;
if (!a.titleOverride && r.title && a.titleFromDeliverable !== false) a.titleOverride = r.title;
}
delete a.mdlNodeId;
}
if ('titleFromDeliverable' in a) delete a.titleFromDeliverable;
cleanAssignment(k);
});
}
// Reset clears the CLASSIFICATION but keeps the pattern config — it's a
// per-project setting, not part of the data being cleared.
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();
if (!c || typeof c !== 'object') return d;
var fields = Array.isArray(c.trackingFields) && c.trackingFields.length
? c.trackingFields.map(function (f) { return { name: String(f && f.name || '').trim() || '?', optional: !!(f && f.optional) }; })
: d.trackingFields;
return {
trackingFields: fields,
statuses: Array.isArray(c.statuses) && c.statuses.length ? c.statuses.slice() : d.statuses,
modifiers: Array.isArray(c.modifiers) && c.modifiers.length ? c.modifiers.slice() : d.modifiers,
};
}
function getConfig() { return state.config; }
function getTrackingFields() { return state.config.trackingFields; }
function setConfig(c) { state.config = normalizeConfig(c); notify(); }
// ── "From a list" scratch worklist ───────────────────────────────────────
// A temporary list of known/typed tracking numbers (from the archive/MDL, a
// paste, or a name-match). Dropping a file on a row MATERIALIZES a real
// tracking-tree placement (assignFromRow) — the list is pure input, so it can
// be cleared without losing any classification. `placed` is a transient
// row→keys hint (not the source of truth, not serialized) used to re-stamp a
// row's files when its tracking number / revision is edited.
function rowSource(r) {
if (r && r.source) return { mdl: !!r.source.mdl, archive: !!r.source.archive, pasted: !!r.source.pasted };
return { mdl: !!(r && r.inMdl), archive: !!(r && Array.isArray(r.archiveRevisions) && r.archiveRevisions.length), pasted: false };
}
function normalizeRow(r) {
r = r || {};
return {
id: r.id || uid(), party: r.party || '',
trackingNumber: (r.trackingNumber || '').trim(), title: r.title || '',
revisionCell: r.revisionCell || '',
// The file's existing name (pasted col 4) — a join key for name-match.
currentName: (r.currentName || '').trim(),
source: rowSource(r),
archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [],
placed: Object.create(null),
};
}
function setWorklist(rows) { state.worklist = (rows || []).map(normalizeRow); notify(); }
function appendWorklist(rows) {
var byTn = Object.create(null);
state.worklist.forEach(function (r) { if (r.trackingNumber) byTn[r.trackingNumber] = r; });
(rows || []).forEach(function (raw) {
var r = normalizeRow(raw), ex = r.trackingNumber ? byTn[r.trackingNumber] : null;
if (ex) {
if (!ex.revisionCell && r.revisionCell) ex.revisionCell = r.revisionCell;
if (!ex.title && r.title) ex.title = r.title;
ex.source.mdl = ex.source.mdl || r.source.mdl;
ex.source.archive = ex.source.archive || r.source.archive;
ex.source.pasted = ex.source.pasted || r.source.pasted;
if (r.archiveRevisions.length && !ex.archiveRevisions.length) ex.archiveRevisions = r.archiveRevisions;
} else {
state.worklist.push(r);
if (r.trackingNumber) byTn[r.trackingNumber] = r;
}
});
notify();
}
function clearWorklist() { state.worklist = []; notify(); } // rows only — assignments survive
function getWorklist() { return state.worklist; }
function getWorklistRow(id) { return state.worklist.filter(function (r) { return r.id === id; })[0] || null; }
// Build (creating folders as needed) the tracking-tree leaf a row points at:
// "<tracking>_<rev (status)>". A tracking number's last segment is an
// ancestor and the revision is ALWAYS the leaf, so a blank revision gets a
// PENDING_REV placeholder leaf (the file shows incomplete until set; the
// placeholder is pruned when the real revision lands).
var PENDING_REV = 'pending';
function leafForRow(row) {
var tn = (row.trackingNumber || '').trim();
if (!tn) return null;
var rev = (row.revisionCell || '').trim() || PENDING_REV;
return addTrackingPath(null, parseFolderLevels(tn + '_' + rev));
}
function assignFromRow(keys, row) {
var leaf = leafForRow(row);
if (!leaf || !keys || !keys.length) return;
place(keys, leaf, 'tracking');
keys.forEach(function (k) {
row.placed[k] = true;
if (row.title && row.title.trim()) {
var a = state.assignments[k];
if (a && !a.titleOverride) setTitleOverride(k, row.title);
}
});
notify();
}
function nodeHasFiles(nodeId) {
for (var k in state.assignments) { if (state.assignments[k].trackingNodeId === nodeId) return true; }
return false;
}
// Delete a now-empty tracking leaf and any ancestors it leaves empty (so a
// re-stamp doesn't litter the By-tracking tree with stale folders).
function pruneEmptyTrackingChain(nodeId) {
var info = infoFor(nodeId);
while (info && info.kind === 'tracking') {
if ((info.node.children || []).length || nodeHasFiles(info.node.id)) break;
var parent = info.parent; // parent NODE (or null)
deleteNode(info.node.id); // rebuilds the index
info = parent ? infoFor(parent.id) : null;
}
}
// Re-point a row's already-dropped files at the row's current leaf (after its
// tracking number or revision was edited). Skips keys the user has since
// un-placed elsewhere; prunes the leaves it empties.
function restampRow(row) {
var keys = Object.keys(row.placed || {});
if (!keys.length) return;
var leaf = leafForRow(row);
if (!leaf) return;
var old = Object.create(null);
keys.forEach(function (k) {
var a = state.assignments[k];
if (a && a.trackingNodeId) { if (a.trackingNodeId !== leaf) old[a.trackingNodeId] = true; a.trackingNodeId = leaf; }
else delete row.placed[k]; // user un-placed it elsewhere — don't resurrect
});
clearHashConflicts();
Object.keys(old).forEach(pruneEmptyTrackingChain);
notify();
}
function unassignRowFile(row, key) {
var a = state.assignments[key], old = a ? a.trackingNodeId : null;
if (row && row.placed) delete row.placed[key];
place([key], null, 'tracking');
if (old) pruneEmptyTrackingChain(old);
}
function setRowTracking(rowId, tn) {
var r = getWorklistRow(rowId); if (!r) return;
r.trackingNumber = (tn == null ? '' : String(tn)).trim();
restampRow(r); notify();
}
function setRowTitle(rowId, title) {
var r = getWorklistRow(rowId); if (!r) return;
r.title = (title == null ? '' : String(title));
Object.keys(r.placed || {}).forEach(function (k) { if (state.assignments[k]) setTitleOverride(k, r.title); });
notify();
}
function setRevisionCell(rowId, value) { setRevisionCells([rowId], value); }
function setRevisionCells(rowIds, value) {
var set = Object.create(null); (rowIds || []).forEach(function (i) { set[i] = true; });
var changed = false;
state.worklist.forEach(function (r) {
if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); restampRow(r); changed = true; }
});
if (changed) notify();
}
// ── paste parsing + name matching (pure helpers, unit-tested) ─────────────
// Parse Excel/TSV text into scratch rows. Columns: Tracking ⇥ Rev(Status) ⇥
// Title; a 4th bare-status column merges into the revision; a lone cell that
// parses as a full ZDDC filename is split; a header row is skipped.
// FIXED schema, by column position (no variant detection): a header row is
// skipped, then each line is tracking_number ⇥ rev (status) ⇥ title ⇥
// current name. Trailing columns may be omitted (currentName/title blank).
function parsePastedRows(text) {
function unq(s) {
s = (s == null ? '' : String(s)).trim();
if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') s = s.slice(1, -1).replace(/""/g, '"');
return s.trim();
}
var lines = String(text == null ? '' : text).replace(/\r\n?/g, '\n').split('\n');
var rows = [], skipped = [], sawData = false;
lines.forEach(function (raw, i) {
if (!raw.trim()) return;
var cells = raw.split('\t').map(unq);
var c0 = cells[0] || '';
// Skip a leading header row (first cell is a header word, not a tn).
if (!sawData && /^(tracking|number|no\.?|doc(ument)?|drawing|item)\b/i.test(c0) && c0.indexOf('-') === -1) return;
if (!c0) { skipped.push({ line: i + 1, reason: 'no tracking number', text: raw }); return; }
sawData = true;
rows.push({
trackingNumber: c0,
revisionCell: (cells[1] || '').trim(),
title: cells[2] || '',
currentName: cells[3] || '',
source: { pasted: true },
});
});
return { rows: rows, skipped: skipped };
}
function normTok(s) { return String(s == null ? '' : s).toUpperCase().replace(/[^A-Z0-9]/g, ''); }
function dropExt(s) { return String(s == null ? '' : s).replace(/\.[^.\/\\]+$/, ''); }
function nameKey(s) { return dropExt(s).toLowerCase().replace(/[^a-z0-9]+/g, ''); }
function nameTokens(s) { return dropExt(s).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); }
// Score a pasted "current name" against a file's name: 1 = exact (normalized,
// extension dropped), 0.60.95 = token coverage, 0.7 = a clean substring,
// 0 = no match. Token-set beats raw substring (survives reordering).
function nameScore(rowName, fileFull) {
var rk = nameKey(rowName); if (!rk) return 0;
var fk = nameKey(fileFull);
if (rk === fk) return 1;
var rt = nameTokens(rowName);
if (rt.length) {
var ft = Object.create(null); nameTokens(fileFull).forEach(function (t) { ft[t] = true; });
var hit = 0; rt.forEach(function (t) { if (ft[t]) hit++; });
var cov = hit / rt.length;
if (cov >= 0.6) return Math.min(0.95, 0.6 + 0.35 * cov);
}
var a = rk.length <= fk.length ? rk : fk, b = rk.length <= fk.length ? fk : rk;
if (a.length >= 4 && b.indexOf(a) !== -1) return 0.7;
return 0;
}
// Propose file↔row matches. PRIMARY signal is the pasted "current name"
// column (nameScore); FALLBACK is the tracking number embedded in the
// filename (opts.fuzzy also tries the digit-run). Each proposal carries a
// confidence and an `auto` flag — true only for an exact 1:1 match (conf 1,
// the unique conf-1 match for BOTH its file and its row), the only kind safe
// to assign without confirmation.
function proposeMatches(files, rows, opts) {
opts = opts || {};
var named = (rows || []).filter(function (r) { return (r.currentName || '').trim(); });
var out = [];
(files || []).forEach(function (f) {
var full = zddc.joinExtension(f.originalFilename, f.extension);
var best = null;
named.forEach(function (r) {
var s = nameScore(r.currentName, full);
if (s > 0 && (!best || s > best.confidence)) best = { row: r, confidence: s, via: 'name' };
});
if (!best) { // fallback: tracking number in the filename
var nameNorm = normTok(full), nameDigits = nameNorm.replace(/[^0-9]/g, '');
(rows || []).forEach(function (r) {
var tn = r.trackingNumber || ''; if (!tn) return;
var tnNorm = normTok(tn), conf = 0;
if (full.indexOf(tn) !== -1) conf = 1;
else if (tnNorm && nameNorm.indexOf(tnNorm) !== -1) conf = 0.8;
else if (opts.fuzzy) { var d = tnNorm.replace(/[^0-9]/g, ''); if (d && nameDigits.indexOf(d) !== -1) conf = 0.5; }
if (conf && (!best || conf > best.confidence)) best = { row: r, confidence: conf, via: 'tracking' };
});
}
if (best) out.push({ file: f, row: best.row, confidence: best.confidence, via: best.via, auto: false });
});
// Auto-assignable = exact + unambiguous both ways (so duplicate names
// never silently grab the wrong file).
var rowEx = Object.create(null), fileEx = Object.create(null);
out.forEach(function (p) {
if (p.confidence !== 1) return;
rowEx[p.row.id || p.row.trackingNumber] = (rowEx[p.row.id || p.row.trackingNumber] || 0) + 1;
fileEx[srcKeyForFile(p.file)] = (fileEx[srcKeyForFile(p.file)] || 0) + 1;
});
out.forEach(function (p) {
if (p.confidence === 1) p.auto = rowEx[p.row.id || p.row.trackingNumber] === 1 && fileEx[srcKeyForFile(p.file)] === 1;
});
return out;
}
// ── add-folder pattern expansion ─────────────────────────────────────────
// Brace expansion for the add-folder box. Supports (non-nested) groups:
// {a,b,c} → alternation: a | b | c
// {0001-0002} → numeric range, zero-padded to the operands' width
// {0001-0002,0005} → mix ranges and literals in one group
// Multiple groups expand as a cartesian product, e.g.
// "X-{PM,EL}-{0001-0002,0005}_A (IFR)" → 6 names.
// A pattern with no braces returns itself (one name). Unbalanced braces are
// treated literally so the user never silently loses input.
function expandGroup(body) {
var out = [];
String(body).split(',').forEach(function (piece) {
var m = /^\s*(\d+)\s*-\s*(\d+)\s*$/.exec(piece);
if (m) {
var a = m[1], b = m[2];
var start = parseInt(a, 10), end = parseInt(b, 10);
// Pad when either operand carries a leading zero (e.g. 0001).
var width = (a.length > 1 && a[0] === '0') || (b.length > 1 && b[0] === '0')
? Math.max(a.length, b.length) : 0;
var step = start <= end ? 1 : -1;
for (var v = start; step > 0 ? v <= end : v >= end; v += step) {
out.push(width ? String(v).padStart(width, '0') : String(v));
}
} else {
out.push(piece);
}
});
return out;
}
function expandFolderPattern(pattern) {
var s = String(pattern == null ? '' : pattern);
var parts = []; // each: {lit} or {opts:[...]}
var i = 0;
while (i < s.length) {
var open = s.indexOf('{', i);
if (open === -1) { parts.push({ lit: s.slice(i) }); break; }
var close = s.indexOf('}', open);
if (close === -1) { parts.push({ lit: s.slice(i) }); break; } // unbalanced → literal
if (open > i) parts.push({ lit: s.slice(i, open) });
parts.push({ opts: expandGroup(s.slice(open + 1, close)) });
i = close + 1;
}
var results = [''];
parts.forEach(function (p) {
var opts = p.lit != null ? [p.lit] : p.opts;
var next = [];
results.forEach(function (prefix) {
opts.forEach(function (o) { next.push(prefix + o); });
});
results = next;
});
// Trim + drop empties so a stray comma can't create a blank folder.
return results.map(function (r) { return r.trim(); }).filter(Boolean);
}
// Parse one (already brace-expanded) folder name into the nested tracking
// levels it represents: split on "-" into tracking-number segments, then
// split the FINAL segment once on "_" to separate the last tracking segment
// from the "REV (STATUS)" leaf. So "CPO-0001_0 (IFU)" → ["CPO","0001","0 (IFU)"]
// and "BMB-187023-PM-MOM-0001_A (IFR)" → ["BMB","187023","PM","MOM","0001","A (IFR)"].
// A name with no "-"/"_" is a single level (e.g. adding a leaf "A (IFR)").
function parseFolderLevels(name) {
var s = String(name == null ? '' : name).trim();
if (!s) return [];
var u = s.indexOf('_'); // the "_" separates the tracking number from the leaf
if (u < 0) {
// No "_" → a pure tracking-number path: nest by "-".
return s.split('-').map(function (x) { return x.trim(); }).filter(Boolean);
}
// Tracking number (before "_") nests by "-"; everything AFTER the "_" is
// ONE leaf, kept whole — the revision may itself contain hyphens, e.g. a
// date revision "2025-11-17 (IFI)".
var segs = s.slice(0, u).split('-').map(function (x) { return x.trim(); }).filter(Boolean);
var leaf = s.slice(u + 1).trim();
if (leaf) segs.push(leaf);
return segs;
}
// Children array for a tracking node (or the roots for null), or null.
function trackingChildren(parentId) {
if (!parentId) return state.trackingTree;
var info = infoFor(parentId);
return (info && info.kind === 'tracking') ? info.node.children : null;
}
// Ensure a nested chain of tracking folders exists under parentId, reusing
// an existing child when one already has that name (so sibling leaves share
// ancestors). Returns the leaf node id.
function addTrackingPath(parentId, segments) {
var cur = parentId || null;
(segments || []).forEach(function (seg) {
var name = (seg || '').trim();
if (!name) return;
var kids = trackingChildren(cur) || [];
var existing = kids.filter(function (n) { return n.name === name; })[0];
cur = existing ? existing.id : addTrackingNode(cur, name);
});
return cur;
}
// A tracking node is a "complete" drop target when it's a leaf whose name
// carries a valid "(STATUS)" — i.e. a file dropped there yields a full name
// with no more levels needed. Used to decide whether a drop should prompt.
function trackingNodeComplete(nodeId) {
var info = infoFor(nodeId);
if (!info || info.kind !== 'tracking') return false;
if ((info.node.children || []).length) return false;
var leaf = parseLeafLabel(info.node.name);
return !!(leaf.status && zddc.isValidStatus(leaf.status));
}
// Human-readable "root / … / node" path for a tracking node (prompt context).
function trackingPathLabel(nodeId) {
var info = infoFor(nodeId);
if (!info || info.kind !== 'tracking') return '';
return trackingChain(info).join(' / ');
}
// ── filename-based export/import helpers ─────────────────────────────────
// A flat, AI-friendly transmittal record for a placed file (export side).
function transmittalRecord(binId) {
var info = infoFor(binId);
if (!info || info.kind !== 'transmittal') return null;
var slot = info.parent ? infoFor(info.parent.id) : null;
var party = slot && slot.parent ? infoFor(slot.parent.id) : null;
var m = info.node.meta || {};
return {
party: party ? party.node.name : '',
slot: slot ? slot.node.slot : '',
date: m.date || '', type: m.type || 'TRN', seq: m.seq || '',
status: m.status || '', title: m.title || '',
};
}
// Find-or-create a party by name (import side — reuse so shared transmittals
// don't duplicate the party).
function findOrAddParty(name) {
var existing = (state.transmittalTree || []).filter(function (p) { return p.name === name; })[0];
return existing ? existing.id : addParty(name);
}
// Find-or-create a transmittal bin under party/slot matching meta (import).
function findOrAddTransmittalBin(partyId, slot, meta) {
var pinfo = infoFor(partyId);
if (!pinfo || pinfo.kind !== 'party') return null;
var wantName = transmittalFolderName(pinfo.node.name, meta);
var slotNode = (pinfo.node.children || []).filter(function (s) { return s.slot === slot; })[0];
if (slotNode) {
var existing = (slotNode.children || []).filter(function (b) { return b.name === wantName; })[0];
if (existing) return existing.id;
}
return addTransmittalBin(partyId, slot, meta);
}
// ── mode ─────────────────────────────────────────────────────────────────
function setEnabled(on) { state.enabled = !!on; notify(); }
function isEnabled() { return state.enabled; }
window.app.modules.classify = {
// mode
setEnabled: setEnabled, isEnabled: isEnabled,
// pub/sub
on: on,
// keys/title
srcKeyForFile: srcKeyForFile, defaultTitle: defaultTitle,
// assignments
assignmentFor: assignmentFor, getAssignment: getAssignment,
place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
setHashConflicts: setHashConflicts, hasHashConflict: hasHashConflict,
setTitleOverride: setTitleOverride,
// trees
addTrackingNode: addTrackingNode, addParty: addParty,
addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
expandFolderPattern: expandFolderPattern,
parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath,
trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
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,
setRowTracking: setRowTracking, setRowTitle: setRowTitle,
setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells,
parsePastedRows: parsePastedRows, proposeMatches: proposeMatches,
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse
deriveTarget: deriveTarget,
fileState: fileState, stats: stats,
// persistence
serialize: serialize, load: load, reset: reset,
getOutputName: function () { return state.outputName; },
setOutputName: function (n) { state.outputName = n || null; notify(); },
};
})();