Compare commits
11 commits
8d8094bb9f
...
5663244fb4
| Author | SHA1 | Date | |
|---|---|---|---|
| 5663244fb4 | |||
| bc762a7d74 | |||
| d951c3a5e7 | |||
| 61c1b4f90d | |||
| 144baeec61 | |||
| 325dce9f3e | |||
| 645b308ebc | |||
| 823cfb0d48 | |||
| 4b79386c4e | |||
| 800454d0db | |||
| 1631e2b8ca |
16 changed files with 1499 additions and 450 deletions
|
|
@ -444,7 +444,11 @@
|
||||||
|
|
||||||
/* placed files under a node */
|
/* placed files under a node */
|
||||||
.tnode__files { margin: 0.1rem 0 0.2rem 1.6rem; }
|
.tnode__files { margin: 0.1rem 0 0.2rem 1.6rem; }
|
||||||
.tfile { display: flex; align-items: baseline; gap: 0.4rem; font-size: 0.75rem; padding: 0.05rem 0; }
|
.tfile { display: flex; align-items: baseline; gap: 0.4rem; font-size: 0.75rem; padding: 0.05rem 0; cursor: grab; }
|
||||||
|
.tfile[draggable="true"]:active { cursor: grabbing; }
|
||||||
|
.tfile__remove { opacity: 0; flex: 0 0 auto; align-self: center; line-height: 1; }
|
||||||
|
.tfile:hover .tfile__remove { opacity: 1; }
|
||||||
|
.tfile__remove:hover { color: var(--danger); border-color: var(--danger); }
|
||||||
.tfile__orig { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 14rem; }
|
.tfile__orig { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 14rem; }
|
||||||
.tfile__arrow { color: var(--text-muted); }
|
.tfile__arrow { color: var(--text-muted); }
|
||||||
.tfile__name { color: var(--text); }
|
.tfile__name { color: var(--text); }
|
||||||
|
|
@ -567,20 +571,72 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ZIP Extract All Button */
|
/* ── Copy destination dialog ────────────────────────────────────────────── */
|
||||||
.zip-extract-all-btn {
|
.copy-choice__backdrop {
|
||||||
margin-left: auto;
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
padding: 0.15rem 0.4rem;
|
background: rgba(0, 0, 0, 0.45);
|
||||||
font-size: 0.7rem;
|
display: flex; align-items: center; justify-content: center; padding: 1rem;
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
}
|
||||||
|
.copy-choice {
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 460px; width: 100%; padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
.copy-choice h3 { margin: 0 0 0.5rem; font-size: 1.15rem; }
|
||||||
|
.copy-choice p { margin: 0 0 1.1rem; color: var(--text-muted); font-size: 0.85rem; line-height: 1.5; }
|
||||||
|
.copy-choice code { font-size: 0.82em; }
|
||||||
|
.copy-choice__select {
|
||||||
|
width: 100%; margin: 0 0 1rem; padding: 0.45rem 0.55rem;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.copy-choice__btns { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; }
|
||||||
|
|
||||||
.folder-item:hover .zip-extract-all-btn {
|
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
|
||||||
opacity: 1;
|
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
|
||||||
|
.ttable { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
||||||
|
.ttable th, .ttable td {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
.ttable thead th {
|
||||||
.zip-extract-all-btn:disabled {
|
position: sticky; top: 0; z-index: 3;
|
||||||
opacity: 0.5;
|
background: var(--bg-secondary, var(--bg));
|
||||||
cursor: wait;
|
color: var(--text-muted);
|
||||||
|
font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||||
|
text-align: left; padding: 0.3rem 0.5rem; white-space: nowrap;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.ttable__rh { color: var(--primary); }
|
||||||
|
.ttable__fileh { width: 99%; } /* the files column soaks up remaining width */
|
||||||
|
.ttable__cell--empty { background: var(--bg-secondary, var(--bg)); }
|
||||||
|
/* The merged-cell value stays pinned just under the header while you scroll the
|
||||||
|
group, so a tall rowspan never reads as a blank column. */
|
||||||
|
.tcell__inner {
|
||||||
|
position: sticky; top: 1.6rem;
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
padding: 0.25rem 0.5rem; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tcell__name { font-weight: 600; }
|
||||||
|
.trev__inner .tcell__name { color: var(--primary); }
|
||||||
|
.tcell__preview { text-decoration: none; cursor: pointer; }
|
||||||
|
.tcell__preview:hover { text-decoration: underline; }
|
||||||
|
.ttable__cell:hover .tnode__actions, .ttable__rev:hover .tnode__actions { opacity: 1; }
|
||||||
|
.ttable .drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
|
||||||
|
.ttable__file { padding: 0.1rem 0.4rem; }
|
||||||
|
.ttable__drop { color: var(--text-muted); font-style: italic; font-size: 0.75rem; }
|
||||||
|
.ttable .tfile { gap: 0.3rem; align-items: center; }
|
||||||
|
.ttable .tfile__name {
|
||||||
|
flex: 1; min-width: 8rem; max-width: 24rem;
|
||||||
|
padding: 0.15rem 0.35rem; border: 1px solid transparent; border-radius: var(--radius);
|
||||||
|
background: transparent; color: var(--text); font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.ttable .tfile__name:hover, .ttable .tfile__name:focus { border-color: var(--border); background: var(--bg); }
|
||||||
|
.ttable .tfile__name--err { color: var(--danger); }
|
||||||
|
.ttable .tfile--err::before { content: none; } /* we render our own badge instead */
|
||||||
|
.tfile__badge { font-size: 0.78rem; flex: 0 0 auto; }
|
||||||
|
.tfile__badge--ok { color: var(--success, #16a34a); }
|
||||||
|
.tfile__badge--err { color: var(--danger); }
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,7 @@
|
||||||
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
|
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
|
||||||
classifyFilters: document.getElementById('classifyFilters'),
|
classifyFilters: document.getElementById('classifyFilters'),
|
||||||
showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'),
|
showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'),
|
||||||
|
showPartialCheckbox: document.getElementById('showPartialCheckbox'),
|
||||||
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
|
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
|
||||||
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
|
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
|
||||||
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
|
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
|
||||||
|
|
@ -181,7 +182,8 @@
|
||||||
modeClassifyBtn: document.getElementById('modeClassifyBtn'),
|
modeClassifyBtn: document.getElementById('modeClassifyBtn'),
|
||||||
spreadsheetPane: document.getElementById('spreadsheetPane'),
|
spreadsheetPane: document.getElementById('spreadsheetPane'),
|
||||||
targetPane: document.getElementById('targetPane'),
|
targetPane: document.getElementById('targetPane'),
|
||||||
copyOutputBtn: document.getElementById('copyOutputBtn')
|
copyOutputBtn: document.getElementById('copyOutputBtn'),
|
||||||
|
checkDuplicatesBtn: document.getElementById('checkDuplicatesBtn')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -354,13 +356,14 @@
|
||||||
if (app.modules.tree && app.modules.tree.setShowFilters) {
|
if (app.modules.tree && app.modules.tree.setShowFilters) {
|
||||||
app.modules.tree.setShowFilters({
|
app.modules.tree.setShowFilters({
|
||||||
unassigned: app.dom.showUnassignedCheckbox.checked,
|
unassigned: app.dom.showUnassignedCheckbox.checked,
|
||||||
|
partial: app.dom.showPartialCheckbox.checked,
|
||||||
assigned: app.dom.showAssignedCheckbox.checked,
|
assigned: app.dom.showAssignedCheckbox.checked,
|
||||||
excluded: app.dom.showExcludedCheckbox.checked,
|
excluded: app.dom.showExcludedCheckbox.checked,
|
||||||
empty: app.dom.showEmptyCheckbox.checked,
|
empty: app.dom.showEmptyCheckbox.checked,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
[app.dom.showUnassignedCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox]
|
[app.dom.showUnassignedCheckbox, app.dom.showPartialCheckbox, app.dom.showAssignedCheckbox, app.dom.showExcludedCheckbox, app.dom.showEmptyCheckbox]
|
||||||
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
|
.forEach(function (cb) { if (cb) cb.addEventListener('change', pushClassifyFilters); });
|
||||||
|
|
||||||
// Collapse tree button
|
// Collapse tree button
|
||||||
|
|
@ -370,6 +373,7 @@
|
||||||
if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); });
|
if (app.dom.modeRenameBtn) app.dom.modeRenameBtn.addEventListener('click', function () { setMode('rename'); });
|
||||||
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
|
if (app.dom.modeClassifyBtn) app.dom.modeClassifyBtn.addEventListener('click', function () { setMode('classify'); });
|
||||||
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
|
if (app.dom.copyOutputBtn) app.dom.copyOutputBtn.addEventListener('click', function () { app.modules.copy.run(); });
|
||||||
|
if (app.dom.checkDuplicatesBtn) app.dom.checkDuplicatesBtn.addEventListener('click', function () { app.modules.copy.audit(); });
|
||||||
|
|
||||||
// Live source-tree filter (matches file path + name; reveals the hierarchy).
|
// Live source-tree filter (matches file path + name; reveals the hierarchy).
|
||||||
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {
|
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,26 @@
|
||||||
return 'n' + (Date.now().toString(36)) + '-' + (++_idSeq).toString(36);
|
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: 'PROJ', 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 ────────────────────────────────────────────────────────────────
|
// ── state ────────────────────────────────────────────────────────────────
|
||||||
var state = {
|
var state = {
|
||||||
enabled: false, // classify mode on/off
|
enabled: false, // classify mode on/off
|
||||||
|
|
@ -36,11 +56,19 @@
|
||||||
trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children)
|
trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children)
|
||||||
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
|
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
|
||||||
outputName: null, // remembered output directory display name
|
outputName: null, // remembered output directory display name
|
||||||
|
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
|
||||||
};
|
};
|
||||||
|
|
||||||
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
|
||||||
var nodeIndex = {};
|
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 ──────────────────────────────────────────────────────────────
|
// ── pub/sub ──────────────────────────────────────────────────────────────
|
||||||
var listeners = [];
|
var listeners = [];
|
||||||
function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; }
|
function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; }
|
||||||
|
|
@ -118,6 +146,7 @@
|
||||||
a.excluded = false; // placing un-excludes
|
a.excluded = false; // placing un-excludes
|
||||||
cleanAssignment(k);
|
cleanAssignment(k);
|
||||||
});
|
});
|
||||||
|
clearHashConflicts(); // a placement changed → stale conflict flags
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
function setExcluded(keys, excluded) {
|
function setExcluded(keys, excluded) {
|
||||||
|
|
@ -127,6 +156,7 @@
|
||||||
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
|
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
|
||||||
cleanAssignment(k);
|
cleanAssignment(k);
|
||||||
});
|
});
|
||||||
|
clearHashConflicts();
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
// Forget any assignment for these source keys (e.g. when a .zip flips
|
// Forget any assignment for these source keys (e.g. when a .zip flips
|
||||||
|
|
@ -385,6 +415,7 @@
|
||||||
trackingTree: state.trackingTree,
|
trackingTree: state.trackingTree,
|
||||||
transmittalTree: state.transmittalTree,
|
transmittalTree: state.transmittalTree,
|
||||||
outputName: state.outputName,
|
outputName: state.outputName,
|
||||||
|
config: state.config,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function load(obj) {
|
function load(obj) {
|
||||||
|
|
@ -393,9 +424,12 @@
|
||||||
state.trackingTree = obj.trackingTree || [];
|
state.trackingTree = obj.trackingTree || [];
|
||||||
state.transmittalTree = obj.transmittalTree || [];
|
state.transmittalTree = obj.transmittalTree || [];
|
||||||
state.outputName = obj.outputName || null;
|
state.outputName = obj.outputName || null;
|
||||||
|
state.config = normalizeConfig(obj.config);
|
||||||
rebuildIndex();
|
rebuildIndex();
|
||||||
notify();
|
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() {
|
function reset() {
|
||||||
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
|
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
|
||||||
state.outputName = null;
|
state.outputName = null;
|
||||||
|
|
@ -403,6 +437,23 @@
|
||||||
notify();
|
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(); }
|
||||||
|
|
||||||
// ── add-folder pattern expansion ─────────────────────────────────────────
|
// ── add-folder pattern expansion ─────────────────────────────────────────
|
||||||
// Brace expansion for the add-folder box. Supports (non-nested) groups:
|
// Brace expansion for the add-folder box. Supports (non-nested) groups:
|
||||||
// {a,b,c} → alternation: a | b | c
|
// {a,b,c} → alternation: a | b | c
|
||||||
|
|
@ -566,6 +617,7 @@
|
||||||
// assignments
|
// assignments
|
||||||
assignmentFor: assignmentFor, getAssignment: getAssignment,
|
assignmentFor: assignmentFor, getAssignment: getAssignment,
|
||||||
place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
|
place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
|
||||||
|
setHashConflicts: setHashConflicts, hasHashConflict: hasHashConflict,
|
||||||
setTitleOverride: setTitleOverride,
|
setTitleOverride: setTitleOverride,
|
||||||
// trees
|
// trees
|
||||||
addTrackingNode: addTrackingNode, addParty: addParty,
|
addTrackingNode: addTrackingNode, addParty: addParty,
|
||||||
|
|
@ -575,6 +627,7 @@
|
||||||
trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
|
trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
|
||||||
transmittalRecord: transmittalRecord,
|
transmittalRecord: transmittalRecord,
|
||||||
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
|
||||||
|
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
|
||||||
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
|
||||||
getTransmittalTree: function () { return state.transmittalTree; },
|
getTransmittalTree: function () { return state.transmittalTree; },
|
||||||
// derive + reverse
|
// derive + reverse
|
||||||
|
|
|
||||||
|
|
@ -88,13 +88,17 @@
|
||||||
return cur;
|
return cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sameContent(existingHandle, srcFileObj) {
|
// Resolve a target subdirectory WITHOUT creating it (null if any segment is
|
||||||
var ef = await existingHandle.getFile();
|
// missing). Lets us check a file's existence cheaply on resume before paying
|
||||||
var sf = await readSource(srcFileObj);
|
// to create the folder chain.
|
||||||
if (ef.size !== sf.size) return false;
|
async function resolveDir(root, relPath, create) {
|
||||||
var a = await window.zddc.crypto.sha256File(ef);
|
var parts = relPath.split('/').filter(Boolean);
|
||||||
var b = await window.zddc.crypto.sha256File(sf);
|
var cur = root;
|
||||||
return a === b;
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
try { cur = await cur.getDirectoryHandle(parts[i], create ? { create: true } : undefined); }
|
||||||
|
catch (e) { if (!create) return null; throw e; }
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a source file's live handle. Fresh-scan files already carry one;
|
// Resolve a source file's live handle. Fresh-scan files already carry one;
|
||||||
|
|
@ -115,75 +119,265 @@
|
||||||
return (await srcHandle(fileObj)).getFile();
|
return (await srcHandle(fileObj)).getFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone).
|
// Copy one file. Returns 'copied' | 'skipped' (already present → resumable).
|
||||||
async function copyOne(out, p) {
|
// The existence check is a cheap stat/HEAD; a present target is left as-is so
|
||||||
var dir = await ensureDir(out, p.d.outPath);
|
// re-running after an interruption skips the work already done — no source
|
||||||
var existing = null;
|
// read, no hashing. (Canonical ZDDC names ⇒ same name = same document, and
|
||||||
try { existing = await dir.getFileHandle(p.d.filename); } catch (e) { /* NotFound → fresh copy */ }
|
// the server archive is WORM, so we never overwrite.)
|
||||||
if (existing) {
|
// SHA-256 of a source file's bytes, cached on the file object (reused by the
|
||||||
return (await sameContent(existing, p.file)) ? 'skipped' : 'differ';
|
// duplicate-conflict audit AND the post-copy verify).
|
||||||
|
async function sourceSha(fileObj) {
|
||||||
|
if (fileObj.sha256) return fileObj.sha256;
|
||||||
|
var blob = await readSource(fileObj);
|
||||||
|
var h = await window.zddc.crypto.sha256File(blob);
|
||||||
|
fileObj.sha256 = h;
|
||||||
|
return h;
|
||||||
}
|
}
|
||||||
|
async function writeTarget(out, p) {
|
||||||
|
var dir = await ensureDir(out, p.d.outPath);
|
||||||
var srcFile = await readSource(p.file); // READ source (never write it)
|
var srcFile = await readSource(p.file); // READ source (never write it)
|
||||||
var fh = await dir.getFileHandle(p.d.filename, { create: true });
|
var fh = await dir.getFileHandle(p.d.filename, { create: true });
|
||||||
var w = await fh.createWritable();
|
var w = await fh.createWritable();
|
||||||
await w.write(srcFile);
|
await w.write(srcFile);
|
||||||
await w.close();
|
await w.close();
|
||||||
|
}
|
||||||
|
async function copyOne(out, p) {
|
||||||
|
// Cheap existence probe: resolve the dir WITHOUT creating it (the HTTP
|
||||||
|
// handle doesn't verify here, but getFileHandle below does a HEAD).
|
||||||
|
var probe = await resolveDir(out, p.d.outPath, false);
|
||||||
|
if (probe) {
|
||||||
|
try { await probe.getFileHandle(p.d.filename); return 'skipped'; }
|
||||||
|
catch (e) { /* NotFound → write it below */ }
|
||||||
|
}
|
||||||
|
await writeTarget(out, p);
|
||||||
return 'copied';
|
return 'copied';
|
||||||
}
|
}
|
||||||
|
// Read the written target back and compare its SHA-256 to the source.
|
||||||
|
async function verifyOne(out, p) {
|
||||||
|
var dir = await resolveDir(out, p.d.outPath, false);
|
||||||
|
if (!dir) return false;
|
||||||
|
var fh; try { fh = await dir.getFileHandle(p.d.filename); } catch (e) { return false; }
|
||||||
|
var th = await window.zddc.crypto.sha256File(await fh.getFile());
|
||||||
|
return th === (await sourceSha(p.file));
|
||||||
|
}
|
||||||
|
async function removeTarget(out, p) {
|
||||||
|
var dir = await resolveDir(out, p.d.outPath, false);
|
||||||
|
if (dir && dir.removeEntry) { try { await dir.removeEntry(p.d.filename); } catch (e) { /* best effort */ } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot-loaded files have no live handle — re-grant read on the source
|
||||||
|
// (one click) before we read any bytes (hashing or copying). Returns false
|
||||||
|
// if the source can't be read.
|
||||||
|
async function ensureSourceReadable(items) {
|
||||||
|
if (!items.some(function (p) { return !p.file.handle; })) return true;
|
||||||
|
if (!window.app.rootHandle) {
|
||||||
|
toast('The source directory isn’t connected. Re-open the workspace to reconnect it.', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var ok = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false);
|
||||||
|
if (!ok) { toast('Permission to read the source directory was denied.', 'error'); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group fully-classified files by their canonical output name. Files with the
|
||||||
|
// SAME tracking number + revision MUST have the same content: identical bytes
|
||||||
|
// collapse to a single copy; differing bytes are a CONFLICT the user must fix.
|
||||||
|
async function resolvePlan(items) {
|
||||||
|
var by = {};
|
||||||
|
items.forEach(function (p) { (by[p.outRel] = by[p.outRel] || []).push(p); });
|
||||||
|
var todo = [], conflicts = [], conflictKeys = {}, dupeCount = 0, keys = Object.keys(by);
|
||||||
|
for (var i = 0; i < keys.length; i++) {
|
||||||
|
var group = by[keys[i]];
|
||||||
|
if (group.length === 1) { todo.push(group[0]); continue; }
|
||||||
|
var hashes = [], bad = false;
|
||||||
|
for (var j = 0; j < group.length; j++) {
|
||||||
|
try { hashes.push(await sourceSha(group[j].file)); } catch (e) { bad = true; hashes.push('ERR' + j); }
|
||||||
|
}
|
||||||
|
var distinct = {}; hashes.forEach(function (h) { distinct[h] = true; });
|
||||||
|
if (!bad && Object.keys(distinct).length === 1) {
|
||||||
|
todo.push(group[0]); dupeCount += group.length - 1; // identical → one copy
|
||||||
|
} else {
|
||||||
|
conflicts.push(keys[i]);
|
||||||
|
group.forEach(function (g) { conflictKeys[g.d.key] = true; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { todo: todo, conflicts: conflicts, conflictKeys: conflictKeys, dupeCount: dupeCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-flight shared by Copy and the standalone "Check" button: hash colliding
|
||||||
|
// names, flag conflicts in the UI, return the deduped todo (or null to abort).
|
||||||
|
async function preflight(verb) {
|
||||||
|
var items = plan();
|
||||||
|
if (!items.length) {
|
||||||
|
toast('Nothing ' + verb + ' yet — no files are fully classified (need a tracking leaf AND a transmittal).', 'warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!(await ensureSourceReadable(items))) return null;
|
||||||
|
setStatus('Checking for same-name/different-content conflicts…');
|
||||||
|
var r = await resolvePlan(items);
|
||||||
|
setStatus('');
|
||||||
|
C().setHashConflicts(r.conflictKeys);
|
||||||
|
if (r.conflicts.length) {
|
||||||
|
toast(r.conflicts.length + ' same-name/different-content conflict(s) flagged (≠ in red): same tracking+revision, different bytes. Fix these before copying.', 'error');
|
||||||
|
}
|
||||||
|
if (r.dupeCount) toast(r.dupeCount + ' exact duplicate(s) collapse to one copy.', 'info');
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone audit (the "Check" button) — flag conflicts without copying.
|
||||||
|
async function audit() {
|
||||||
|
var r = await preflight('to check');
|
||||||
|
if (r && !r.conflicts.length) {
|
||||||
|
toast('No conflicts — ' + r.todo.length + ' file' + (r.todo.length === 1 ? '' : 's') + ' ready to copy.', 'success');
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!C().isEnabled()) return;
|
if (!C().isEnabled()) return;
|
||||||
var items = plan();
|
var r = await preflight('to copy');
|
||||||
if (!items.length) {
|
if (!r) return;
|
||||||
toast('Nothing to copy yet — no files are fully classified (need both a tracking leaf and a transmittal).', 'warning');
|
var todo = r.todo;
|
||||||
return;
|
if (!todo.length) { if (r.conflicts.length) toast('Resolve the flagged conflicts, then copy.', 'warning'); return; }
|
||||||
}
|
|
||||||
var cf = conflictsIn(items);
|
|
||||||
var blocked = {};
|
|
||||||
cf.conflicts.forEach(function (path) { blocked[path] = true; });
|
|
||||||
var todo = items.filter(function (p) { return !blocked[p.outRel]; });
|
|
||||||
|
|
||||||
if (cf.conflicts.length) {
|
// Where to file the canonical copies: the server archive (HTTP) or a local
|
||||||
toast(cf.conflicts.length + ' output-name collision(s) — two source files map to the same name. Skipped:\n'
|
// folder. Both read the source, never write it, both resumable + verified.
|
||||||
+ cf.conflicts.join('\n'), 'error');
|
var dest = await chooseDestination(todo.length);
|
||||||
}
|
if (!dest) return;
|
||||||
if (!todo.length) return;
|
return dest === 'server' ? copyToServer(todo) : copyToLocal(todo);
|
||||||
|
|
||||||
// Snapshot-loaded files have no live handle — re-grant read on the
|
|
||||||
// workspace source directory (one click) before copying.
|
|
||||||
if (todo.some(function (p) { return !p.file.handle; })) {
|
|
||||||
if (!window.app.rootHandle) {
|
|
||||||
toast('The source directory isn’t connected. Re-open the workspace to reconnect it.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var srcOk = await window.app.modules.persist.verifyPermission(window.app.rootHandle, false);
|
|
||||||
if (!srcOk) { toast('Permission to read the source directory was denied.', 'error'); return; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summary(s, where) {
|
||||||
|
var msg = 'Copy to ' + where + ' — ' + s.copied + ' copied & verified, ' + s.skipped + ' already there'
|
||||||
|
+ (s.verifyFailed ? (', ' + s.verifyFailed + ' FAILED verification (bad copy removed — re-run)') : '')
|
||||||
|
+ (s.errors ? (', ' + s.errors + ' errored (retry to resume)') : '') + '.';
|
||||||
|
toast(msg, (s.errors || s.verifyFailed) ? 'warning' : 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToLocal(todo) {
|
||||||
var out = outputHandle || await chooseOutput();
|
var out = outputHandle || await chooseOutput();
|
||||||
if (!out) return;
|
if (!out) return;
|
||||||
if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\nThe source directory is not modified.')) return;
|
if (!confirm('Copy ' + todo.length + ' file(s) into "' + out.name + '"?\n\n'
|
||||||
|
+ 'Written under <party>/<received|issued>/<transmittal>/ — pick your archive/ folder to file them directly. '
|
||||||
|
+ 'Re-running resumes (already-copied files are skipped). The source is not modified.')) return;
|
||||||
var s = await copyTo(out, todo);
|
var s = await copyTo(out, todo);
|
||||||
|
summary(s, '"' + out.name + '"');
|
||||||
var msg = 'Copy complete — ' + s.copied + ' copied, ' + s.skipped + ' identical skipped'
|
|
||||||
+ (s.differ ? (', ' + s.differ + ' already exist with different content (left untouched)') : '')
|
|
||||||
+ (s.errors ? (', ' + s.errors + ' errors') : '') + '.';
|
|
||||||
toast(msg, (s.errors || s.differ) ? 'warning' : 'success');
|
|
||||||
if (s.differing.length) toast('Existing-but-different (not overwritten):\n' + s.differing.join('\n'), 'warning');
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy straight into a project's archive on the server over HTTP (PUT per
|
||||||
|
// file, mkdir as needed). Uses the zddc-source HTTP handle, so the SAME copy
|
||||||
|
// engine writes <party>/<received|issued>/<transmittal>/<name> under
|
||||||
|
// <project>/archive/. The user picks any project they can access.
|
||||||
|
async function copyToServer(todo) {
|
||||||
|
var src = window.zddc && window.zddc.source;
|
||||||
|
if (!src || location.protocol === 'file:') {
|
||||||
|
toast('Server copy needs the classifier to be served by a zddc-server (open it over http).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var projects = await fetchAccessProjects();
|
||||||
|
if (projects == null) { toast('Could not load your projects from the server.', 'error'); return; }
|
||||||
|
if (!projects.length) { toast('No projects you can access on this server.', 'warning'); return; }
|
||||||
|
var proj = await chooseProject(projects);
|
||||||
|
if (!proj) return;
|
||||||
|
var archive;
|
||||||
|
try {
|
||||||
|
var rel = proj.url || ('/' + proj.name + '/');
|
||||||
|
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
||||||
|
archive = new URL(rel + 'archive/', location.origin).href;
|
||||||
|
} catch (e) { toast('Bad project URL — ' + (e.message || e), 'error'); return; }
|
||||||
|
var out = new src.HttpDirectoryHandle(archive, 'archive');
|
||||||
|
var s = await copyTo(out, todo);
|
||||||
|
summary(s, (proj.title || proj.name) + ' / archive');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// The caller's accessible projects (read view from /.profile/access). Write
|
||||||
|
// permission is enforced server-side on PUT, so a 403 surfaces per file.
|
||||||
|
async function fetchAccessProjects() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/.profile/access', { headers: { 'Accept': 'application/json' }, credentials: 'same-origin', cache: 'no-cache' });
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
if ((resp.headers.get('Content-Type') || '').toLowerCase().indexOf('json') === -1) return null;
|
||||||
|
var data = await resp.json();
|
||||||
|
return Array.isArray(data.projects) ? data.projects : [];
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function chooseProject(projects) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var done = false;
|
||||||
|
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }
|
||||||
|
function onKey(e) { if (e.key === 'Escape') finish(null); }
|
||||||
|
var back = document.createElement('div'); back.className = 'copy-choice__backdrop';
|
||||||
|
var box = document.createElement('div'); box.className = 'copy-choice';
|
||||||
|
var h = document.createElement('h3'); h.textContent = 'Copy to a project archive';
|
||||||
|
var p = document.createElement('p');
|
||||||
|
p.innerHTML = 'Files go to <code><project>/archive/<party>/<received|issued>/<transmittal>/</code>. Pick a project you can access.';
|
||||||
|
var sel = document.createElement('select'); sel.className = 'copy-choice__select';
|
||||||
|
projects.forEach(function (pr, i) {
|
||||||
|
var o = document.createElement('option'); o.value = String(i);
|
||||||
|
o.textContent = pr.name + (pr.title ? ' — ' + pr.title : '');
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
var row = document.createElement('div'); row.className = 'copy-choice__btns';
|
||||||
|
var go = document.createElement('button'); go.className = 'btn btn-primary'; go.textContent = 'Copy here';
|
||||||
|
go.addEventListener('click', function () { finish(projects[Number(sel.value)] || null); });
|
||||||
|
var cancel = document.createElement('button'); cancel.className = 'btn btn-secondary'; cancel.textContent = 'Cancel';
|
||||||
|
cancel.addEventListener('click', function () { finish(null); });
|
||||||
|
row.appendChild(go); row.appendChild(cancel);
|
||||||
|
box.appendChild(h); box.appendChild(p); box.appendChild(sel); box.appendChild(row);
|
||||||
|
back.appendChild(box);
|
||||||
|
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.body.appendChild(back);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny modal: choose server archive vs local folder. Resolves 'server' |
|
||||||
|
// 'local' | null. The server option is offered only over http(s).
|
||||||
|
function chooseDestination(n) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var done = false;
|
||||||
|
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }
|
||||||
|
function onKey(e) { if (e.key === 'Escape') finish(null); }
|
||||||
|
var onServer = location.protocol === 'http:' || location.protocol === 'https:';
|
||||||
|
var back = document.createElement('div'); back.className = 'copy-choice__backdrop';
|
||||||
|
var box = document.createElement('div'); box.className = 'copy-choice';
|
||||||
|
var h = document.createElement('h3');
|
||||||
|
h.textContent = 'Copy ' + n + ' classified file' + (n === 1 ? '' : 's');
|
||||||
|
var p = document.createElement('p');
|
||||||
|
p.innerHTML = 'Filed under <code><party>/<received|issued>/<transmittal>/<name></code>. '
|
||||||
|
+ 'Re-running resumes — files already present at the destination are skipped.';
|
||||||
|
var row = document.createElement('div'); row.className = 'copy-choice__btns';
|
||||||
|
function btn(label, cls, val, disabled) {
|
||||||
|
var b = document.createElement('button'); b.className = 'btn ' + cls; b.textContent = label;
|
||||||
|
if (disabled) { b.disabled = true; b.title = 'Open the classifier over a zddc-server to enable this'; }
|
||||||
|
else b.addEventListener('click', function () { finish(val); });
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
row.appendChild(btn('☁ Copy to server archive', 'btn-primary', 'server', !onServer));
|
||||||
|
row.appendChild(btn('📁 Copy to a local folder…', onServer ? 'btn-secondary' : 'btn-primary', 'local'));
|
||||||
|
row.appendChild(btn('Cancel', 'btn-secondary', null));
|
||||||
|
box.appendChild(h); box.appendChild(p); box.appendChild(row);
|
||||||
|
back.appendChild(box);
|
||||||
|
back.addEventListener('click', function (e) { if (e.target === back) finish(null); });
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.body.appendChild(back);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Run the copy loop over a ready list against an output handle. No picker,
|
// Run the copy loop over a ready list against an output handle. No picker,
|
||||||
// no confirm — that's run()'s job; this is the engine (and the test seam).
|
// no confirm — that's run()'s job; this is the engine (and the test seam).
|
||||||
|
// Resumable: copyOne skips targets that already exist, so a re-run after an
|
||||||
|
// interruption only does the remaining work.
|
||||||
async function copyTo(out, todo) {
|
async function copyTo(out, todo) {
|
||||||
var s = { copied: 0, skipped: 0, differ: 0, errors: 0, differing: [] };
|
var s = { copied: 0, skipped: 0, errors: 0, verifyFailed: 0 };
|
||||||
|
var copied = [];
|
||||||
for (var i = 0; i < todo.length; i++) {
|
for (var i = 0; i < todo.length; i++) {
|
||||||
setStatus('Copying… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename);
|
setStatus('Copying… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename);
|
||||||
try {
|
try {
|
||||||
var r = await copyOne(out, todo[i]);
|
var r = await copyOne(out, todo[i]);
|
||||||
s[r]++;
|
s[r]++;
|
||||||
if (r === 'differ') s.differing.push(todo[i].outRel);
|
if (r === 'copied') copied.push(todo[i]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
s.errors++;
|
s.errors++;
|
||||||
if (window.zddc && window.zddc.toast) {
|
if (window.zddc && window.zddc.toast) {
|
||||||
|
|
@ -191,6 +385,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Verification pass over JUST the files copied this run: read each target
|
||||||
|
// back, compare SHA-256 to the source. One re-copy attempt on mismatch;
|
||||||
|
// if it still fails, remove the bad target so a re-run re-copies it — so
|
||||||
|
// resume converges on a fully-correct archive.
|
||||||
|
for (var k = 0; k < copied.length; k++) {
|
||||||
|
setStatus('Verifying… ' + (k + 1) + '/' + copied.length + ' — ' + copied[k].d.filename);
|
||||||
|
try {
|
||||||
|
if (await verifyOne(out, copied[k])) continue;
|
||||||
|
await writeTarget(out, copied[k]);
|
||||||
|
if (await verifyOne(out, copied[k])) continue;
|
||||||
|
s.verifyFailed++;
|
||||||
|
await removeTarget(out, copied[k]);
|
||||||
|
if (window.zddc && window.zddc.toast) {
|
||||||
|
window.zddc.toast('Verification failed for ' + copied[k].outRel + ' — removed the bad copy; re-run to retry.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
s.verifyFailed++;
|
||||||
|
if (window.zddc && window.zddc.toast) {
|
||||||
|
window.zddc.toast('Verify error for ' + copied[k].outRel + ' — ' + (e.message || e), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
setStatus('');
|
setStatus('');
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
@ -199,11 +415,13 @@
|
||||||
|
|
||||||
window.app.modules.copy = {
|
window.app.modules.copy = {
|
||||||
run: run,
|
run: run,
|
||||||
|
audit: audit,
|
||||||
readyCount: readyCount,
|
readyCount: readyCount,
|
||||||
chooseOutput: chooseOutput,
|
chooseOutput: chooseOutput,
|
||||||
// test/advanced seams
|
// test/advanced seams
|
||||||
plan: plan,
|
plan: plan,
|
||||||
conflictsIn: conflictsIn,
|
conflictsIn: conflictsIn,
|
||||||
|
resolvePlan: resolvePlan,
|
||||||
copyTo: copyTo,
|
copyTo: copyTo,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -410,10 +410,18 @@
|
||||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
// Make the content area a height-constrained flex column so the table
|
||||||
|
// scroller below fills the viewport — its horizontal scrollbar then
|
||||||
|
// sits at the window bottom instead of at the bottom of a tall sheet.
|
||||||
|
container.style.display = 'flex';
|
||||||
|
container.style.flexDirection = 'column';
|
||||||
|
container.style.minHeight = '0';
|
||||||
|
container.style.overflow = 'hidden';
|
||||||
|
|
||||||
if (workbook.SheetNames.length > 1) {
|
if (workbook.SheetNames.length > 1) {
|
||||||
const tabs = previewWindow.document.createElement('div');
|
const tabs = previewWindow.document.createElement('div');
|
||||||
tabs.className = 'sheet-tabs';
|
tabs.className = 'sheet-tabs';
|
||||||
|
tabs.style.flexShrink = '0';
|
||||||
workbook.SheetNames.forEach((name, i) => {
|
workbook.SheetNames.forEach((name, i) => {
|
||||||
const tab = previewWindow.document.createElement('button');
|
const tab = previewWindow.document.createElement('button');
|
||||||
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
|
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
|
||||||
|
|
@ -430,6 +438,7 @@
|
||||||
|
|
||||||
const tableContainer = previewWindow.document.createElement('div');
|
const tableContainer = previewWindow.document.createElement('div');
|
||||||
tableContainer.style.flex = '1';
|
tableContainer.style.flex = '1';
|
||||||
|
tableContainer.style.minHeight = '0'; // allow it to shrink so overflow scrolls
|
||||||
tableContainer.style.overflow = 'auto';
|
tableContainer.style.overflow = 'auto';
|
||||||
container.appendChild(tableContainer);
|
container.appendChild(tableContainer);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,14 +158,18 @@
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Placed files inside a transmittal bin. Each row is draggable (drag onto
|
||||||
|
// another bin to MOVE it) and carries an ✕ to remove it from the transmittal.
|
||||||
function fileList(files) {
|
function fileList(files) {
|
||||||
var box = el('div', 'tnode__files');
|
var box = el('div', 'tnode__files');
|
||||||
files.forEach(function (f) {
|
files.forEach(function (f) {
|
||||||
var d = C().deriveTarget(f);
|
var d = C().deriveTarget(f);
|
||||||
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
|
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
|
||||||
row.dataset.key = d.key;
|
row.dataset.key = d.key;
|
||||||
|
row.draggable = true;
|
||||||
|
row.addEventListener('dragstart', function (e) { window.app.modules.dnd.setDrag([d.key], e); });
|
||||||
var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''));
|
var orig = el('span', 'tfile__orig', f.originalFilename + (f.extension ? '.' + f.extension : ''));
|
||||||
orig.title = 'Click to preview';
|
orig.title = 'Drag to another transmittal to move · click to preview';
|
||||||
row.appendChild(orig);
|
row.appendChild(orig);
|
||||||
row.appendChild(el('span', 'tfile__arrow', '→'));
|
row.appendChild(el('span', 'tfile__arrow', '→'));
|
||||||
// Editable derived filename — edit it to re-file the item.
|
// Editable derived filename — edit it to re-file the item.
|
||||||
|
|
@ -175,6 +179,10 @@
|
||||||
name.placeholder = '(incomplete)';
|
name.placeholder = '(incomplete)';
|
||||||
name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item';
|
name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item';
|
||||||
row.appendChild(name);
|
row.appendChild(name);
|
||||||
|
var rm = el('button', 'tnode__act tfile__remove', '✕');
|
||||||
|
rm.dataset.act = 'untransmit';
|
||||||
|
rm.title = 'Remove from this transmittal';
|
||||||
|
row.appendChild(rm);
|
||||||
box.appendChild(row);
|
box.appendChild(row);
|
||||||
});
|
});
|
||||||
return box;
|
return box;
|
||||||
|
|
@ -199,51 +207,165 @@
|
||||||
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
|
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tracking tree (recursive, filter-aware — a match reveals its whole path).
|
// ── By-tracking: merged-cell table ──────────────────────────────────────
|
||||||
function renderTrackingInto(container, nodes, placedMap) {
|
// The positional hierarchy reads left-to-right as columns (one per configured
|
||||||
container.textContent = '';
|
// field), ancestor cells span their descendants' rows, and the revision (the
|
||||||
if (!nodes.length) {
|
// leaf) gets its own aligned column. Each placed file is a row.
|
||||||
container.appendChild(el('div', 'target-empty', 'No tracking folders yet — “+ Root folder” to start.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nodes.forEach(function (n) { var e = trackingNode(n, placedMap, false); if (e) container.appendChild(e); });
|
|
||||||
if (rfActive() && !container.children.length) {
|
|
||||||
container.appendChild(el('div', 'target-empty', 'No matches in the tracking tree.'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function trackingNode(n, placedMap, ancMatched) {
|
|
||||||
var matched = ancMatched || rfHit(n.name);
|
|
||||||
var isLeaf = (n.children || []).length === 0;
|
|
||||||
var expanded = !collapsed[n.id] || rfActive(); // auto-expand to reveal matches
|
|
||||||
var childEls = [];
|
|
||||||
if (expanded || rfActive()) {
|
|
||||||
(n.children || []).forEach(function (c) { var ce = trackingNode(c, placedMap, matched); if (ce) childEls.push(ce); });
|
|
||||||
}
|
|
||||||
var placed = placedMap[n.id] || [];
|
|
||||||
var shownFiles = (rfActive() && !matched) ? placed.filter(fileRowMatches) : placed;
|
|
||||||
if (rfActive() && !matched && !childEls.length && !shownFiles.length) return null;
|
|
||||||
|
|
||||||
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : ''));
|
// A node is a revision leaf when its name ends in a "(STATUS)" we recognise —
|
||||||
wrap.dataset.id = n.id;
|
// tracking field codes never carry a parenthesised status, so this cleanly
|
||||||
var row = el('div', 'tnode__row');
|
// separates "0001" (a SEQ field) from "A (IFR)" (a revision).
|
||||||
var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (expanded ? '▾' : '▸'));
|
function revStatusOf(name) {
|
||||||
if (!isLeaf) toggle.dataset.act = 'toggle';
|
var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || '');
|
||||||
row.appendChild(toggle);
|
return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null;
|
||||||
row.appendChild(el('span', 'tnode__name', n.name));
|
}
|
||||||
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
|
function isRevisionLeaf(node) {
|
||||||
row.appendChild(nodeActions([
|
return !(node.children || []).length && revStatusOf(node.name) != null;
|
||||||
{ act: 'add', label: '+', title: 'Add child folder' },
|
}
|
||||||
|
// Flatten the tree into rows: { path:[fieldNodes], rev:revNode|null, file }.
|
||||||
|
function buildTrackingRows(nodes, placedMap) {
|
||||||
|
var rows = [];
|
||||||
|
function emit(path, rev, files) {
|
||||||
|
var fs = (files && files.length) ? files : [null];
|
||||||
|
fs.forEach(function (f) { rows.push({ path: path, rev: rev, file: f }); });
|
||||||
|
}
|
||||||
|
function walk(node, ancestors) {
|
||||||
|
var placed = placedMap[node.id] || [];
|
||||||
|
if (isRevisionLeaf(node)) { emit(ancestors, node, placed); return; }
|
||||||
|
var myPath = ancestors.concat(node); // node is a tracking field segment
|
||||||
|
if (placed.length) emit(myPath, null, placed); // files dropped on a partial number
|
||||||
|
var kids = node.children || [];
|
||||||
|
if (kids.length) kids.forEach(function (c) { walk(c, myPath); });
|
||||||
|
else if (!placed.length) emit(myPath, null, []); // empty leaf = drop target
|
||||||
|
}
|
||||||
|
nodes.forEach(function (n) { walk(n, []); });
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
function rowMatches(row) {
|
||||||
|
if (!rfActive()) return true;
|
||||||
|
if (row.file && fileRowMatches(row.file)) return true;
|
||||||
|
if (row.rev && rfHit(row.rev.name)) return true;
|
||||||
|
for (var i = 0; i < row.path.length; i++) { if (rfHit(row.path[i].name)) return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldCellContent(node) {
|
||||||
|
var inner = el('div', 'tcell__inner');
|
||||||
|
inner.appendChild(el('span', 'tcell__name', node.name));
|
||||||
|
inner.appendChild(nodeActions([
|
||||||
|
{ act: 'add', label: '+', title: 'Add child segment / revision' },
|
||||||
{ act: 'rename', label: '✎', title: 'Rename' },
|
{ act: 'rename', label: '✎', title: 'Rename' },
|
||||||
{ act: 'del', label: '🗑', title: 'Delete' },
|
{ act: 'del', label: '🗑', title: 'Delete' },
|
||||||
]));
|
]));
|
||||||
wrap.appendChild(row);
|
return inner;
|
||||||
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
|
|
||||||
if (!isLeaf && expanded && childEls.length) {
|
|
||||||
var kids = el('div', 'tnode__children');
|
|
||||||
childEls.forEach(function (ce) { kids.appendChild(ce); });
|
|
||||||
wrap.appendChild(kids);
|
|
||||||
}
|
}
|
||||||
return wrap;
|
function revCellContent(node, placedMap) {
|
||||||
|
var inner = el('div', 'tcell__inner trev__inner');
|
||||||
|
// The revision name doubles as a preview link for its placed file (the
|
||||||
|
// common case is one file per revision). No count bubble.
|
||||||
|
var files = placedMap[node.id] || [];
|
||||||
|
if (files.length) {
|
||||||
|
var link = el('a', 'tcell__name tcell__preview', node.name);
|
||||||
|
link.href = '#';
|
||||||
|
link.dataset.previewKey = C().srcKeyForFile(files[0]);
|
||||||
|
link.title = 'Preview ' + files[0].originalFilename + (files[0].extension ? '.' + files[0].extension : '');
|
||||||
|
inner.appendChild(link);
|
||||||
|
} else {
|
||||||
|
inner.appendChild(el('span', 'tcell__name', node.name));
|
||||||
|
}
|
||||||
|
inner.appendChild(nodeActions([
|
||||||
|
{ act: 'rename', label: '✎', title: 'Rename revision' },
|
||||||
|
{ act: 'del', label: '🗑', title: 'Delete' },
|
||||||
|
]));
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
// A placed-file cell: editable ZDDC name + validation badge; the original
|
||||||
|
// filename is on hover, not shown inline. Reuses .tfile/.tfile__name so the
|
||||||
|
// delegated preview + name-edit handlers apply.
|
||||||
|
function fileCellContent(f) {
|
||||||
|
var d = C().deriveTarget(f);
|
||||||
|
var conflict = C().hasHashConflict(d.key); // same name, different bytes
|
||||||
|
var bad = d.errors.length || conflict;
|
||||||
|
var row = el('div', 'tfile' + (bad ? ' tfile--err' : ''));
|
||||||
|
row.dataset.key = d.key;
|
||||||
|
var orig = f.originalFilename + (f.extension ? '.' + f.extension : '');
|
||||||
|
var name = el('input', 'tfile__name' + (bad ? ' tfile__name--err' : ''));
|
||||||
|
name.type = 'text';
|
||||||
|
name.value = d.filename || '';
|
||||||
|
name.placeholder = '(incomplete)';
|
||||||
|
name.title = (conflict ? 'Same tracking+revision as another file but DIFFERENT content — fix before copying · ' : '')
|
||||||
|
+ (d.errors.length ? d.errors.join('; ') + ' · ' : '') + 'original: ' + orig;
|
||||||
|
row.appendChild(name);
|
||||||
|
row.appendChild(el('span', 'tfile__badge' + (bad ? ' tfile__badge--err' : ' tfile__badge--ok'),
|
||||||
|
conflict ? '≠' : (d.errors.length ? '⚠' : '✓')));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrackingInto(container, nodes, placedMap) {
|
||||||
|
container.textContent = '';
|
||||||
|
if (!nodes.length) {
|
||||||
|
container.appendChild(el('div', 'target-empty', 'No tracking numbers yet — “+ Root folder” to start.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rows = buildTrackingRows(nodes, placedMap).filter(rowMatches);
|
||||||
|
if (!rows.length) {
|
||||||
|
container.appendChild(el('div', 'target-empty', rfActive() ? 'No matches in the tracking tree.' : 'No tracking numbers yet.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var fields = C().getTrackingFields();
|
||||||
|
var maxPath = rows.reduce(function (m, r) { return Math.max(m, r.path.length); }, 0);
|
||||||
|
var nCols = Math.max(fields.length, maxPath);
|
||||||
|
|
||||||
|
function cellId(row, col) {
|
||||||
|
if (col < nCols) { var n = row.path[col]; return n ? n.id : null; }
|
||||||
|
return row.rev ? row.rev.id : null; // col === nCols → revision
|
||||||
|
}
|
||||||
|
// Rowspan run starting at row i for column col (0 = covered from above).
|
||||||
|
function spanAt(col, i) {
|
||||||
|
var id = cellId(rows[i], col);
|
||||||
|
if (id == null) return 1;
|
||||||
|
if (i > 0 && cellId(rows[i - 1], col) === id) return 0;
|
||||||
|
var span = 1;
|
||||||
|
for (var j = i + 1; j < rows.length; j++) { if (cellId(rows[j], col) === id) span++; else break; }
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = el('table', 'ttable');
|
||||||
|
var thead = el('thead'), htr = el('tr');
|
||||||
|
for (var c = 0; c < nCols; c++) {
|
||||||
|
htr.appendChild(el('th', 'ttable__fh', fields[c] ? fields[c].name + (fields[c].optional ? ' ?' : '') : '·'));
|
||||||
|
}
|
||||||
|
htr.appendChild(el('th', 'ttable__rh', 'REVISION'));
|
||||||
|
htr.appendChild(el('th', 'ttable__fileh', 'Files'));
|
||||||
|
thead.appendChild(htr); table.appendChild(thead);
|
||||||
|
|
||||||
|
var tbody = el('tbody');
|
||||||
|
rows.forEach(function (row, i) {
|
||||||
|
var tr = el('tr');
|
||||||
|
for (var col = 0; col < nCols; col++) {
|
||||||
|
var span = spanAt(col, i);
|
||||||
|
if (span === 0) continue; // merged from the row above
|
||||||
|
var node = row.path[col] || null;
|
||||||
|
var td = el('td', 'ttable__cell' + (node ? '' : ' ttable__cell--empty'));
|
||||||
|
if (span > 1) td.rowSpan = span;
|
||||||
|
if (node) { td.dataset.id = node.id; td.appendChild(fieldCellContent(node)); }
|
||||||
|
tr.appendChild(td);
|
||||||
|
}
|
||||||
|
var rspan = spanAt(nCols, i);
|
||||||
|
if (rspan !== 0) {
|
||||||
|
var rtd = el('td', 'ttable__rev' + (row.rev ? '' : ' ttable__cell--empty'));
|
||||||
|
if (rspan > 1) rtd.rowSpan = rspan;
|
||||||
|
if (row.rev) { rtd.dataset.id = row.rev.id; rtd.appendChild(revCellContent(row.rev, placedMap)); }
|
||||||
|
tr.appendChild(rtd);
|
||||||
|
}
|
||||||
|
var ftd = el('td', 'ttable__file');
|
||||||
|
if (row.file) ftd.appendChild(fileCellContent(row.file));
|
||||||
|
else ftd.appendChild(el('span', 'ttable__drop', 'drop a file here'));
|
||||||
|
tr.appendChild(ftd);
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
table.appendChild(tbody);
|
||||||
|
container.appendChild(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transmittal tree
|
// Transmittal tree
|
||||||
|
|
@ -307,7 +429,10 @@
|
||||||
var row = el('div', 'tnode__row');
|
var row = el('div', 'tnode__row');
|
||||||
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
|
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
|
||||||
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
|
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
|
||||||
row.appendChild(nodeActions([{ act: 'del', label: '🗑', title: 'Delete transmittal' }]));
|
row.appendChild(nodeActions([
|
||||||
|
{ act: 'rename-bin', label: '✎', title: 'Rename transmittal' },
|
||||||
|
{ act: 'del', label: '🗑', title: 'Delete transmittal' },
|
||||||
|
]));
|
||||||
wrap.appendChild(row);
|
wrap.appendChild(row);
|
||||||
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
|
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
|
||||||
return wrap;
|
return wrap;
|
||||||
|
|
@ -334,7 +459,7 @@
|
||||||
|
|
||||||
// ── events ─────────────────────────────────────────────────────────────
|
// ── events ─────────────────────────────────────────────────────────────
|
||||||
function closestNodeId(target) {
|
function closestNodeId(target) {
|
||||||
var n = target.closest('.tnode');
|
var n = target.closest('[data-id]');
|
||||||
return n ? n.dataset.id : null;
|
return n ? n.dataset.id : null;
|
||||||
}
|
}
|
||||||
function fileByKey(key) {
|
function fileByKey(key) {
|
||||||
|
|
@ -344,6 +469,17 @@
|
||||||
}
|
}
|
||||||
// Click a placed-file row (anywhere but its editable name) → preview it.
|
// Click a placed-file row (anywhere but its editable name) → preview it.
|
||||||
function previewFromTarget(e) {
|
function previewFromTarget(e) {
|
||||||
|
// Preview link on a revision cell (its placed file).
|
||||||
|
var pl = e.target.closest('[data-preview-key]');
|
||||||
|
if (pl) {
|
||||||
|
e.preventDefault();
|
||||||
|
var pf = fileByKey(pl.dataset.previewKey);
|
||||||
|
if (pf && window.app.modules.preview && window.app.modules.preview.previewFile) {
|
||||||
|
window.app.modules.preview.previewFile(pf);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.target.closest('[data-act]')) return false; // action button — not a preview
|
||||||
if (e.target.closest('.tfile__name')) return false;
|
if (e.target.closest('.tfile__name')) return false;
|
||||||
var tf = e.target.closest('.tfile');
|
var tf = e.target.closest('.tfile');
|
||||||
if (!tf || !tf.dataset.key) return false;
|
if (!tf || !tf.dataset.key) return false;
|
||||||
|
|
@ -420,6 +556,18 @@
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (act === 'untransmit') {
|
||||||
|
var tf = btn.closest('.tfile');
|
||||||
|
if (tf && tf.dataset.key) C().place([tf.dataset.key], null, 'transmittal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (act === 'rename-bin') {
|
||||||
|
var bid = closestNodeId(btn);
|
||||||
|
var bn = C().getNode(bid);
|
||||||
|
var nn = prompt('Rename transmittal (this becomes its folder name):', bn ? bn.name : '');
|
||||||
|
if (nn && nn.trim()) C().renameNode(bid, nn.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (act === 'bincancel') { openForm = null; render(); return; }
|
if (act === 'bincancel') { openForm = null; render(); return; }
|
||||||
if (act === 'binadd') {
|
if (act === 'binadd') {
|
||||||
var form = btn.closest('.binform');
|
var form = btn.closest('.binform');
|
||||||
|
|
@ -453,10 +601,14 @@
|
||||||
// tracking → any folder node (.tnode)
|
// tracking → any folder node (.tnode)
|
||||||
// transmittal → a transmittal bin only (.tnode--bin)
|
// transmittal → a transmittal bin only (.tnode--bin)
|
||||||
function dropTarget(target, axis) {
|
function dropTarget(target, axis) {
|
||||||
var sel = axis === 'transmittal' ? '.tnode--bin' : '.tnode';
|
if (axis === 'transmittal') {
|
||||||
var node = target.closest(sel);
|
var bin = target.closest('.tnode--bin');
|
||||||
if (!node || !node.dataset.id) return null;
|
if (!bin || !bin.dataset.id) return null;
|
||||||
return { id: node.dataset.id, row: node.querySelector('.tnode__row') || node };
|
return { id: bin.dataset.id, row: bin.querySelector('.tnode__row') || bin };
|
||||||
|
}
|
||||||
|
var cell = target.closest('.ttable__cell[data-id], .ttable__rev[data-id]');
|
||||||
|
if (!cell) return null;
|
||||||
|
return { id: cell.dataset.id, row: cell };
|
||||||
}
|
}
|
||||||
function clearHover(container) {
|
function clearHover(container) {
|
||||||
var hot = container.querySelectorAll('.drop-hover');
|
var hot = container.querySelectorAll('.drop-hover');
|
||||||
|
|
@ -515,7 +667,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function flashNode(container, id) {
|
function flashNode(container, id) {
|
||||||
var node = container.querySelector('.tnode[data-id="' + id + '"]');
|
var node = container.querySelector('[data-id="' + id + '"]');
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
node.scrollIntoView({ block: 'center' });
|
node.scrollIntoView({ block: 'center' });
|
||||||
var row = node.querySelector('.tnode__row') || node;
|
var row = node.querySelector('.tnode__row') || node;
|
||||||
|
|
|
||||||
|
|
@ -41,29 +41,35 @@
|
||||||
// excluded — and three "Show …" toggles control which buckets are visible
|
// excluded — and three "Show …" toggles control which buckets are visible
|
||||||
// (so unchecking Assigned+Excluded leaves only what's left to do). A folder
|
// (so unchecking Assigned+Excluded leaves only what's left to do). A folder
|
||||||
// whose whole scanned subtree is filtered away is itself hidden.
|
// whose whole scanned subtree is filtered away is itself hidden.
|
||||||
var showFilters = { unassigned: true, assigned: true, excluded: true };
|
var showFilters = { unassigned: true, partial: true, assigned: true, excluded: true };
|
||||||
var showEmpty = true; // show folders that contain no files
|
var showEmpty = true; // show folders that contain no files
|
||||||
function setShowFilters(f) {
|
function setShowFilters(f) {
|
||||||
showFilters = {
|
showFilters = {
|
||||||
unassigned: f.unassigned !== false,
|
unassigned: f.unassigned !== false,
|
||||||
|
partial: f.partial !== false,
|
||||||
assigned: f.assigned !== false,
|
assigned: f.assigned !== false,
|
||||||
excluded: f.excluded !== false,
|
excluded: f.excluded !== false,
|
||||||
};
|
};
|
||||||
showEmpty = f.empty !== false;
|
showEmpty = f.empty !== false;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
function allFiltersOn() { return showFilters.unassigned && showFilters.assigned && showFilters.excluded; }
|
function allFiltersOn() { return showFilters.unassigned && showFilters.partial && showFilters.assigned && showFilters.excluded; }
|
||||||
function activeAxis() {
|
function activeAxis() {
|
||||||
var tt = window.app.modules.targetTree;
|
var tt = window.app.modules.targetTree;
|
||||||
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
|
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
|
||||||
}
|
}
|
||||||
// Bucket a file relative to the active axis: 'excluded' | 'assigned' | 'unassigned'.
|
// Bucket a file relative to the active axis:
|
||||||
|
// 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on the OTHER
|
||||||
|
// axis only — the to-do for this tab) | 'unassigned' (neither axis).
|
||||||
function fileCategory(file) {
|
function fileCategory(file) {
|
||||||
var c = window.app.modules.classify;
|
var c = window.app.modules.classify;
|
||||||
var a = c.getAssignment(c.srcKeyForFile(file));
|
var a = c.getAssignment(c.srcKeyForFile(file));
|
||||||
if (a && a.excluded) return 'excluded';
|
if (a && a.excluded) return 'excluded';
|
||||||
var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId);
|
var onTransmittal = activeAxis() === 'transmittal';
|
||||||
return assigned ? 'assigned' : 'unassigned';
|
var here = a && (onTransmittal ? a.transmittalNodeId : a.trackingNodeId);
|
||||||
|
if (here) return 'assigned';
|
||||||
|
var other = a && (onTransmittal ? a.trackingNodeId : a.transmittalNodeId);
|
||||||
|
return other ? 'partial' : 'unassigned';
|
||||||
}
|
}
|
||||||
function classifyAllows(file) { return !classifyOn() || !!showFilters[fileCategory(file)]; }
|
function classifyAllows(file) { return !classifyOn() || !!showFilters[fileCategory(file)]; }
|
||||||
|
|
||||||
|
|
@ -142,9 +148,9 @@
|
||||||
}
|
}
|
||||||
function updateFilterCounts() {
|
function updateFilterCounts() {
|
||||||
if (!classifyOn()) return;
|
if (!classifyOn()) return;
|
||||||
var n = { unassigned: 0, assigned: 0, excluded: 0 };
|
var n = { unassigned: 0, partial: 0, assigned: 0, excluded: 0 };
|
||||||
allClassifyFiles().forEach(function (f) { n[fileCategory(f)]++; });
|
allClassifyFiles().forEach(function (f) { n[fileCategory(f)]++; });
|
||||||
['unassigned', 'assigned', 'excluded'].forEach(function (k) {
|
['unassigned', 'partial', 'assigned', 'excluded'].forEach(function (k) {
|
||||||
var el = document.getElementById('show' + k.charAt(0).toUpperCase() + k.slice(1) + 'Count');
|
var el = document.getElementById('show' + k.charAt(0).toUpperCase() + k.slice(1) + 'Count');
|
||||||
if (el) el.textContent = '(' + n[k] + ')';
|
if (el) el.textContent = '(' + n[k] + ')';
|
||||||
});
|
});
|
||||||
|
|
@ -341,22 +347,6 @@
|
||||||
item.appendChild(extractBtn);
|
item.appendChild(extractBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract All button for folders with ZIP descendants (but not ZIP roots themselves)
|
|
||||||
if (!folder.isZipRoot && !folder.isVirtualDir) {
|
|
||||||
const zipCount = countZipDescendants(folder);
|
|
||||||
if (zipCount > 0) {
|
|
||||||
const extractAllBtn = document.createElement('button');
|
|
||||||
extractAllBtn.className = 'btn btn-sm zip-extract-all-btn';
|
|
||||||
extractAllBtn.textContent = `📤 Extract All (${zipCount})`;
|
|
||||||
extractAllBtn.title = `Extract all ${zipCount} ZIP file(s) in this folder`;
|
|
||||||
extractAllBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
await handleExtractAllZips(folder);
|
|
||||||
});
|
|
||||||
item.appendChild(extractAllBtn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click handler for selection
|
// Click handler for selection
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
handleFolderClick(folder, e);
|
handleFolderClick(folder, e);
|
||||||
|
|
@ -507,77 +497,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Count ZIP descendants in a folder
|
|
||||||
*/
|
|
||||||
function countZipDescendants(folder) {
|
|
||||||
let count = 0;
|
|
||||||
if (folder.children) {
|
|
||||||
for (const child of folder.children) {
|
|
||||||
if (child.isZipRoot) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
count += countZipDescendants(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all ZIP folders as flat list
|
|
||||||
*/
|
|
||||||
function getZipDescendants(folder, zips = []) {
|
|
||||||
if (folder.children) {
|
|
||||||
for (const child of folder.children) {
|
|
||||||
if (child.isZipRoot) {
|
|
||||||
zips.push(child);
|
|
||||||
}
|
|
||||||
getZipDescendants(child, zips);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return zips;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle extracting all ZIPs in a folder
|
|
||||||
*/
|
|
||||||
async function handleExtractAllZips(folder) {
|
|
||||||
const zips = getZipDescendants(folder);
|
|
||||||
if (zips.length === 0) return;
|
|
||||||
|
|
||||||
const confirmed = confirm(`Extract ${zips.length} ZIP file(s)?\n\nThis will create folders for each ZIP with their contents.`);
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Show extracting state on button
|
|
||||||
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-all-btn`);
|
|
||||||
if (btn) {
|
|
||||||
btn.textContent = '⏳ Extracting...';
|
|
||||||
btn.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract all ZIPs
|
|
||||||
for (const zip of zips) {
|
|
||||||
if (zip.zipPath) {
|
|
||||||
await window.app.modules.scanner.extractZip(zip.zipPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-refresh preserving tree state
|
|
||||||
await window.app.modules.scanner.scanDirectory(window.app.rootHandle, true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error extracting ZIPs:', err);
|
|
||||||
alert('Error extracting ZIPs: ' + err.message);
|
|
||||||
|
|
||||||
// Reset button
|
|
||||||
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-all-btn`);
|
|
||||||
if (btn) {
|
|
||||||
btn.textContent = `📤 Extract All (${zips.length})`;
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle folder expansion
|
* Toggle folder expansion
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -65,11 +65,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="classifyFilters" class="classify-filters tree-toolbar" hidden>
|
<div id="classifyFilters" class="classify-filters tree-toolbar" hidden>
|
||||||
<span class="tree-toolbar__label">Show</span>
|
<span class="tree-toolbar__label">Show</span>
|
||||||
<label class="checkbox-label" title="Files not yet assigned in the active tab">
|
<label class="checkbox-label" title="Not assigned on either axis">
|
||||||
<input type="checkbox" id="showUnassignedCheckbox" checked>
|
<input type="checkbox" id="showUnassignedCheckbox" checked>
|
||||||
Unassigned <span class="filter-count" id="showUnassignedCount"></span>
|
Unassigned <span class="filter-count" id="showUnassignedCount"></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="checkbox-label" title="Files already assigned in the active tab">
|
<label class="checkbox-label" title="Assigned in the OTHER tab but not this one — the to-do for this tab">
|
||||||
|
<input type="checkbox" id="showPartialCheckbox" checked>
|
||||||
|
Partial <span class="filter-count" id="showPartialCount"></span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label" title="Already assigned in the active tab">
|
||||||
<input type="checkbox" id="showAssignedCheckbox" checked>
|
<input type="checkbox" id="showAssignedCheckbox" checked>
|
||||||
Assigned <span class="filter-count" id="showAssignedCount"></span>
|
Assigned <span class="filter-count" id="showAssignedCount"></span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -166,7 +170,8 @@
|
||||||
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</button>
|
<button id="importDatasetBtn" class="btn btn-secondary btn-sm" title="Load an edited classification JSON back in — replaces the current classifications. (To move a whole scanned workspace between browsers, use “Import workspace” on the welcome screen.)">Import edits</button>
|
||||||
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
|
<input type="file" id="importDatasetInput" accept="application/json,.json" hidden>
|
||||||
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
|
<button id="resetDatasetBtn" class="btn btn-sm btn-danger" title="Discard all classifications and start over from the raw scanned input (does not touch your files)">Reset</button>
|
||||||
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to an output directory (source untouched)">Copy…</button>
|
<button id="checkDuplicatesBtn" class="btn btn-secondary btn-sm" title="Check for files with the same tracking number + revision but different content (flagged ≠ in red)">Check</button>
|
||||||
|
<button id="copyOutputBtn" class="btn btn-primary btn-sm" disabled title="Copy mapped files to the server archive or a local folder (source untouched, resumable, verified)">Copy…</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="target-body">
|
<div class="target-body">
|
||||||
|
|
|
||||||
|
|
@ -161,9 +161,9 @@ test('target tree renders structure and tabs switch', async ({ page }) => {
|
||||||
const party = c.addParty('ClientCorp');
|
const party = c.addParty('ClientCorp');
|
||||||
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
});
|
});
|
||||||
// Tracking panel visible by default with the nodes rendered.
|
// Tracking panel visible by default with the table rendered.
|
||||||
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible();
|
||||||
await expect(page.locator('#trackingTree .tnode--leaf .tnode__name', { hasText: 'A (IFR)' })).toBeVisible();
|
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible();
|
||||||
// Switch to transmittal tab.
|
// Switch to transmittal tab.
|
||||||
await page.click('#transmittalTab');
|
await page.click('#transmittalTab');
|
||||||
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
|
expect(await page.locator('#transmittalPanel').isHidden()).toBe(false);
|
||||||
|
|
@ -174,10 +174,10 @@ test('"+ Root folder" button (prompt) parses a name into nested levels', async (
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
|
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
|
||||||
await page.click('#addTrackingRootBtn');
|
await page.click('#addTrackingRootBtn');
|
||||||
// "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU) (three nested levels).
|
// "CPO-0001_0 (IFU)" → CPO / 0001 columns + "0 (IFU)" revision cell.
|
||||||
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'CPO' })).toBeVisible();
|
await expect(page.locator('#trackingTree .tcell__name', { hasText: 'CPO' })).toBeVisible();
|
||||||
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0001' })).toBeVisible();
|
await expect(page.locator('#trackingTree .tcell__name', { hasText: '0001' })).toBeVisible();
|
||||||
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0 (IFU)' })).toBeVisible();
|
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: '0 (IFU)' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
|
// ── Phase 3: drag-and-drop assignment (drop handler) ───────────────────────
|
||||||
|
|
@ -188,7 +188,7 @@ test('dropping a file onto a tracking leaf assigns it', async ({ page }) => {
|
||||||
const c = window.app.modules.classify;
|
const c = window.app.modules.classify;
|
||||||
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
window.app.modules.targetTree.render();
|
window.app.modules.targetTree.render();
|
||||||
const row = document.querySelector('#trackingTree .tnode--leaf .tnode__row');
|
const row = document.querySelector('#trackingTree .ttable__rev[data-id]');
|
||||||
const key = 'Sub/foundation.pdf';
|
const key = 'Sub/foundation.pdf';
|
||||||
window.app.modules.dnd.setDrag([key]);
|
window.app.modules.dnd.setDrag([key]);
|
||||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||||
|
|
@ -322,7 +322,7 @@ test('cross-tree reveal: source→target switches to the placed axis', async ({
|
||||||
|
|
||||||
// ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ───────
|
// ── Phase 5: copy-out engine + duplicate detection (mock FS handles) ───────
|
||||||
|
|
||||||
test('copy: writes the file, skips an identical re-copy, flags a differing target', async ({ page }) => {
|
test('copy: writes the file, then resumes by skipping an existing target', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const res = await page.evaluate(async () => {
|
const res = await page.evaluate(async () => {
|
||||||
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||||
|
|
@ -353,15 +353,12 @@ test('copy: writes the file, skips an identical re-copy, flags a differing targe
|
||||||
|
|
||||||
const out = mockDir('');
|
const out = mockDir('');
|
||||||
const first = await copy.copyTo(out, copy.plan());
|
const first = await copy.copyTo(out, copy.plan());
|
||||||
const second = await copy.copyTo(out, copy.plan()); // identical → skipped
|
const second = await copy.copyTo(out, copy.plan()); // already present → skipped (resume)
|
||||||
const tkey = Object.keys(store)[0];
|
return { firstCopied: first.copied, secondSkipped: second.skipped, secondCopied: second.copied, keys: Object.keys(store) };
|
||||||
store[tkey] = 'DIFFERENT'; // tamper target
|
|
||||||
const third = await copy.copyTo(out, copy.plan()); // differs → left alone
|
|
||||||
return { firstCopied: first.copied, secondSkipped: second.skipped, thirdDiffer: third.differ, keys: Object.keys(store) };
|
|
||||||
});
|
});
|
||||||
expect(res.firstCopied).toBe(1);
|
expect(res.firstCopied).toBe(1);
|
||||||
expect(res.secondSkipped).toBe(1);
|
expect(res.secondSkipped).toBe(1); // re-run resumes: the existing target is skipped
|
||||||
expect(res.thirdDiffer).toBe(1);
|
expect(res.secondCopied).toBe(0); // …and not re-written
|
||||||
expect(res.keys.some((k) => k.endsWith('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal/ACME-MECH-0001_A (IFR) - foundation.pdf'))).toBe(true);
|
expect(res.keys.some((k) => k.endsWith('ClientCorp/received/2026-03-14_ClientCorp-TRN-0007 (---) - Transmittal/ACME-MECH-0001_A (IFR) - foundation.pdf'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -739,7 +736,7 @@ test('tracking-tree filter reveals matching nodes and hides the rest', async ({
|
||||||
c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
|
c.addTrackingPath(null, c.parseFolderLevels('XYZ-0009_A (IFR)'));
|
||||||
window.app.modules.targetTree.render();
|
window.app.modules.targetTree.render();
|
||||||
window.app.modules.targetTree.setNameFilter('CPO');
|
window.app.modules.targetTree.setNameFilter('CPO');
|
||||||
return Array.from(document.querySelectorAll('#trackingTree .tnode__name')).map((e) => e.textContent);
|
return Array.from(document.querySelectorAll('#trackingTree .tcell__name')).map((e) => e.textContent);
|
||||||
});
|
});
|
||||||
expect(names).toContain('CPO');
|
expect(names).toContain('CPO');
|
||||||
expect(names).toContain('0001');
|
expect(names).toContain('0001');
|
||||||
|
|
@ -960,3 +957,223 @@ test('a fully-excluded folder is struck through like its files', async ({ page }
|
||||||
expect(r.before).toBe(false); // not struck through while active
|
expect(r.before).toBe(false); // not struck through while active
|
||||||
expect(r.after).toBe(true); // struck through once the whole subtree is excluded
|
expect(r.after).toBe(true); // struck through once the whole subtree is excluded
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('By-tracking table merges shared ancestors and aligns revisions', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
|
c.reset();
|
||||||
|
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_2025-11-17 (IFI)'));
|
||||||
|
c.addTrackingPath(null, c.parseFolderLevels('LKU-123456-PM-SCH-0001_A (IFR)'));
|
||||||
|
c.addTrackingPath(null, c.parseFolderLevels('CPO-0001_0 (IFU)'));
|
||||||
|
tt.render();
|
||||||
|
const cellByName = (n) => Array.from(document.querySelectorAll('#trackingTree .ttable__cell .tcell__name'))
|
||||||
|
.filter((e) => e.textContent === n).map((e) => e.closest('td'))[0];
|
||||||
|
const lku = cellByName('LKU'), cpo = cellByName('CPO');
|
||||||
|
return {
|
||||||
|
lkuSpan: lku ? lku.rowSpan : 0,
|
||||||
|
cpoSpan: cpo ? cpo.rowSpan : 0,
|
||||||
|
revs: Array.from(document.querySelectorAll('#trackingTree .ttable__rev .tcell__name')).map((e) => e.textContent),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(r.lkuSpan).toBe(2); // the LKU ancestor cell spans its two revisions (merged)
|
||||||
|
expect(r.cpoSpan).toBe(1);
|
||||||
|
// The revisions live in one aligned column; the date revision stays intact.
|
||||||
|
expect(r.revs).toEqual(['2025-11-17 (IFI)', 'A (IFR)', '0 (IFU)']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revision cell links to preview its file and shows no count bubble', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
|
c.reset();
|
||||||
|
const f = { originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root' };
|
||||||
|
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }];
|
||||||
|
const leaf = c.addTrackingPath(null, c.parseFolderLevels('ACME-MECH-0001_A (IFR)'));
|
||||||
|
c.place([c.srcKeyForFile(f)], leaf, 'tracking');
|
||||||
|
tt.render();
|
||||||
|
const rev = document.querySelector('#trackingTree .ttable__rev');
|
||||||
|
const link = rev.querySelector('.tcell__preview[data-preview-key]');
|
||||||
|
return {
|
||||||
|
hasPreview: !!link,
|
||||||
|
previewKey: link && link.dataset.previewKey,
|
||||||
|
hasBadge: !!rev.querySelector('.tnode__badge'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(r.hasPreview).toBe(true); // revision name is a preview link
|
||||||
|
expect(r.previewKey).toBe('foundation.pdf');
|
||||||
|
expect(r.hasBadge).toBe(false); // no count bubble
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Show Partial surfaces files assigned in the other tab only', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(() => {
|
||||||
|
const c = window.app.modules.classify, tree = window.app.modules.tree, tt = window.app.modules.targetTree;
|
||||||
|
c.reset();
|
||||||
|
const f = { originalFilename: 'a', extension: 'pdf', folderPath: 'Docs' };
|
||||||
|
window.app.folderTree = [{ name: 'Docs', path: 'Docs', expanded: true, scanState: 'done', children: [], files: [f] }];
|
||||||
|
// Assign tracking only, then view the TRANSMITTAL tab → it reads as "partial" there.
|
||||||
|
const leaf = c.addTrackingPath(null, c.parseFolderLevels('ACME-MECH-0001_A (IFR)'));
|
||||||
|
c.place([c.srcKeyForFile(f)], leaf, 'tracking');
|
||||||
|
tt.showTab('transmittal');
|
||||||
|
tree.render();
|
||||||
|
const withPartial = !!document.querySelector('#folderTree .file-item');
|
||||||
|
tree.setShowFilters({ unassigned: true, partial: false, assigned: true, excluded: true });
|
||||||
|
const withoutPartial = !!document.querySelector('#folderTree .file-item');
|
||||||
|
return { withPartial, withoutPartial };
|
||||||
|
});
|
||||||
|
expect(r.withPartial).toBe(true); // shown while Partial is on (to-do for this tab)
|
||||||
|
expect(r.withoutPartial).toBe(false); // hidden once Partial is off
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy: PUTs into a server-style handle, then resumes by skipping existing', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||||
|
c.reset();
|
||||||
|
const f = {
|
||||||
|
originalFilename: 'foundation', extension: 'pdf', folderPath: 'Root',
|
||||||
|
handle: { getFile: async () => new File(['AAA'], 'foundation.pdf') },
|
||||||
|
};
|
||||||
|
window.app.folderTree = [{ name: 'Root', path: 'Root', files: [f], children: [] }];
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
|
||||||
|
const bin = c.addTransmittalBin(c.addParty('ClientCorp'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
c.place([c.srcKeyForFile(f)], leaf, 'tracking');
|
||||||
|
c.place([c.srcKeyForFile(f)], bin, 'transmittal');
|
||||||
|
|
||||||
|
// Server-style handle: getDirectoryHandle never verifies (like the HTTP
|
||||||
|
// polyfill); getFileHandle does a HEAD-style existence check.
|
||||||
|
const store = {}, mkdirs = [];
|
||||||
|
const srvDir = (base) => ({
|
||||||
|
getDirectoryHandle: async (n, opts) => { if (opts && opts.create) mkdirs.push(base + n); return srvDir(base + n + '/'); },
|
||||||
|
getFileHandle: async (n, opts) => {
|
||||||
|
const full = base + n;
|
||||||
|
if ((!opts || !opts.create) && !(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
|
||||||
|
return { createWritable: async () => ({ write: async (d) => { store[full] = d; }, close: async () => {} }) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const out = srvDir('');
|
||||||
|
const first = await copy.copyTo(out, copy.plan());
|
||||||
|
const second = await copy.copyTo(out, copy.plan()); // existing → skipped (resume)
|
||||||
|
return { firstCopied: first.copied, secondSkipped: second.skipped, paths: Object.keys(store) };
|
||||||
|
});
|
||||||
|
expect(r.firstCopied).toBe(1);
|
||||||
|
expect(r.secondSkipped).toBe(1);
|
||||||
|
expect(r.paths[0].startsWith('ClientCorp/received/')).toBe(true);
|
||||||
|
expect(r.paths[0].endsWith('ACME-MECH-0001_A (IFR) - foundation.pdf')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy audit: same name+rev — identical content dedups, different content conflicts', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||||
|
c.reset();
|
||||||
|
const mk = (folder, content) => ({
|
||||||
|
originalFilename: 'doc', extension: 'pdf', folderPath: 'R/' + folder,
|
||||||
|
handle: { getFile: async () => new File([content], 'doc.pdf') },
|
||||||
|
});
|
||||||
|
const s1 = mk('S1', 'SAME'), s2 = mk('S2', 'SAME'), d1 = mk('D1', 'AAA'), d2 = mk('D2', 'BBB');
|
||||||
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [], children: [
|
||||||
|
{ name: 'S1', path: 'R/S1', files: [s1], children: [] },
|
||||||
|
{ name: 'S2', path: 'R/S2', files: [s2], children: [] },
|
||||||
|
{ name: 'D1', path: 'R/D1', files: [d1], children: [] },
|
||||||
|
{ name: 'D2', path: 'R/D2', files: [d2], children: [] },
|
||||||
|
] }];
|
||||||
|
const L1 = c.addTrackingNode(c.addTrackingNode(null, 'ACME-0001'), 'A (IFR)');
|
||||||
|
const L2 = c.addTrackingNode(c.addTrackingNode(null, 'ACME-0002'), 'A (IFR)');
|
||||||
|
const T = c.addTransmittalBin(c.addParty('CC'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
[[s1, L1], [s2, L1], [d1, L2], [d2, L2]].forEach(([f, leaf]) => {
|
||||||
|
c.place([c.srcKeyForFile(f)], leaf, 'tracking');
|
||||||
|
c.place([c.srcKeyForFile(f)], T, 'transmittal');
|
||||||
|
});
|
||||||
|
const res = await copy.resolvePlan(copy.plan());
|
||||||
|
return {
|
||||||
|
todo: res.todo.length, dupes: res.dupeCount, conflicts: res.conflicts.length,
|
||||||
|
s1Flagged: !!res.conflictKeys[c.srcKeyForFile(s1)],
|
||||||
|
d1Flagged: !!res.conflictKeys[c.srcKeyForFile(d1)],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(r.todo).toBe(1); // the identical pair collapses to one; the conflicting pair is excluded
|
||||||
|
expect(r.dupes).toBe(1); // one duplicate collapsed
|
||||||
|
expect(r.conflicts).toBe(1); // one same-name/different-content group
|
||||||
|
expect(r.s1Flagged).toBe(false);
|
||||||
|
expect(r.d1Flagged).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy: verifies copied bytes; a bad write fails verification and is removed', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const c = window.app.modules.classify, copy = window.app.modules.copy;
|
||||||
|
c.reset();
|
||||||
|
const f = { originalFilename: 'doc', extension: 'pdf', folderPath: 'R',
|
||||||
|
handle: { getFile: async () => new File(['GOOD'], 'doc.pdf') } };
|
||||||
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||||
|
const leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-0001'), 'A (IFR)');
|
||||||
|
const bin = c.addTransmittalBin(c.addParty('CC'), 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
c.place([c.srcKeyForFile(f)], leaf, 'tracking'); c.place([c.srcKeyForFile(f)], bin, 'transmittal');
|
||||||
|
// A dir whose writes CORRUPT the content → verification must catch it.
|
||||||
|
const store = {}, removed = [];
|
||||||
|
const mkdir = (base) => ({
|
||||||
|
getDirectoryHandle: async (n) => mkdir(base + n + '/'),
|
||||||
|
getFileHandle: async (n, opts) => {
|
||||||
|
const full = base + n;
|
||||||
|
if ((!opts || !opts.create) && !(full in store)) { const e = new Error('NF'); e.name = 'NotFoundError'; throw e; }
|
||||||
|
return {
|
||||||
|
getFile: async () => new File([store[full]], n),
|
||||||
|
createWritable: async () => ({ write: async () => { store[full] = 'CORRUPT'; }, close: async () => {} }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
removeEntry: async (n) => { delete store[base + n]; removed.push(base + n); },
|
||||||
|
});
|
||||||
|
const s = await copy.copyTo(mkdir(''), copy.plan());
|
||||||
|
return { copied: s.copied, verifyFailed: s.verifyFailed, removed: removed.length, left: Object.keys(store).length };
|
||||||
|
});
|
||||||
|
expect(r.copied).toBe(1);
|
||||||
|
expect(r.verifyFailed).toBe(1); // SHA mismatch caught
|
||||||
|
expect(r.removed).toBe(1); // bad copy removed…
|
||||||
|
expect(r.left).toBe(0); // …so a re-run re-copies it
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transmittal: rename a bin (feeds the folder), remove and move a placed file', async ({ page }) => {
|
||||||
|
await page.click('#modeClassifyBtn');
|
||||||
|
const r = await page.evaluate(async () => {
|
||||||
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
|
c.reset();
|
||||||
|
const f = { originalFilename: 'doc', extension: 'pdf', folderPath: 'R' };
|
||||||
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||||
|
const key = c.srcKeyForFile(f);
|
||||||
|
const party = c.addParty('CC');
|
||||||
|
const bin1 = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
|
||||||
|
const bin2 = c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0008' });
|
||||||
|
c.place([key], bin1, 'transmittal');
|
||||||
|
tt.showTab('transmittal'); tt.render();
|
||||||
|
|
||||||
|
// Rename the bin → it becomes the copy folder name.
|
||||||
|
c.renameNode(bin1, 'My Custom Transmittal');
|
||||||
|
const renamed = c.getNode(bin1).name === 'My Custom Transmittal';
|
||||||
|
const folder = c.deriveTarget(f).transmittalFolder;
|
||||||
|
|
||||||
|
// The placed-file row is draggable (move) and carries a remove button.
|
||||||
|
tt.render();
|
||||||
|
const row = document.querySelector('#transmittalTree .tfile[data-key]');
|
||||||
|
const draggable = !!(row && row.draggable);
|
||||||
|
const hasRemove = !!(row && row.querySelector('.tfile__remove[data-act="untransmit"]'));
|
||||||
|
|
||||||
|
// Remove from the transmittal (click ✕).
|
||||||
|
row.querySelector('.tfile__remove').click();
|
||||||
|
const a1 = c.getAssignment(key);
|
||||||
|
const removed = !(a1 && a1.transmittalNodeId);
|
||||||
|
|
||||||
|
// Move = re-place onto another bin (what dropping on bin2 does).
|
||||||
|
c.place([key], bin2, 'transmittal');
|
||||||
|
const movedTo = (c.getAssignment(key) || {}).transmittalNodeId === bin2;
|
||||||
|
|
||||||
|
return { renamed, folder, draggable, hasRemove, removed, movedTo };
|
||||||
|
});
|
||||||
|
expect(r.renamed).toBe(true);
|
||||||
|
expect(r.folder).toBe('My Custom Transmittal'); // rename drives the filing folder
|
||||||
|
expect(r.draggable).toBe(true);
|
||||||
|
expect(r.hasRemove).toBe(true);
|
||||||
|
expect(r.removed).toBe(true);
|
||||||
|
expect(r.movedTo).toBe(true);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1778,7 +1778,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -2770,7 +2770,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
archive=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
transmittal=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
transmittal=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
classifier=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
classifier=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
landing=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
landing=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
form=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
form=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
tables=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
tables=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
|
||||||
browse=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
|
browse=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
|
||||||
|
|
|
||||||
|
|
@ -1722,7 +1722,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue