Classify & Copy interaction pass (replaces the single "Hide Assigned" toggle): - Source-tree filters: three "Show Unassigned / Show Assigned / Show Excluded" checkboxes (classify mode only) with live per-tab counts; "Hide Compliant" is now rename-mode only. Folders with nothing visible collapse out. - Target tree: ctrl/cmd-click a toggle to expand/collapse the whole subtree. - Tracking drop-to-any-level: dropping on a node that isn't already a complete leaf prompts for the remaining levels (e.g. "0001_0 (IFU)"), which are parsed and nested under the drop target. Dropping on a finished leaf assigns directly. - Placed-file rows: click to preview; the derived filename is now an inline input — edit it (full "TRACKING_REV (STATUS) - Title.ext") and the item is re-filed onto the parsed tracking path (created if needed) + title override. New classify helpers: trackingNodeComplete, trackingPathLabel. tree.setShowFilters replaces setHideAssigned. Tests updated/added (classify.spec.js -> 33 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
537 lines
25 KiB
JavaScript
537 lines
25 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);
|
|
}
|
|
|
|
// ── 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
|
|
};
|
|
|
|
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
|
var nodeIndex = {};
|
|
|
|
// ── 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.
|
|
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);
|
|
});
|
|
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);
|
|
});
|
|
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 };
|
|
});
|
|
});
|
|
});
|
|
}
|
|
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 — tracking.
|
|
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;
|
|
}
|
|
|
|
// Files currently placed in a node (reverse lookup over all source files).
|
|
function filesInNode(nodeId, axis, allFiles) {
|
|
var field = axis === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId';
|
|
return (allFiles || []).filter(function (f) {
|
|
var a = state.assignments[srcKeyForFile(f)];
|
|
return a && a[field] === nodeId;
|
|
});
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
function load(obj) {
|
|
if (!obj) return;
|
|
state.assignments = obj.assignments || {};
|
|
state.trackingTree = obj.trackingTree || [];
|
|
state.transmittalTree = obj.transmittalTree || [];
|
|
state.outputName = obj.outputName || null;
|
|
rebuildIndex();
|
|
notify();
|
|
}
|
|
function reset() {
|
|
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
|
|
state.outputName = null;
|
|
rebuildIndex();
|
|
notify();
|
|
}
|
|
|
|
// ── 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 segs = s.split('-');
|
|
var last = segs.pop();
|
|
var u = last.indexOf('_');
|
|
if (u >= 0) { segs.push(last.slice(0, u)); segs.push(last.slice(u + 1)); }
|
|
else { segs.push(last); }
|
|
return segs.map(function (x) { return x.trim(); }).filter(Boolean);
|
|
}
|
|
// 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(' / ');
|
|
}
|
|
|
|
// ── 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,
|
|
setTitleOverride: setTitleOverride,
|
|
// trees
|
|
addTrackingNode: addTrackingNode, addParty: addParty,
|
|
addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
|
|
expandFolderPattern: expandFolderPattern,
|
|
parseFolderLevels: parseFolderLevels, addTrackingPath: addTrackingPath,
|
|
trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
|
|
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
|
getTransmittalTree: function () { return state.transmittalTree; },
|
|
// derive + reverse
|
|
deriveTarget: deriveTarget, filesInNode: filesInNode,
|
|
fileState: fileState, stats: stats,
|
|
// persistence
|
|
serialize: serialize, load: load, reset: reset,
|
|
getOutputName: function () { return state.outputName; },
|
|
setOutputName: function (n) { state.outputName = n || null; notify(); },
|
|
};
|
|
})();
|