diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html
index 8f708fa..e1d17de 100644
--- a/zddc/internal/apps/embedded/archive.html
+++ b/zddc/internal/apps/embedded/archive.html
@@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
@@ -5780,6 +5841,7 @@ X.B(E,Y);return E}return J}())
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'),
@@ -5816,7 +5878,8 @@ X.B(E,Y);return E}return J}())
modeClassifyBtn: document.getElementById('modeClassifyBtn'),
spreadsheetPane: document.getElementById('spreadsheetPane'),
targetPane: document.getElementById('targetPane'),
- copyOutputBtn: document.getElementById('copyOutputBtn')
+ copyOutputBtn: document.getElementById('copyOutputBtn'),
+ checkDuplicatesBtn: document.getElementById('checkDuplicatesBtn')
};
}
@@ -5989,13 +6052,14 @@ X.B(E,Y);return E}return J}())
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
@@ -6005,6 +6069,7 @@ X.B(E,Y);return E}return J}())
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 () {
@@ -7227,6 +7292,26 @@ X.B(E,Y);return E}return J}())
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
@@ -7234,11 +7319,19 @@ X.B(E,Y);return E}return J}())
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; }); }; }
@@ -7316,6 +7409,7 @@ X.B(E,Y);return E}return J}())
a.excluded = false; // placing un-excludes
cleanAssignment(k);
});
+ clearHashConflicts(); // a placement changed → stale conflict flags
notify();
}
function setExcluded(keys, excluded) {
@@ -7325,6 +7419,7 @@ X.B(E,Y);return E}return J}())
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
@@ -7583,6 +7678,7 @@ X.B(E,Y);return E}return J}())
trackingTree: state.trackingTree,
transmittalTree: state.transmittalTree,
outputName: state.outputName,
+ config: state.config,
};
}
function load(obj) {
@@ -7591,9 +7687,12 @@ X.B(E,Y);return E}return J}())
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;
@@ -7601,6 +7700,23 @@ X.B(E,Y);return E}return J}())
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
@@ -7764,6 +7880,7 @@ X.B(E,Y);return E}return J}())
// assignments
assignmentFor: assignmentFor, getAssignment: getAssignment,
place: place, setExcluded: setExcluded, dropAssignments: dropAssignments,
+ setHashConflicts: setHashConflicts, hasHashConflict: hasHashConflict,
setTitleOverride: setTitleOverride,
// trees
addTrackingNode: addTrackingNode, addParty: addParty,
@@ -7773,6 +7890,7 @@ X.B(E,Y);return E}return J}())
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
@@ -9299,29 +9417,35 @@ X.B(E,Y);return E}return J}())
// 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)]; }
@@ -9400,9 +9524,9 @@ X.B(E,Y);return E}return J}())
}
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] + ')';
});
@@ -9598,22 +9722,6 @@ X.B(E,Y);return E}return J}())
});
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) => {
@@ -9765,77 +9873,6 @@ X.B(E,Y);return E}return J}())
}
}
- /**
- * 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
*/
@@ -10420,14 +10457,18 @@ X.B(E,Y);return E}return J}())
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.
@@ -10437,6 +10478,10 @@ X.B(E,Y);return E}return J}())
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;
@@ -10461,51 +10506,165 @@ X.B(E,Y);return E}return J}())
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
@@ -10569,7 +10728,10 @@ X.B(E,Y);return E}return J}())
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;
@@ -10596,7 +10758,7 @@ X.B(E,Y);return E}return J}())
// ── events ─────────────────────────────────────────────────────────────
function closestNodeId(target) {
- var n = target.closest('.tnode');
+ var n = target.closest('[data-id]');
return n ? n.dataset.id : null;
}
function fileByKey(key) {
@@ -10606,6 +10768,17 @@ X.B(E,Y);return E}return J}())
}
// 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;
@@ -10682,6 +10855,18 @@ X.B(E,Y);return E}return J}())
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');
@@ -10715,10 +10900,14 @@ X.B(E,Y);return E}return J}())
// 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');
@@ -10777,7 +10966,7 @@ X.B(E,Y);return E}return J}())
}
}
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;
@@ -10885,13 +11074,17 @@ X.B(E,Y);return E}return J}())
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;
@@ -10912,75 +11105,265 @@ X.B(E,Y);return E}return J}())
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 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() {
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 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();
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
/// — 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 /// under
+ // /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 <project>/archive/<party>/<received|issued>/<transmittal>/. 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 <party>/<received|issued>/<transmittal>/<name>. '
+ + '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) {
@@ -10988,6 +11371,28 @@ X.B(E,Y);return E}return J}())
}
}
}
+ // 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;
}
@@ -10996,11 +11401,13 @@ X.B(E,Y);return E}return J}())
window.app.modules.copy = {
run: run,
+ audit: audit,
readyCount: readyCount,
chooseOutput: chooseOutput,
// test/advanced seams
plan: plan,
conflictsIn: conflictsIn,
+ resolvePlan: resolvePlan,
copyTo: copyTo,
};
})();
@@ -13087,10 +13494,18 @@ X.B(E,Y);return E}return J}())
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' : '');
@@ -13107,6 +13522,7 @@ X.B(E,Y);return E}return J}())
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);
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html
index 6ded5c0..322b0da 100644
--- a/zddc/internal/apps/embedded/index.html
+++ b/zddc/internal/apps/embedded/index.html
@@ -1778,7 +1778,7 @@ body {