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"] {
ZDDC Archive - v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08 + v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 26930d8..01b8f3b 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
ZDDC Browse - v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08 + v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 1d54d93..e9d0187 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1614,7 +1614,11 @@ body.is-elevated::after { /* 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); } @@ -1737,23 +1741,75 @@ 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); } /** * Spreadsheet Styles @@ -2240,7 +2296,7 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
ZDDC Classifier - v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08 + v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
@@ -2279,11 +2335,15 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
@@ -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 {
ZDDC - v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08 + v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index ad6bf8e..690aec0 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2770,7 +2770,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.27-beta · 2026-06-10 19:57:21 · 5f1df08 + v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
JavaScript not available