Compare commits

..

11 commits

Author SHA1 Message Date
5663244fb4 chore(embedded): cut v0.0.27-beta
Some checks failed
Notify chart dev on beta cut / notify-chart-dev (push) Failing after 9s
2026-06-11 09:40:32 -05:00
bc762a7d74 feat(classifier): rename transmittals; remove/move files already in one
By-transmittal pane gains the editing it was missing:
- Rename a transmittal after creation (✎ on the bin → prompt). The name IS the
  filing folder (deriveTarget reads bin.name), so renaming changes where copies
  land, not just the label.
- Each placed file row is now draggable — drop it on another transmittal to MOVE
  it — and carries an ✕ to remove it from the transmittal (clears that axis).
  previewFromTarget now lets action buttons through so ✕ doesn't trigger preview.

Test: rename feeds the derived folder; the row is draggable + has remove; ✕
clears the transmittal; re-place moves it to another bin (55 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:12:38 -05:00
d951c3a5e7 feat(classifier): hash-check same-name files; verify every copy; resume converges
Same tracking number + revision ⇒ same document, so the bytes must match:
- Pre-flight (and a new "Check" button) groups fully-classified files by their
  canonical name and SHA-256s any collisions. Identical bytes collapse to ONE
  copy (deduped); DIFFERENT bytes are a conflict — flagged ≠ in red in the
  By-tracking table (with a tooltip) and held back from the copy so the user
  fixes them first. Flags clear when a placement changes.
- Every file copied this run is VERIFIED: read the written target back, compare
  SHA-256 to the source. One re-copy attempt on mismatch; if it still fails, the
  bad target is removed so a re-run re-copies it — resume converges on a
  fully-correct archive (skip-if-exists stays the fast path for good files).

classify gains transient hash-conflict flags (setHashConflicts/hasHashConflict),
copy gains sourceSha (cached), writeTarget, verifyOne, removeTarget, resolvePlan
and audit(); copyTo runs the verify pass and reports verifyFailed.

Tests: identical pair dedups + differing pair conflicts/flags; a corrupting
write fails verification and is removed (54 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:01:02 -05:00
61c1b4f90d feat(classifier): server copy picks any accessible project's archive
Replaces the raw archive-URL prompt with a project picker: copyToServer fetches
the caller's projects from /.profile/access and lets them choose one; files are
PUT under <project>/archive/<party>/<received|issued>/<transmittal>/. Write
permission is enforced server-side, so a 403 surfaces per file (and resume
retries). Same resumable engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:53:37 -05:00
144baeec61 feat(classifier): Copy → server archive (HTTP) or local folder, resumable
Replaces the zip option. Copy now offers two real destinations, both filing
under <party>/<received|issued>/<transmittal>/<name>:
- Server archive — PUTs each file into a zddc-server over HTTP via the
  zddc-source HttpDirectoryHandle (mkdir as needed). Offered only over http(s);
  prompts for the archive URL (guessed from the current path's archive/ segment).
- Local folder — the File System Access picker (choose archive/ to file directly).

Both reuse the one copy engine and are efficiently resumable: copyOne probes the
target with a cheap stat/HEAD and SKIPS anything already present — no source
read, no hashing — so a re-run after an interruption only does what's left.
Canonical ZDDC names + the WORM archive mean an existing name is the same
document, so we never overwrite (the old content-diff path is dropped).

Tests: re-run skips an existing local target; PUT into a server-style handle then
resume-skips it (52 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:43:53 -05:00
325dce9f3e feat(classifier): Copy prompts download .zip or copy to a directory
Copy now opens a small dialog: copy the canonical files into a folder (pick your
archive/ to file them straight into <party>/<received|issued>/<transmittal>/),
or download a single .zip with the same layout (unzips into place). The zip path
reuses readSource + the derived outRel, so the bytes and folder structure match
the directory copy exactly; conflicts are still skipped beforehand.

Test: the zip is built with one entry at ClientCorp/received/<transmittal>/
ACME-MECH-0001_A (IFR) - foundation.pdf (52 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:31:35 -05:00
645b308ebc feat(classifier): "Partial" Show filter — assigned in the other tab only
Adds a fourth source-tree bucket relative to the active axis: a file assigned on
the OTHER axis but not this one. With its own "Show Partial" toggle (and count)
you can assign a batch in one tab, switch tabs, show only Partial, and finish
them off — working in chunks across the two axes.

fileCategory now returns excluded / assigned (this axis) / partial (other axis
only) / unassigned (neither); filters, counts and the toolbar checkbox follow.

Test: a tracking-only file reads as Partial on the transmittal tab and hides
when Partial is unchecked (51 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:27:39 -05:00
823cfb0d48 fix(classifier): pin the xlsx preview's horizontal scrollbar to the window
The sheet rendered into #previewContent (flex:1) with an inner table scroller
also set to flex:1 — but #previewContent wasn't a flex column, so the inner
flex:1 was ignored and the scroller grew to the full sheet height, dropping the
horizontal scrollbar to the very bottom of the content. Make #previewContent a
height-constrained flex column (and give the scroller min-height:0) so the table
area fills the viewport and its h-scrollbar sits at the window bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:34:43 -05:00
4b79386c4e ux(classifier): drop the hover "Extract All" button from folder rows
It wasn't needed and its margin-left:auto shoved the folder's direct/total stats
out of position. Removed the button and its now-unused helpers
(countZipDescendants / getZipDescendants / handleExtractAllZips) and CSS. The
per-archive "Extract" on an expanded zip-root stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:18:31 -05:00
800454d0db ux(classifier): revision cell previews its file; drop the count bubble
In the By-tracking table the revision name is now a preview link for its placed
file (clicking it opens the preview; hover names the original), and the per-cell
count bubble is gone — a revision is one document, so the number was noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:11:39 -05:00
1631e2b8ca feat(classifier): By-tracking as a merged-cell table (pattern config + sticky)
The nested tracking tree wasted huge vertical space showing the hierarchy by
indentation. Render it as a spreadsheet-style table instead: each configured
field is a column, ancestor cells span (rowspan) their descendants' rows, the
revision gets its own aligned column, and each placed file is a row. Far denser,
and the hierarchy reads left-to-right.

- classify.js: a per-workspace pattern config { trackingFields[{name,optional}],
  statuses, modifiers }, default ORIG·PROJ·DISC·TYPE·SEQ + optional SUFFIX,
  seeded statuses from the shared enum. Serialized with the workspace; reset
  keeps it (it's a setting, not data). getConfig/setConfig/getTrackingFields.
- target-tree.js: tree → flat rows → merged-cell <table>. A revision leaf is
  detected by its "(STATUS)" suffix, so SEQ "0001" and revision "A (IFR)" land
  in the right columns. Drop targets, CRUD, editable ZDDC name, validation,
  filter and cross-reveal all preserved on the cells.
- css: sticky header + sticky merged-cell values (the value stays pinned under
  the header while you scroll a tall group, so a span never reads blank).
- The original filename moves to a hover tooltip on the editable name.

Next: the revision context-menu (status / draft ~ / +Cn toggles + stepper) and
the settings UI to edit the pattern/status/modifier lists.

Tests: merged ancestors get the right rowspan; revisions align in one column;
the date revision stays intact (49 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:56:00 -05:00
16 changed files with 1499 additions and 450 deletions

View file

@ -444,7 +444,11 @@
/* placed files under a node */
.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__arrow { color: var(--text-muted); }
.tfile__name { color: var(--text); }
@ -567,20 +571,72 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
cursor: wait;
}
/* ZIP Extract All Button */
.zip-extract-all-btn {
margin-left: auto;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
opacity: 0;
transition: opacity 0.15s;
/* ── Copy destination dialog ────────────────────────────────────────────── */
.copy-choice__backdrop {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center; padding: 1rem;
}
.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 {
opacity: 1;
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
#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;
}
.zip-extract-all-btn:disabled {
opacity: 0.5;
cursor: wait;
.ttable thead th {
position: sticky; top: 0; z-index: 3;
background: var(--bg-secondary, var(--bg));
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); }

View file

@ -145,6 +145,7 @@
hideCompliantLabel: document.getElementById('hideCompliantLabel'),
classifyFilters: document.getElementById('classifyFilters'),
showUnassignedCheckbox: document.getElementById('showUnassignedCheckbox'),
showPartialCheckbox: document.getElementById('showPartialCheckbox'),
showAssignedCheckbox: document.getElementById('showAssignedCheckbox'),
showExcludedCheckbox: document.getElementById('showExcludedCheckbox'),
showEmptyCheckbox: document.getElementById('showEmptyCheckbox'),
@ -181,7 +182,8 @@
modeClassifyBtn: document.getElementById('modeClassifyBtn'),
spreadsheetPane: document.getElementById('spreadsheetPane'),
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) {
app.modules.tree.setShowFilters({
unassigned: app.dom.showUnassignedCheckbox.checked,
partial: app.dom.showPartialCheckbox.checked,
assigned: app.dom.showAssignedCheckbox.checked,
excluded: app.dom.showExcludedCheckbox.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); });
// Collapse tree button
@ -370,6 +373,7 @@
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.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).
if (app.dom.treeFilterInput) app.dom.treeFilterInput.addEventListener('input', function () {

View file

@ -29,6 +29,26 @@
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 ────────────────────────────────────────────────────────────────
var state = {
enabled: false, // classify mode on/off
@ -36,11 +56,19 @@
trackingTree: [], // [ { id, name, children:[] } ] (leaf = no children)
transmittalTree: [], // [ { id, kind:'party', name, children:[ slot ] } ]
outputName: null, // remembered output directory display name
config: defaultConfig(), // tracking-number pattern (fields/statuses/modifiers)
};
// id -> { node, kind:'tracking'|'party'|'slot'|'transmittal', parent }
var nodeIndex = {};
// Transient (not serialized): srcKeys flagged by the copy audit as a
// same-name/different-content conflict. Cleared whenever a placement changes.
var hashConflicts = {};
function setHashConflicts(map) { hashConflicts = map || {}; notify(); }
function hasHashConflict(key) { return !!hashConflicts[key]; }
function clearHashConflicts() { hashConflicts = {}; }
// ── pub/sub ──────────────────────────────────────────────────────────────
var listeners = [];
function on(cb) { listeners.push(cb); return function () { listeners = listeners.filter(function (f) { return f !== cb; }); }; }
@ -118,6 +146,7 @@
a.excluded = false; // placing un-excludes
cleanAssignment(k);
});
clearHashConflicts(); // a placement changed → stale conflict flags
notify();
}
function setExcluded(keys, excluded) {
@ -127,6 +156,7 @@
if (excluded) { a.trackingNodeId = null; a.transmittalNodeId = null; }
cleanAssignment(k);
});
clearHashConflicts();
notify();
}
// Forget any assignment for these source keys (e.g. when a .zip flips
@ -385,6 +415,7 @@
trackingTree: state.trackingTree,
transmittalTree: state.transmittalTree,
outputName: state.outputName,
config: state.config,
};
}
function load(obj) {
@ -393,9 +424,12 @@
state.trackingTree = obj.trackingTree || [];
state.transmittalTree = obj.transmittalTree || [];
state.outputName = obj.outputName || null;
state.config = normalizeConfig(obj.config);
rebuildIndex();
notify();
}
// Reset clears the CLASSIFICATION but keeps the pattern config — it's a
// per-project setting, not part of the data being cleared.
function reset() {
state.assignments = {}; state.trackingTree = []; state.transmittalTree = [];
state.outputName = null;
@ -403,6 +437,23 @@
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 ─────────────────────────────────────────
// Brace expansion for the add-folder box. Supports (non-nested) groups:
// {a,b,c} → alternation: a | b | c
@ -566,6 +617,7 @@
// assignments
assignmentFor: assignmentFor, getAssignment: getAssignment,
place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
setHashConflicts: setHashConflicts, hasHashConflict: hasHashConflict,
setTitleOverride: setTitleOverride,
// trees
addTrackingNode: addTrackingNode, addParty: addParty,
@ -575,6 +627,7 @@
trackingNodeComplete: trackingNodeComplete, trackingPathLabel: trackingPathLabel,
transmittalRecord: transmittalRecord,
findOrAddParty: findOrAddParty, findOrAddTransmittalBin: findOrAddTransmittalBin,
getConfig: getConfig, setConfig: setConfig, getTrackingFields: getTrackingFields,
getNode: getNode, getTrackingTree: function () { return state.trackingTree; },
getTransmittalTree: function () { return state.transmittalTree; },
// derive + reverse

View file

@ -88,13 +88,17 @@
return cur;
}
async function sameContent(existingHandle, srcFileObj) {
var ef = await existingHandle.getFile();
var sf = await readSource(srcFileObj);
if (ef.size !== sf.size) return false;
var a = await window.zddc.crypto.sha256File(ef);
var b = await window.zddc.crypto.sha256File(sf);
return a === b;
// Resolve a target subdirectory WITHOUT creating it (null if any segment is
// missing). Lets us check a file's existence cheaply on resume before paying
// to create the folder chain.
async function resolveDir(root, relPath, create) {
var parts = relPath.split('/').filter(Boolean);
var cur = root;
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;
@ -115,75 +119,265 @@
return (await srcHandle(fileObj)).getFile();
}
// Copy one file. Returns 'copied' | 'skipped' (identical) | 'differ' (left alone).
async function copyOne(out, p) {
// Copy one file. Returns 'copied' | 'skipped' (already present → resumable).
// The existence check is a cheap stat/HEAD; a present target is left as-is so
// re-running after an interruption skips the work already done — no source
// read, no hashing. (Canonical ZDDC names ⇒ same name = same document, and
// the server archive is WORM, so we never overwrite.)
// SHA-256 of a source file's bytes, cached on the file object (reused by the
// 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 existing = null;
try { existing = await dir.getFileHandle(p.d.filename); } catch (e) { /* NotFound → fresh copy */ }
if (existing) {
return (await sameContent(existing, p.file)) ? 'skipped' : 'differ';
}
var srcFile = await readSource(p.file); // READ source (never write it)
var fh = await dir.getFileHandle(p.d.filename, { create: true });
var w = await fh.createWritable();
await w.write(srcFile);
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';
}
// 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 isnt 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() {
if (!C().isEnabled()) return;
var items = plan();
if (!items.length) {
toast('Nothing to copy yet — no files are fully classified (need both a tracking leaf and a transmittal).', '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]; });
var r = await preflight('to copy');
if (!r) return;
var todo = r.todo;
if (!todo.length) { if (r.conflicts.length) toast('Resolve the flagged conflicts, then copy.', 'warning'); return; }
if (cf.conflicts.length) {
toast(cf.conflicts.length + ' output-name collision(s) — two source files map to the same name. Skipped:\n'
+ cf.conflicts.join('\n'), 'error');
}
if (!todo.length) return;
// Where to file the canonical copies: the server archive (HTTP) or a local
// folder. Both read the source, never write it, both resumable + verified.
var dest = await chooseDestination(todo.length);
if (!dest) 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 isnt 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();
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 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');
summary(s, '"' + out.name + '"');
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>&lt;project&gt;/archive/&lt;party&gt;/&lt;received|issued&gt;/&lt;transmittal&gt;/</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>&lt;party&gt;/&lt;received|issued&gt;/&lt;transmittal&gt;/&lt;name&gt;</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,
// 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) {
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++) {
setStatus('Copying… ' + (i + 1) + '/' + todo.length + ' — ' + todo[i].d.filename);
try {
var r = await copyOne(out, todo[i]);
s[r]++;
if (r === 'differ') s.differing.push(todo[i].outRel);
if (r === 'copied') copied.push(todo[i]);
} catch (e) {
s.errors++;
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('');
return s;
}
@ -199,11 +415,13 @@
window.app.modules.copy = {
run: run,
audit: audit,
readyCount: readyCount,
chooseOutput: chooseOutput,
// test/advanced seams
plan: plan,
conflictsIn: conflictsIn,
resolvePlan: resolvePlan,
copyTo: copyTo,
};
})();

View file

@ -410,10 +410,18 @@
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
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) {
const tabs = previewWindow.document.createElement('div');
tabs.className = 'sheet-tabs';
tabs.style.flexShrink = '0';
workbook.SheetNames.forEach((name, i) => {
const tab = previewWindow.document.createElement('button');
tab.className = 'sheet-tab' + (i === 0 ? ' active' : '');
@ -430,6 +438,7 @@
const tableContainer = previewWindow.document.createElement('div');
tableContainer.style.flex = '1';
tableContainer.style.minHeight = '0'; // allow it to shrink so overflow scrolls
tableContainer.style.overflow = 'auto';
container.appendChild(tableContainer);

View file

@ -158,14 +158,18 @@
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) {
var box = el('div', 'tnode__files');
files.forEach(function (f) {
var d = C().deriveTarget(f);
var row = el('div', 'tfile' + (d.errors.length ? ' tfile--err' : ''));
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 : ''));
orig.title = 'Click to preview';
orig.title = 'Drag to another transmittal to move · click to preview';
row.appendChild(orig);
row.appendChild(el('span', 'tfile__arrow', '→'));
// Editable derived filename — edit it to re-file the item.
@ -175,6 +179,10 @@
name.placeholder = '(incomplete)';
name.title = d.errors.length ? d.errors.join('; ') : 'Edit this ZDDC filename to re-file the item';
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);
});
return box;
@ -199,51 +207,165 @@
return rfHit(orig) || rfHit(C().deriveTarget(f).filename || '');
}
// Tracking tree (recursive, filter-aware — a match reveals its whole path).
function renderTrackingInto(container, nodes, placedMap) {
container.textContent = '';
if (!nodes.length) {
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;
// ── By-tracking: merged-cell table ──────────────────────────────────────
// The positional hierarchy reads left-to-right as columns (one per configured
// field), ancestor cells span their descendants' rows, and the revision (the
// leaf) gets its own aligned column. Each placed file is a row.
var wrap = el('div', 'tnode' + (isLeaf ? ' tnode--leaf' : ''));
wrap.dataset.id = n.id;
var row = el('div', 'tnode__row');
var toggle = el('button', 'tnode__toggle', isLeaf ? '·' : (expanded ? '▾' : '▸'));
if (!isLeaf) toggle.dataset.act = 'toggle';
row.appendChild(toggle);
row.appendChild(el('span', 'tnode__name', n.name));
if (placed.length) row.appendChild(el('span', 'tnode__badge', String(placed.length)));
row.appendChild(nodeActions([
{ act: 'add', label: '', title: 'Add child folder' },
// A node is a revision leaf when its name ends in a "(STATUS)" we recognise —
// tracking field codes never carry a parenthesised status, so this cleanly
// separates "0001" (a SEQ field) from "A (IFR)" (a revision).
function revStatusOf(name) {
var m = /\(\s*([A-Za-z0-9-]{1,5})\s*\)\s*$/.exec(name || '');
return (m && window.zddc.isValidStatus(m[1])) ? m[1] : null;
}
function isRevisionLeaf(node) {
return !(node.children || []).length && revStatusOf(node.name) != null;
}
// 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: 'del', label: '🗑', title: 'Delete' },
]));
wrap.appendChild(row);
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 inner;
}
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));
}
return wrap;
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
@ -307,7 +429,10 @@
var row = el('div', 'tnode__row');
row.appendChild(el('span', 'tnode__name', bin.name || '(invalid — set date/seq)'));
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);
if (shownFiles.length) wrap.appendChild(fileList(shownFiles));
return wrap;
@ -334,7 +459,7 @@
// ── events ─────────────────────────────────────────────────────────────
function closestNodeId(target) {
var n = target.closest('.tnode');
var n = target.closest('[data-id]');
return n ? n.dataset.id : null;
}
function fileByKey(key) {
@ -344,6 +469,17 @@
}
// Click a placed-file row (anywhere but its editable name) → preview it.
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;
var tf = e.target.closest('.tfile');
if (!tf || !tf.dataset.key) return false;
@ -420,6 +556,18 @@
render();
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 === 'binadd') {
var form = btn.closest('.binform');
@ -453,10 +601,14 @@
// tracking → any folder node (.tnode)
// transmittal → a transmittal bin only (.tnode--bin)
function dropTarget(target, axis) {
var sel = axis === 'transmittal' ? '.tnode--bin' : '.tnode';
var node = target.closest(sel);
if (!node || !node.dataset.id) return null;
return { id: node.dataset.id, row: node.querySelector('.tnode__row') || node };
if (axis === 'transmittal') {
var bin = target.closest('.tnode--bin');
if (!bin || !bin.dataset.id) return null;
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) {
var hot = container.querySelectorAll('.drop-hover');
@ -515,7 +667,7 @@
}
}
function flashNode(container, id) {
var node = container.querySelector('.tnode[data-id="' + id + '"]');
var node = container.querySelector('[data-id="' + id + '"]');
if (!node) return;
node.scrollIntoView({ block: 'center' });
var row = node.querySelector('.tnode__row') || node;

View file

@ -41,29 +41,35 @@
// excluded — and three "Show …" toggles control which buckets are visible
// (so unchecking Assigned+Excluded leaves only what's left to do). A folder
// 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
function setShowFilters(f) {
showFilters = {
unassigned: f.unassigned !== false,
partial: f.partial !== false,
assigned: f.assigned !== false,
excluded: f.excluded !== false,
};
showEmpty = f.empty !== false;
render();
}
function allFiltersOn() { return showFilters.unassigned && showFilters.assigned && showFilters.excluded; }
function allFiltersOn() { return showFilters.unassigned && showFilters.partial && showFilters.assigned && showFilters.excluded; }
function activeAxis() {
var tt = window.app.modules.targetTree;
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) {
var c = window.app.modules.classify;
var a = c.getAssignment(c.srcKeyForFile(file));
if (a && a.excluded) return 'excluded';
var assigned = a && (activeAxis() === 'transmittal' ? a.transmittalNodeId : a.trackingNodeId);
return assigned ? 'assigned' : 'unassigned';
var onTransmittal = activeAxis() === 'transmittal';
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)]; }
@ -142,9 +148,9 @@
}
function updateFilterCounts() {
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)]++; });
['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');
if (el) el.textContent = '(' + n[k] + ')';
});
@ -341,22 +347,6 @@
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
item.addEventListener('click', (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
*/

View file

@ -65,11 +65,15 @@
</div>
<div id="classifyFilters" class="classify-filters tree-toolbar" hidden>
<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>
Unassigned <span class="filter-count" id="showUnassignedCount"></span>
</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>
Assigned <span class="filter-count" id="showAssignedCount"></span>
</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>
<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="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 class="target-body">

View file

@ -161,9 +161,9 @@ test('target tree renders structure and tabs switch', async ({ page }) => {
const party = c.addParty('ClientCorp');
c.addTransmittalBin(party, 'received', { date: '2026-03-14', type: 'TRN', seq: '0007' });
});
// Tracking panel visible by default with the nodes rendered.
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'ACME-PROJ' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode--leaf .tnode__name', { hasText: 'A (IFR)' })).toBeVisible();
// Tracking panel visible by default with the table rendered.
await expect(page.locator('#trackingTree .ttable__cell .tcell__name', { hasText: 'ACME-PROJ' })).toBeVisible();
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: 'A (IFR)' })).toBeVisible();
// Switch to transmittal tab.
await page.click('#transmittalTab');
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');
page.once('dialog', (d) => d.accept('CPO-0001_0 (IFU)'));
await page.click('#addTrackingRootBtn');
// "CPO-0001_0 (IFU)" → CPO / 0001 / 0 (IFU) (three nested levels).
await expect(page.locator('#trackingTree .tnode__name', { hasText: 'CPO' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0001' })).toBeVisible();
await expect(page.locator('#trackingTree .tnode__name', { hasText: '0 (IFU)' })).toBeVisible();
// "CPO-0001_0 (IFU)" → CPO / 0001 columns + "0 (IFU)" revision cell.
await expect(page.locator('#trackingTree .tcell__name', { hasText: 'CPO' })).toBeVisible();
await expect(page.locator('#trackingTree .tcell__name', { hasText: '0001' })).toBeVisible();
await expect(page.locator('#trackingTree .ttable__rev .tcell__name', { hasText: '0 (IFU)' })).toBeVisible();
});
// ── 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 leaf = c.addTrackingNode(c.addTrackingNode(null, 'ACME-MECH-0001'), 'A (IFR)');
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';
window.app.modules.dnd.setDrag([key]);
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) ───────
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');
const res = await page.evaluate(async () => {
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 first = await copy.copyTo(out, copy.plan());
const second = await copy.copyTo(out, copy.plan()); // identical → skipped
const tkey = Object.keys(store)[0];
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) };
const second = await copy.copyTo(out, copy.plan()); // already present → skipped (resume)
return { firstCopied: first.copied, secondSkipped: second.skipped, secondCopied: second.copied, keys: Object.keys(store) };
});
expect(res.firstCopied).toBe(1);
expect(res.secondSkipped).toBe(1);
expect(res.thirdDiffer).toBe(1);
expect(res.secondSkipped).toBe(1); // re-run resumes: the existing target is skipped
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);
});
@ -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)'));
window.app.modules.targetTree.render();
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('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.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);
});

View file

@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
</svg>
<div class="header-title-group">
<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>
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data"></button>

View file

@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
</svg>
<div class="header-title-group">
<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>
<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>

File diff suppressed because it is too large Load diff

View file

@ -1778,7 +1778,7 @@ body {
</svg>
<div class="header-title-group">
<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 class="header-right">

View file

@ -2770,7 +2770,7 @@ dialog.modal--narrow {
</svg>
<div class="header-title-group">
<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>
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
<!-- Publish split-button (Transmittal-specific primary action;

View file

@ -1,8 +1,8 @@
# 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
transmittal=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
classifier=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
landing=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
form=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
tables=v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08
browse=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-11 14:40:24 · bc762a7
classifier=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
landing=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
form=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
tables=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
browse=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7

View file

@ -1722,7 +1722,7 @@ body.is-elevated::after {
</svg>
<div class="header-title-group">
<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 class="header-right">