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>
961 lines
48 KiB
JavaScript
961 lines
48 KiB
JavaScript
/**
|
||
* 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.6–0.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(); },
|
||
};
|
||
})();
|