/** * 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): * /{issued,received}// * * 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) mdlList: [], // loaded MDL deliverables (drop targets): [ { id, party, trackingNumber, title, revisionCell } ] }; // 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, mdlNodeId: null, excluded: false, titleOverride: null, titleFromDeliverable: true }; 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.mdlNodeId && !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' : axis === 'mdl' ? 'mdlNodeId' : 'trackingNodeId'; keys.forEach(function (k) { var a = assignmentFor(k); a[field] = nodeId || null; // Tracking and MDL are alternative NAME sources — placing on one // clears the other so the file has a single name origin. if (axis === 'mdl' && nodeId) a.trackingNodeId = null; else if (axis === 'tracking' && nodeId) a.mdlNodeId = 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; a.mdlNodeId = 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 }; }); }); }); (state.mdlList || []).forEach(function (row) { nodeIndex[row.id] = { node: row, kind: 'mdl', parent: null }; }); } 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 "--"). 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. An MDL deliverable (alternative to the tracking tree) // supplies the tracking number + title; its revision comes from the // classifier-local revision cell. Otherwise the tracking tree. if (a.mdlNodeId) { var mi = infoFor(a.mdlNodeId); if (mi && mi.kind === 'mdl') { var row = mi.node; out.tracking = row.trackingNumber || ''; var ml = parseLeafLabel(row.revisionCell || ''); out.revision = ml.revision; out.status = ml.status; out.trackingLeaf = true; if (!a.titleOverride && a.titleFromDeliverable !== false && row.title) out.title = row.title; if (!out.tracking) out.errors.push('deliverable has no tracking number'); if (!out.revision) out.errors.push('set a revision for this deliverable (e.g. "A (IFR)")'); else if (out.status && !zddc.isValidStatus(out.status)) out.errors.push('unknown status "' + out.status + '"'); else if (!out.status) out.errors.push('revision needs a "(STATUS)" — e.g. "A (IFR)"'); } else { out.errors.push('deliverable no longer loaded'); } } else 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, config: state.config, mdlList: state.mdlList, }; } 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.mdlList = Array.isArray(obj.mdlList) ? obj.mdlList : []; rebuildIndex(); notify(); } // 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; rebuildIndex(); 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(); } // ── MDL deliverables (the "By MDL" drop-target axis) ───────────────────── function setMdlList(rows) { state.mdlList = (rows || []).map(function (r) { return { id: r.id || uid(), party: r.party || '', trackingNumber: r.trackingNumber || '', title: r.title || '', inMdl: !!r.inMdl, archiveRevisions: Array.isArray(r.archiveRevisions) ? r.archiveRevisions : [], revisionCell: r.revisionCell || '', }; }); // Drop placements pointing at deliverables no longer loaded. var valid = Object.create(null); state.mdlList.forEach(function (r) { valid[r.id] = true; }); Object.keys(state.assignments).forEach(function (k) { var a = state.assignments[k]; if (a.mdlNodeId && !valid[a.mdlNodeId]) { a.mdlNodeId = null; cleanAssignment(k); } }); rebuildIndex(); notify(); } function getMdlList() { return state.mdlList; } function getMdlRow(id) { var i = infoFor(id); return (i && i.kind === 'mdl') ? i.node : null; } 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.mdlList.forEach(function (r) { if (set[r.id]) { r.revisionCell = (value == null ? '' : String(value)); changed = true; } }); if (changed) notify(); } function setTitleFromDeliverable(key, fromDeliverable) { var a = assignmentFor(key); a.titleFromDeliverable = !!fromDeliverable; cleanAssignment(key); 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 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, setMdlList: setMdlList, getMdlList: getMdlList, getMdlRow: getMdlRow, setRevisionCell: setRevisionCell, setRevisionCells: setRevisionCells, setTitleFromDeliverable: setTitleFromDeliverable, 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(); }, }; })();