feat(classifier): Classify & Copy state model + persistence (phase 1)
Foundation for the non-destructive map+copy workflow: source stays read-only,
files are mapped onto two orthogonal target trees, a later step copies renamed
copies to a separate output dir.
- classify.js: the single source of truth. assignments map keyed by
source-relative path (survives re-pick); tracking tree (positional: ancestors
joined '-' = tracking number, immediate parent 'REV (STATUS)' leaf = rev+status,
title from original name) and transmittal tree (<party>/{received,issued}/<bin>).
deriveTarget() computes filename + output path + validation purely; pub/sub +
debounced autosave; node CRUD with dangling-placement cleanup.
- persist.js: IndexedDB store of the serialized map + the source
FileSystemDirectoryHandle, with queryPermission/requestPermission re-grant on
reload and a re-pick fallback.
- tests/classify.spec.js: 9 in-page unit tests for the derive/assignment logic
(no FS Access needed) — tracking join, leaf REV (STATUS) parse incl. invalid
status, title derivation/override, transmittal path composition, exclude,
cascade delete.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
389b2e94ac
commit
a8f116734d
5 changed files with 684 additions and 0 deletions
|
|
@ -51,6 +51,8 @@ concat_files \
|
|||
"js/utils.js" \
|
||||
"../shared/zddc-filter.js" \
|
||||
"js/store.js" \
|
||||
"js/persist.js" \
|
||||
"js/classify.js" \
|
||||
"js/validator.js" \
|
||||
"js/scanner.js" \
|
||||
"js/tree.js" \
|
||||
|
|
|
|||
431
classifier/js/classify.js
Normal file
431
classifier/js/classify.js
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
/**
|
||||
* 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.
|
||||
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); }
|
||||
}
|
||||
scheduleSave();
|
||||
});
|
||||
}
|
||||
|
||||
// ── persistence hook (debounced) ─────────────────────────────────────────
|
||||
var saveTimer = null;
|
||||
function scheduleSave() {
|
||||
if (!window.app.modules.persist) return;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(function () {
|
||||
saveTimer = null;
|
||||
try { window.app.modules.persist.saveState(serialize()); } catch (e) { console.warn('persist', e); }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
// ── 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, place: place, setExcluded: setExcluded,
|
||||
setTitleOverride: setTitleOverride,
|
||||
// trees
|
||||
addTrackingNode: addTrackingNode, addParty: addParty,
|
||||
addTransmittalBin: addTransmittalBin, renameNode: renameNode, deleteNode: deleteNode,
|
||||
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; scheduleSave(); },
|
||||
};
|
||||
})();
|
||||
98
classifier/js/persist.js
Normal file
98
classifier/js/persist.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* ZDDC Classifier — persistence for the Classify & Copy map.
|
||||
*
|
||||
* The assignment map and target trees, plus the picked source directory
|
||||
* HANDLE, are stored in IndexedDB (localStorage can't hold a
|
||||
* FileSystemDirectoryHandle; the handle is structured-cloneable, so IndexedDB
|
||||
* can). On reload we re-request permission on the stored handle — a single
|
||||
* click re-grants access, no re-navigation. If that fails (permission denied,
|
||||
* or a different machine), the caller falls back to a fresh pick and the map
|
||||
* re-attaches by relative path.
|
||||
*
|
||||
* NOTE: a stored handle is only valid in the same browser profile on the same
|
||||
* machine. The map keys on source-relative paths, so re-picking the same tree
|
||||
* elsewhere still re-attaches — that's the warning shown to the user on save.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var DB_NAME = 'zddc-classifier';
|
||||
var STORE = 'kv';
|
||||
var K_STATE = 'classify-state';
|
||||
var K_HANDLE = 'source-handle';
|
||||
|
||||
var available = typeof indexedDB !== 'undefined';
|
||||
|
||||
function openDB() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (!available) { reject(new Error('IndexedDB unavailable')); return; }
|
||||
var req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = function () {
|
||||
var db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
||||
};
|
||||
req.onsuccess = function () { resolve(req.result); };
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
}
|
||||
|
||||
function tx(mode, fn) {
|
||||
return openDB().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var t = db.transaction(STORE, mode);
|
||||
var store = t.objectStore(STORE);
|
||||
var out = fn(store);
|
||||
t.oncomplete = function () { resolve(out && out.result !== undefined ? out.result : out); };
|
||||
t.onerror = function () { reject(t.error); };
|
||||
t.onabort = function () { reject(t.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function put(key, value) { return tx('readwrite', function (s) { return s.put(value, key); }); }
|
||||
|
||||
function getValue(key) {
|
||||
return openDB().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var t = db.transaction(STORE, 'readonly');
|
||||
var req = t.objectStore(STORE).get(key);
|
||||
req.onsuccess = function () { resolve(req.result); };
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── public API ─────────────────────────────────────────────────────────
|
||||
function saveState(obj) { return put(K_STATE, obj).catch(function (e) { console.warn('persist.saveState', e); }); }
|
||||
function loadState() { return getValue(K_STATE).catch(function () { return null; }); }
|
||||
|
||||
function saveSourceHandle(handle) {
|
||||
// Real FileSystemDirectoryHandle only; the HTTP polyfill handle is not
|
||||
// worth persisting (server mode re-detects the root on load).
|
||||
if (!handle || handle.isHttp) return Promise.resolve();
|
||||
return put(K_HANDLE, handle).catch(function (e) { console.warn('persist.saveHandle', e); });
|
||||
}
|
||||
function loadSourceHandle() { return getValue(K_HANDLE).catch(function () { return null; }); }
|
||||
|
||||
function clearAll() {
|
||||
return tx('readwrite', function (s) { s.delete(K_STATE); s.delete(K_HANDLE); })
|
||||
.catch(function (e) { console.warn('persist.clear', e); });
|
||||
}
|
||||
|
||||
// Re-acquire read permission on a stored handle. Returns true if usable.
|
||||
function verifyPermission(handle) {
|
||||
if (!handle || typeof handle.queryPermission !== 'function') return Promise.resolve(false);
|
||||
var opts = { mode: 'read' };
|
||||
return handle.queryPermission(opts).then(function (p) {
|
||||
if (p === 'granted') return true;
|
||||
return handle.requestPermission(opts).then(function (p2) { return p2 === 'granted'; });
|
||||
}).catch(function () { return false; });
|
||||
}
|
||||
|
||||
window.app.modules.persist = {
|
||||
available: available,
|
||||
saveState: saveState, loadState: loadState,
|
||||
saveSourceHandle: saveSourceHandle, loadSourceHandle: loadSourceHandle,
|
||||
verifyPermission: verifyPermission, clearAll: clearAll,
|
||||
};
|
||||
})();
|
||||
|
|
@ -51,6 +51,10 @@ export default defineConfig({
|
|||
name: 'classifier',
|
||||
testMatch: 'classifier.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'classify',
|
||||
testMatch: 'classify.spec.js',
|
||||
},
|
||||
{
|
||||
name: 'browse',
|
||||
testMatch: 'browse.spec.js',
|
||||
|
|
|
|||
149
tests/classify.spec.js
Normal file
149
tests/classify.spec.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Tests for the classifier "Classify & Copy" state model
|
||||
* (classifier/js/classify.js) — the pure derive/assignment logic.
|
||||
*
|
||||
* Runs against the compiled classifier/dist/classifier.html, driving
|
||||
* window.app.modules.classify via page.evaluate. No File System Access API is
|
||||
* needed: synthetic file objects ({folderPath, originalFilename, extension})
|
||||
* carry everything deriveTarget consults. Drag-and-drop and the actual copy
|
||||
* stay manual (Playwright can't drive the directory picker).
|
||||
*
|
||||
* Build first: sh classifier/build.sh
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as path from 'path';
|
||||
|
||||
const PAGE = 'file://' + path.resolve('classifier/dist/classifier.html');
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(PAGE, { waitUntil: 'load' });
|
||||
const ok = await page.evaluate(() => !!(window.app && window.app.modules && window.app.modules.classify));
|
||||
expect(ok).toBe(true);
|
||||
await page.evaluate(() => window.app.modules.classify.reset());
|
||||
});
|
||||
|
||||
// Build a tracking chain of folders and place one file in the deepest;
|
||||
// return the derived target.
|
||||
async function deriveInTracking(page, segments, file) {
|
||||
return page.evaluate(({ segments, file }) => {
|
||||
const c = window.app.modules.classify;
|
||||
let parent = null;
|
||||
for (const name of segments) parent = c.addTrackingNode(parent, name);
|
||||
const key = c.srcKeyForFile(file);
|
||||
c.place([key], parent, 'tracking');
|
||||
return c.deriveTarget(file);
|
||||
}, { segments, file });
|
||||
}
|
||||
|
||||
const FILE = { folderPath: 'Root/Sub', originalFilename: 'Foundation Plan', extension: 'pdf' };
|
||||
|
||||
test('tracking: ancestors join with "-", parent leaf = REV (STATUS), title from name', async ({ page }) => {
|
||||
const d = await deriveInTracking(page, ['ACME-PROJ', 'MECH', '0001', 'A (IFR)'], FILE);
|
||||
expect(d.tracking).toBe('ACME-PROJ-MECH-0001');
|
||||
expect(d.revision).toBe('A');
|
||||
expect(d.status).toBe('IFR');
|
||||
expect(d.title).toBe('Foundation Plan');
|
||||
expect(d.filename).toBe('ACME-PROJ-MECH-0001_A (IFR) - Foundation Plan.pdf');
|
||||
expect(d.trackingLeaf).toBe(true);
|
||||
});
|
||||
|
||||
test('tracking: a single full-number folder also works', async ({ page }) => {
|
||||
const d = await deriveInTracking(page, ['ACME-PROJ-MECH-0001', 'B (IFC)'], FILE);
|
||||
expect(d.tracking).toBe('ACME-PROJ-MECH-0001');
|
||||
expect(d.revision).toBe('B');
|
||||
expect(d.status).toBe('IFC');
|
||||
expect(d.filename).toBe('ACME-PROJ-MECH-0001_B (IFC) - Foundation Plan.pdf');
|
||||
});
|
||||
|
||||
test('tracking: parked in an intermediate (non-leaf) folder is flagged incomplete', async ({ page }) => {
|
||||
const d = await page.evaluate((file) => {
|
||||
const c = window.app.modules.classify;
|
||||
const proj = c.addTrackingNode(null, 'ACME-PROJ');
|
||||
const mech = c.addTrackingNode(proj, 'MECH');
|
||||
c.addTrackingNode(mech, '0001'); // child exists → MECH is not a leaf
|
||||
const key = c.srcKeyForFile(file);
|
||||
c.place([key], mech, 'tracking'); // file parked at MECH
|
||||
return c.deriveTarget(file);
|
||||
}, FILE);
|
||||
expect(d.trackingLeaf).toBe(false);
|
||||
expect(d.errors.join(' ')).toContain('leaf');
|
||||
expect(d.complete).toBe(false);
|
||||
});
|
||||
|
||||
test('tracking: unknown status code is reported', async ({ page }) => {
|
||||
const d = await deriveInTracking(page, ['ACME', 'Z (BOGUS)'], FILE);
|
||||
expect(d.status).toBe('BOGUS');
|
||||
expect(d.errors.join(' ')).toContain('unknown status');
|
||||
expect(d.complete).toBe(false);
|
||||
});
|
||||
|
||||
test('tracking: leaf with no "(STATUS)" parens is flagged', async ({ page }) => {
|
||||
const d = await deriveInTracking(page, ['ACME', '0001'], FILE);
|
||||
expect(d.status).toBe('');
|
||||
expect(d.filename).toBe(''); // formatFilename needs a status
|
||||
expect(d.errors.join(' ')).toContain('STATUS');
|
||||
});
|
||||
|
||||
test('title: original ZDDC name reuses its title; titleOverride wins', async ({ page }) => {
|
||||
const zddcFile = { folderPath: 'Root', originalFilename: 'X-1_A (IFR) - Real Title', extension: 'pdf' };
|
||||
const def = await page.evaluate((f) => window.app.modules.classify.defaultTitle(f), zddcFile);
|
||||
expect(def).toBe('Real Title');
|
||||
|
||||
const overridden = await page.evaluate((f) => {
|
||||
const c = window.app.modules.classify;
|
||||
const key = c.srcKeyForFile(f);
|
||||
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)');
|
||||
c.place([key], leaf, 'tracking');
|
||||
c.setTitleOverride(key, 'Custom Title');
|
||||
return c.deriveTarget(f);
|
||||
}, FILE);
|
||||
expect(overridden.title).toBe('Custom Title');
|
||||
expect(overridden.filename).toContain(' - Custom Title.pdf');
|
||||
});
|
||||
|
||||
test('transmittal: party/slot/bin → output path; full target composes', async ({ page }) => {
|
||||
const d = await page.evaluate((file) => {
|
||||
const c = window.app.modules.classify;
|
||||
const key = c.srcKeyForFile(file);
|
||||
// axis 1
|
||||
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||
c.place([key], leaf, 'tracking');
|
||||
// axis 2
|
||||
const party = c.addParty('ClientCorp');
|
||||
const bin = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||
c.place([key], bin, 'transmittal');
|
||||
return { d: c.deriveTarget(file), binName: c.getNode(bin).name };
|
||||
}, FILE);
|
||||
expect(d.binName).toBe('2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal');
|
||||
expect(d.d.party).toBe('ClientCorp');
|
||||
expect(d.d.slot).toBe('received');
|
||||
expect(d.d.outPath).toBe('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal');
|
||||
expect(d.d.complete).toBe(true);
|
||||
});
|
||||
|
||||
test('exclude clears placements and reports excluded state', async ({ page }) => {
|
||||
const r = await page.evaluate((file) => {
|
||||
const c = window.app.modules.classify;
|
||||
const key = c.srcKeyForFile(file);
|
||||
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME'), 'A (IFR)');
|
||||
c.place([key], leaf, 'tracking');
|
||||
c.setExcluded([key], true);
|
||||
return { state: c.fileState(file), d: c.deriveTarget(file) };
|
||||
}, FILE);
|
||||
expect(r.state).toBe('excluded');
|
||||
expect(r.d.excluded).toBe(true);
|
||||
});
|
||||
|
||||
test('deleting a tracking node clears the files placed in it', async ({ page }) => {
|
||||
const after = await page.evaluate((file) => {
|
||||
const c = window.app.modules.classify;
|
||||
const key = c.srcKeyForFile(file);
|
||||
const acme = c.addTrackingNode(null, 'ACME');
|
||||
const leaf = c.addTrackingNode(acme, 'A (IFR)');
|
||||
c.place([key], leaf, 'tracking');
|
||||
c.deleteNode(acme); // removes leaf too
|
||||
return c.fileState(file);
|
||||
}, FILE);
|
||||
expect(after).toBe('none');
|
||||
});
|
||||
Loading…
Reference in a new issue