Compare commits
No commits in common. "8e10e5e5e6d82f85e82d4c4d518f91f227319fe9" and "6c3c58bc70ad83062883838459cdd5e0e7404738" have entirely different histories.
8e10e5e5e6
...
6c3c58bc70
13 changed files with 177 additions and 1498 deletions
|
|
@ -60,7 +60,6 @@ concat_files \
|
||||||
"js/validator.js" \
|
"js/validator.js" \
|
||||||
"js/scanner.js" \
|
"js/scanner.js" \
|
||||||
"js/tree.js" \
|
"js/tree.js" \
|
||||||
"js/dir-picker.js" \
|
|
||||||
"js/target-tree.js" \
|
"js/target-tree.js" \
|
||||||
"js/copy.js" \
|
"js/copy.js" \
|
||||||
"js/spreadsheet.js" \
|
"js/spreadsheet.js" \
|
||||||
|
|
|
||||||
|
|
@ -571,16 +571,24 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Target tabs: grouped (assign a tracking number) + separate (route) ───── */
|
/* ── Catalog overlay (MDL ∪ archive; seltable rows = drop targets) ───────── */
|
||||||
.pane-header--target { flex-wrap: wrap; }
|
.target-pane { position: relative; }
|
||||||
.target-goal { flex: 1 0 100%; margin: 0 0 0.4rem; font-size: 0.78rem; color: var(--text-muted); line-height: 1.4; }
|
.target-tabs__catalog { margin-left: 0.75rem; }
|
||||||
.target-goal strong { color: var(--text); }
|
.catalog-overlay {
|
||||||
.target-goal em { font-style: normal; font-weight: 600; color: var(--text); }
|
position: absolute; inset: 0; z-index: 20;
|
||||||
.target-tabs__group { display: flex; gap: 0.25rem; }
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
.target-tabs__divider { width: 1px; align-self: stretch; margin: 0.2rem 0.6rem 0; background: var(--border); }
|
background: var(--bg); border-left: 2px solid var(--primary);
|
||||||
/* The "By existing" catalog is now a normal in-flow tab panel. */
|
}
|
||||||
#mdlTree { flex: 1; min-height: 0; }
|
.catalog-overlay[hidden] { display: none; }
|
||||||
#mdlTree .seltable { height: 100%; }
|
.catalog-overlay__head {
|
||||||
|
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
||||||
|
padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); background: var(--bg-secondary, var(--bg));
|
||||||
|
}
|
||||||
|
.catalog-overlay__title { font-weight: 700; font-size: 0.9rem; }
|
||||||
|
.catalog-overlay__close { margin-left: auto; background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--text-muted); cursor: pointer; padding: 0 0.4rem; }
|
||||||
|
.catalog-overlay__close:hover { color: var(--text); }
|
||||||
|
.catalog-overlay__table { flex: 1; min-height: 0; }
|
||||||
|
.catalog-overlay__table .seltable { height: 100%; }
|
||||||
.mdl-rev__input {
|
.mdl-rev__input {
|
||||||
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
width: 8rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
|
border-radius: var(--radius); background: var(--bg); color: var(--text); font-size: 0.8rem;
|
||||||
|
|
@ -646,18 +654,6 @@ input.tfile__name:focus { border-color: var(--primary); background: var(--bg); o
|
||||||
background: var(--bg-secondary, var(--bg)); color: var(--text); font-size: 0.9rem;
|
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; }
|
.copy-choice__btns { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.5rem; }
|
||||||
.copy-choice--wide { max-width: 560px; }
|
|
||||||
|
|
||||||
/* ── Directory picker (lazy multi-select tree inside the copy-choice modal) ─ */
|
|
||||||
.dir-picker__tree {
|
|
||||||
max-height: 50vh; overflow: auto; margin: 0 0 1rem;
|
|
||||||
border: 1px solid var(--border); border-radius: var(--radius); padding: 0.4rem;
|
|
||||||
}
|
|
||||||
.dir-picker__row { display: flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; padding: 0.05rem 0; }
|
|
||||||
.dir-picker__twisty { width: 1rem; text-align: center; cursor: pointer; color: var(--text-muted); user-select: none; }
|
|
||||||
.dir-picker__name { cursor: pointer; }
|
|
||||||
.dir-picker__children { margin-left: 1.1rem; }
|
|
||||||
.dir-picker__err { color: var(--danger); font-size: 0.78rem; margin-left: 1.1rem; }
|
|
||||||
|
|
||||||
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
|
/* ── By-tracking merged-cell table ──────────────────────────────────────── */
|
||||||
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
|
#trackingTree { padding: 0; } /* table reaches the edges; cells carry padding */
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
/**
|
|
||||||
* ZDDC Classifier — lazy, multi-select directory picker (modal).
|
|
||||||
*
|
|
||||||
* Given one or more root directory handles, render an expandable checkbox tree
|
|
||||||
* and let the user TICK the directories whose files they want. Ticking a
|
|
||||||
* directory includes its whole subtree; a descendant under a ticked ancestor
|
|
||||||
* shows as checked+disabled (covered). Confirm resolves with the TOPMOST ticked
|
|
||||||
* handles only (a ticked child under a ticked parent is dropped — the parent's
|
|
||||||
* recursive walk covers it). Cancel/Esc/backdrop → [].
|
|
||||||
*
|
|
||||||
* Handle-agnostic: a "handle" is anything exposing async `values()` (yielding
|
|
||||||
* child handles {name, kind}) and `getDirectoryHandle(name)` — satisfied by both
|
|
||||||
* zddc-source.js HttpDirectoryHandle and native FileSystemDirectoryHandle.
|
|
||||||
*
|
|
||||||
* window.app.modules.dirPicker.pick(roots) → Promise<handle[]>
|
|
||||||
* roots: [ { label, handle } ]
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
if (!window.app) window.app = {};
|
|
||||||
if (!window.app.modules) window.app.modules = {};
|
|
||||||
|
|
||||||
function elt(tag, cls, text) {
|
|
||||||
var e = document.createElement(tag);
|
|
||||||
if (cls) e.className = cls;
|
|
||||||
if (text != null) e.textContent = text;
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
// Same skip set as the archive walk: dotfiles, system (_), and risk folders.
|
|
||||||
function hiddenName(nm) { return nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk'; }
|
|
||||||
|
|
||||||
function ancestorChecked(node) {
|
|
||||||
for (var p = node.parent; p; p = p.parent) { if (p.checked) return true; }
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Topmost ticked handles: a node whose own `checked` is set and which has no
|
|
||||||
// checked ancestor. Pure over { checked, handle, children } — also the test seam.
|
|
||||||
function collect(nodes, underChecked, out) {
|
|
||||||
(nodes || []).forEach(function (n) {
|
|
||||||
if (n.checked && !underChecked) out.push(n.handle);
|
|
||||||
collect(n.children, underChecked || !!n.checked, out);
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(roots) {
|
|
||||||
return new Promise(function (resolve) {
|
|
||||||
var done = false, rootNodes = [];
|
|
||||||
function finish(v) { if (done) return; done = true; document.removeEventListener('keydown', onKey); back.remove(); resolve(v); }
|
|
||||||
function onKey(e) { if (e.key === 'Escape') finish([]); }
|
|
||||||
|
|
||||||
var back = elt('div', 'copy-choice__backdrop');
|
|
||||||
var box = elt('div', 'copy-choice copy-choice--wide');
|
|
||||||
var h = elt('h3', null, 'Choose directories to scan');
|
|
||||||
var p = elt('p', null, 'Tick the directories whose files you want in the catalog — subfolders are included. Expand with ▸.');
|
|
||||||
var treeWrap = elt('div', 'dir-picker__tree');
|
|
||||||
var btns = elt('div', 'copy-choice__btns');
|
|
||||||
var go = elt('button', 'btn btn-primary', 'Scan'); go.disabled = true;
|
|
||||||
go.addEventListener('click', function () { finish(collect(rootNodes, false, [])); });
|
|
||||||
var cancel = elt('button', 'btn btn-secondary', 'Cancel');
|
|
||||||
cancel.addEventListener('click', function () { finish([]); });
|
|
||||||
btns.appendChild(go); btns.appendChild(cancel);
|
|
||||||
box.appendChild(h); box.appendChild(p); box.appendChild(treeWrap); box.appendChild(btns);
|
|
||||||
back.appendChild(box);
|
|
||||||
back.addEventListener('click', function (e) { if (e.target === back) finish([]); });
|
|
||||||
document.addEventListener('keydown', onKey);
|
|
||||||
document.body.appendChild(back);
|
|
||||||
|
|
||||||
function refreshGo() { go.disabled = collect(rootNodes, false, []).length === 0; }
|
|
||||||
|
|
||||||
// Recompute the displayed checkbox state of a subtree: a node under a
|
|
||||||
// checked ancestor is forced checked + disabled (inherited coverage).
|
|
||||||
function recompute(node, inherited) {
|
|
||||||
node.checkbox.disabled = inherited;
|
|
||||||
node.checkbox.checked = inherited || node.checked;
|
|
||||||
var below = inherited || node.checked;
|
|
||||||
node.children.forEach(function (c) { recompute(c, below); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeNode(handle, label, parent, container) {
|
|
||||||
var node = { handle: handle, name: label, parent: parent, checked: false, expanded: false, loaded: false, children: [], childrenWrap: null, checkbox: null };
|
|
||||||
var rowEl = elt('div', 'dir-picker__row');
|
|
||||||
var twisty = elt('span', 'dir-picker__twisty', '▸');
|
|
||||||
var cb = elt('input'); cb.type = 'checkbox';
|
|
||||||
var nameEl = elt('span', 'dir-picker__name', label);
|
|
||||||
twisty.addEventListener('click', function () { toggle(node, twisty); });
|
|
||||||
nameEl.addEventListener('click', function () { toggle(node, twisty); });
|
|
||||||
cb.addEventListener('change', function () {
|
|
||||||
if (cb.disabled) return;
|
|
||||||
node.checked = cb.checked;
|
|
||||||
recompute(node, ancestorChecked(node));
|
|
||||||
refreshGo();
|
|
||||||
});
|
|
||||||
rowEl.appendChild(twisty); rowEl.appendChild(cb); rowEl.appendChild(nameEl);
|
|
||||||
var kids = elt('div', 'dir-picker__children'); kids.hidden = true;
|
|
||||||
node.checkbox = cb; node.childrenWrap = kids;
|
|
||||||
container.appendChild(rowEl); container.appendChild(kids);
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggle(node, twisty) {
|
|
||||||
node.expanded = !node.expanded;
|
|
||||||
node.childrenWrap.hidden = !node.expanded;
|
|
||||||
twisty.textContent = node.expanded ? '▾' : '▸';
|
|
||||||
if (node.expanded && !node.loaded) {
|
|
||||||
node.loaded = true;
|
|
||||||
twisty.textContent = '…';
|
|
||||||
try {
|
|
||||||
for await (var e of node.handle.values()) {
|
|
||||||
if (e.kind !== 'directory') continue;
|
|
||||||
var nm = String(e.name).replace(/\/$/, '');
|
|
||||||
if (hiddenName(nm)) continue;
|
|
||||||
var childHandle = e.getDirectoryHandle ? e : await node.handle.getDirectoryHandle(nm);
|
|
||||||
var child = makeNode(childHandle, nm, node, node.childrenWrap);
|
|
||||||
node.children.push(child);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
node.childrenWrap.appendChild(elt('div', 'dir-picker__err', 'Could not read — ' + (err.message || err)));
|
|
||||||
}
|
|
||||||
twisty.textContent = node.children.length ? (node.expanded ? '▾' : '▸') : '·';
|
|
||||||
// A freshly-loaded subtree inherits an already-checked ancestor.
|
|
||||||
recompute(node, ancestorChecked(node));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(roots || []).forEach(function (r) { rootNodes.push(makeNode(r.handle, r.label, null, treeWrap)); });
|
|
||||||
if (!rootNodes.length) finish([]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app.modules.dirPicker = {
|
|
||||||
pick: pick,
|
|
||||||
_collect: function (nodes) { return collect(nodes, false, []); }, // test seam
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
|
var collapsed = {}; // nodeId -> true when collapsed (default expanded)
|
||||||
var openForm = null; // { partyId, slot } when a bin form is open
|
var openForm = null; // { partyId, slot } when a bin form is open
|
||||||
var initialized = false;
|
var initialized = false;
|
||||||
var currentTab = 'tracking'; // 'tracking' | 'existing' | 'transmittal' — active tab
|
var currentTab = 'tracking'; // 'tracking' | 'transmittal' — the active tab
|
||||||
|
var catalogOpen = false; // the Catalog overlay (the 'mdl' axis) is open
|
||||||
var mdlTable = null; // the seltable controller for the catalog
|
var mdlTable = null; // the seltable controller for the catalog
|
||||||
var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell)
|
var mdlPlaced = {}; // latest placed.mdl map (read by the placed-file cell)
|
||||||
|
|
||||||
|
|
@ -28,8 +29,8 @@
|
||||||
initialized = true;
|
initialized = true;
|
||||||
els = {
|
els = {
|
||||||
trackingTab: document.getElementById('trackingTab'),
|
trackingTab: document.getElementById('trackingTab'),
|
||||||
existingTab: document.getElementById('existingTab'),
|
|
||||||
transmittalTab: document.getElementById('transmittalTab'),
|
transmittalTab: document.getElementById('transmittalTab'),
|
||||||
|
catalogBtn: document.getElementById('catalogBtn'),
|
||||||
trackingPanel: document.getElementById('trackingPanel'),
|
trackingPanel: document.getElementById('trackingPanel'),
|
||||||
transmittalPanel: document.getElementById('transmittalPanel'),
|
transmittalPanel: document.getElementById('transmittalPanel'),
|
||||||
mdlPanel: document.getElementById('mdlPanel'),
|
mdlPanel: document.getElementById('mdlPanel'),
|
||||||
|
|
@ -37,14 +38,16 @@
|
||||||
transmittalTree: document.getElementById('transmittalTree'),
|
transmittalTree: document.getElementById('transmittalTree'),
|
||||||
mdlTree: document.getElementById('mdlTree'),
|
mdlTree: document.getElementById('mdlTree'),
|
||||||
loadMdlBtn: document.getElementById('loadMdlBtn'),
|
loadMdlBtn: document.getElementById('loadMdlBtn'),
|
||||||
|
catalogCloseBtn: document.getElementById('catalogCloseBtn'),
|
||||||
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
addTrackingRootBtn: document.getElementById('addTrackingRootBtn'),
|
||||||
addPartyBtn: document.getElementById('addPartyBtn'),
|
addPartyBtn: document.getElementById('addPartyBtn'),
|
||||||
stats: document.getElementById('classifyStats'),
|
stats: document.getElementById('classifyStats'),
|
||||||
};
|
};
|
||||||
|
|
||||||
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
|
els.trackingTab.addEventListener('click', function () { showTab('tracking'); });
|
||||||
if (els.existingTab) els.existingTab.addEventListener('click', function () { showTab('existing'); });
|
|
||||||
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
els.transmittalTab.addEventListener('click', function () { showTab('transmittal'); });
|
||||||
|
if (els.catalogBtn) els.catalogBtn.addEventListener('click', function () { catalogOpen ? closeCatalog() : openCatalog(); });
|
||||||
|
if (els.catalogCloseBtn) els.catalogCloseBtn.addEventListener('click', closeCatalog);
|
||||||
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
|
if (els.loadMdlBtn) els.loadMdlBtn.addEventListener('click', loadMdl);
|
||||||
els.addTrackingRootBtn.addEventListener('click', function () {
|
els.addTrackingRootBtn.addEventListener('click', function () {
|
||||||
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
var name = prompt('Root folder name (a tracking-number segment, e.g. "ACME-PROJ").\n'
|
||||||
|
|
@ -98,21 +101,31 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(which) {
|
function showTab(which) {
|
||||||
currentTab = (which === 'transmittal' || which === 'existing') ? which : 'tracking';
|
currentTab = which === 'transmittal' ? 'transmittal' : 'tracking';
|
||||||
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
|
els.trackingTab.classList.toggle('active', currentTab === 'tracking');
|
||||||
if (els.existingTab) els.existingTab.classList.toggle('active', currentTab === 'existing');
|
|
||||||
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
|
els.transmittalTab.classList.toggle('active', currentTab === 'transmittal');
|
||||||
els.trackingPanel.hidden = currentTab !== 'tracking';
|
els.trackingPanel.hidden = currentTab !== 'tracking';
|
||||||
if (els.mdlPanel) els.mdlPanel.hidden = currentTab !== 'existing';
|
|
||||||
els.transmittalPanel.hidden = currentTab !== 'transmittal';
|
els.transmittalPanel.hidden = currentTab !== 'transmittal';
|
||||||
render();
|
|
||||||
// The source-tree Show filters are per-axis, so the visible set changes
|
// The source-tree Show filters are per-axis, so the visible set changes
|
||||||
// with the active tab — re-render the left tree.
|
// with the active tab — re-render the left tree.
|
||||||
|
if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render();
|
||||||
|
}
|
||||||
|
// The active axis is the catalog ('mdl') while the overlay is open, else the tab.
|
||||||
|
function activeAxis() { return catalogOpen ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); }
|
||||||
|
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
|
||||||
|
function openCatalog() {
|
||||||
|
catalogOpen = true;
|
||||||
|
if (els.mdlPanel) els.mdlPanel.hidden = false;
|
||||||
|
if (els.catalogBtn) els.catalogBtn.classList.add('active');
|
||||||
|
render();
|
||||||
|
reRenderSource();
|
||||||
|
}
|
||||||
|
function closeCatalog() {
|
||||||
|
catalogOpen = false;
|
||||||
|
if (els.mdlPanel) els.mdlPanel.hidden = true;
|
||||||
|
if (els.catalogBtn) els.catalogBtn.classList.remove('active');
|
||||||
reRenderSource();
|
reRenderSource();
|
||||||
}
|
}
|
||||||
// The active axis is the catalog ('mdl') on the "By existing" tab, else the tab's.
|
|
||||||
function activeAxis() { return currentTab === 'existing' ? 'mdl' : (currentTab === 'transmittal' ? 'transmittal' : 'tracking'); }
|
|
||||||
function reRenderSource() { if (window.app.modules.tree && window.app.modules.tree.render) window.app.modules.tree.render(); }
|
|
||||||
|
|
||||||
// Expand a brace pattern into folder names and create them (confirming a
|
// Expand a brace pattern into folder names and create them (confirming a
|
||||||
// multi-create first). parentId null = root folders. See expandFolderPattern.
|
// multi-create first). parentId null = root folders. See expandFolderPattern.
|
||||||
|
|
@ -477,7 +490,7 @@
|
||||||
if (!C().getMdlList().length) {
|
if (!C().getMdlList().length) {
|
||||||
mdlTable = null;
|
mdlTable = null;
|
||||||
els.mdlTree.textContent = '';
|
els.mdlTree.textContent = '';
|
||||||
els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…”, tick the directories to scan, and their existing files + MDL deliverables appear here (one row per tracking number, latest revision).'));
|
els.mdlTree.appendChild(el('div', 'target-empty', 'Nothing loaded yet — “Load…” to pull in the project’s MDL deliverables and archive tracking numbers.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ensureMdlTable();
|
ensureMdlTable();
|
||||||
|
|
@ -494,7 +507,7 @@
|
||||||
});
|
});
|
||||||
cols.push({ key: 'title', title: 'Title', get: function (r) { return r.title || ''; } });
|
cols.push({ key: 'title', title: 'Title', get: function (r) { return r.title || ''; } });
|
||||||
cols.push({ key: 'mdl', title: 'MDL', cls: 'catalog-mdl', get: function (r) { return r.inMdl ? '✓' : ''; } });
|
cols.push({ key: 'mdl', title: 'MDL', cls: 'catalog-mdl', get: function (r) { return r.inMdl ? '✓' : ''; } });
|
||||||
cols.push({ key: 'latest', title: 'Latest rev', get: function (r) { return latestRevOf(r.archiveRevisions); } });
|
cols.push({ key: 'arev', title: 'Archive revs', get: function (r) { return (r.archiveRevisions || []).join(', '); } });
|
||||||
cols.push({
|
cols.push({
|
||||||
key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
key: 'rev', title: 'Revision', cls: 'mdl-rev', get: function (r) { return r.revisionCell; },
|
||||||
render: function (r, td) {
|
render: function (r, td) {
|
||||||
|
|
@ -545,82 +558,81 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the catalog: "Load…" opens a multi-select directory tree (scoped to
|
// Load the catalog: the union of the project's MDL deliverables and its
|
||||||
// the served context); every ticked directory is walked recursively into the
|
// archive tracking numbers, deduped by tracking number. Server reads both;
|
||||||
// union of existing files + MDL deliverables, deduped by tracking number to
|
// a local folder reads just its *.yaml deliverables. Writes/alters nothing —
|
||||||
// one row at the latest revision. Writes/alters nothing — the revision cell
|
// the revision cell is classifier-local and starts blank.
|
||||||
// is classifier-local and starts blank.
|
|
||||||
function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; }
|
function isRowYaml(nm) { return /\.yaml$/i.test(nm) && nm !== 'table.yaml' && nm !== 'form.yaml'; }
|
||||||
|
|
||||||
// The newest combined "<rev> (<status>)" string in a set, by revision token.
|
|
||||||
function latestRevOf(revs) {
|
|
||||||
var best = null, bestTok = null;
|
|
||||||
(revs || []).forEach(function (r) {
|
|
||||||
var tok = String(r).replace(/\s*\([^)]*\)\s*$/, '').trim(); // "A (IFR)" → "A"
|
|
||||||
if (best == null || window.zddc.compareRevisions(tok, bestTok) > 0) { best = r; bestTok = tok; }
|
|
||||||
});
|
|
||||||
return best || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where is the classifier served? Decides the directory-tree roots.
|
|
||||||
// 'local' → offline (file://), pick a folder.
|
|
||||||
// 'all' → standalone /_apps/classifier.html, root at every accessible project.
|
|
||||||
// {one:p} → under <project>/…, root at just that project.
|
|
||||||
function detectScope(pathname, hasSource, protocol) {
|
|
||||||
if (!hasSource || protocol === 'file:') return 'local';
|
|
||||||
if (/^\/_apps\//.test(pathname || '')) return 'all';
|
|
||||||
var seg = (String(pathname || '').split('/').filter(Boolean)[0]) || '';
|
|
||||||
return seg ? { one: seg } : 'all';
|
|
||||||
}
|
|
||||||
async function buildRoots() {
|
|
||||||
var src = window.zddc && window.zddc.source;
|
|
||||||
var scope = detectScope(location.pathname, !!src, location.protocol);
|
|
||||||
if (scope === 'local') {
|
|
||||||
if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local folder needs the File System Access API (Chromium).', 'error'); return null; }
|
|
||||||
try { var dir = await window.showDirectoryPicker({ mode: 'read' }); return [{ label: dir.name || 'Selected folder', handle: dir }]; }
|
|
||||||
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return null; }
|
|
||||||
}
|
|
||||||
function archiveOf(rel) {
|
|
||||||
if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
|
||||||
return new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
|
|
||||||
}
|
|
||||||
if (scope === 'all') {
|
|
||||||
var projects = await window.app.modules.copy.fetchAccessProjects();
|
|
||||||
if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return null; }
|
|
||||||
if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return null; }
|
|
||||||
return projects.map(function (p) { return { label: (p.title ? p.name + ' — ' + p.title : p.name), handle: archiveOf(p.url || ('/' + p.name + '/')) }; });
|
|
||||||
}
|
|
||||||
return [{ label: scope.one, handle: archiveOf('/' + scope.one + '/') }];
|
|
||||||
}
|
|
||||||
async function loadMdl() {
|
async function loadMdl() {
|
||||||
var roots = await buildRoots();
|
if (window.zddc && window.zddc.source && location.protocol !== 'file:') return loadMdlServer();
|
||||||
if (!roots) return;
|
return loadMdlLocal();
|
||||||
var picked = await window.app.modules.dirPicker.pick(roots);
|
}
|
||||||
if (!picked || !picked.length) return;
|
async function loadMdlLocal() {
|
||||||
|
if (!window.showDirectoryPicker) { window.zddc.toast('Loading a local MDL needs the File System Access API (Chromium).', 'error'); return; }
|
||||||
|
var dir;
|
||||||
|
try { dir = await window.showDirectoryPicker({ mode: 'read' }); }
|
||||||
|
catch (e) { if (e.name !== 'AbortError') window.zddc.toast('Could not open the folder — ' + (e.message || e), 'error'); return; }
|
||||||
|
var rows = [];
|
||||||
|
try {
|
||||||
|
for await (var entry of dir.values()) {
|
||||||
|
var nm = String(entry.name).replace(/\/$/, '');
|
||||||
|
if (entry.kind !== 'file' || !isRowYaml(nm)) continue;
|
||||||
|
var obj = null; try { obj = window.jsyaml.load(await (await entry.getFile()).text()); } catch (_) { /* skip */ }
|
||||||
|
var stem = nm.replace(/\.yaml$/i, '');
|
||||||
|
rows.push({ id: stem, party: dir.name || 'local', trackingNumber: stem, title: (obj && obj.title) || '', inMdl: true, archiveRevisions: [], revisionCell: '' });
|
||||||
|
}
|
||||||
|
} catch (e) { window.zddc.toast('Reading the MDL folder failed — ' + (e.message || e), 'error'); return; }
|
||||||
|
finishLoad(rows);
|
||||||
|
}
|
||||||
|
async function loadMdlServer() {
|
||||||
|
var copy = window.app.modules.copy, src = window.zddc.source;
|
||||||
|
var projects = await copy.fetchAccessProjects();
|
||||||
|
if (projects == null) { window.zddc.toast('Could not load your projects from the server.', 'error'); return; }
|
||||||
|
if (!projects.length) { window.zddc.toast('No projects you can access on this server.', 'warning'); return; }
|
||||||
|
var proj = await copy.chooseProject(projects);
|
||||||
|
if (!proj) return;
|
||||||
|
var rel = (proj.url || ('/' + proj.name + '/')); if (rel.charAt(rel.length - 1) !== '/') rel += '/';
|
||||||
|
var archive = new src.HttpDirectoryHandle(new URL(rel + 'archive/', location.origin).href, 'archive');
|
||||||
var byTn = Object.create(null);
|
var byTn = Object.create(null);
|
||||||
function ensure(tn) { return byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); }
|
function ensure(tn) { return byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) }); }
|
||||||
window.zddc.toast('Scanning selected directories…', 'info', { durationMs: 4000 });
|
window.zddc.toast('Scanning the project MDL + archive…', 'info', { durationMs: 4000 });
|
||||||
try { for (var i = 0; i < picked.length; i++) await walkDirInto(picked[i], ensure); }
|
try {
|
||||||
catch (e) { window.zddc.toast('Reading the directories failed — ' + (e.message || e), 'error'); return; }
|
for await (var party of archive.values()) {
|
||||||
|
if (party.kind !== 'directory') continue;
|
||||||
|
var pn = String(party.name).replace(/\/$/, '');
|
||||||
|
if (pn.charAt(0) === '.' || pn.charAt(0) === '_') continue;
|
||||||
|
for await (var slot of party.values()) {
|
||||||
|
if (slot.kind !== 'directory') continue;
|
||||||
|
var sn = String(slot.name).replace(/\/$/, '');
|
||||||
|
if (sn.charAt(0) === '.' || sn.charAt(0) === '_' || sn === 'rsk') continue;
|
||||||
|
if (sn === 'mdl') { // registered deliverables
|
||||||
|
for await (var ye of slot.values()) {
|
||||||
|
var ynm = String(ye.name).replace(/\/$/, '');
|
||||||
|
if (ye.kind !== 'file' || !isRowYaml(ynm)) continue;
|
||||||
|
var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ }
|
||||||
|
var row = ensure(ynm.replace(/\.yaml$/i, ''));
|
||||||
|
row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title; if (!row.party) row.party = pn;
|
||||||
|
}
|
||||||
|
} else { // archive documents → tracking + revisions
|
||||||
|
await walkArchiveInto(slot, ensure, pn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { window.zddc.toast('Reading the project failed — ' + (e.message || e), 'error'); return; }
|
||||||
var rows = Object.keys(byTn).map(function (tn) {
|
var rows = Object.keys(byTn).map(function (tn) {
|
||||||
var x = byTn[tn];
|
var x = byTn[tn];
|
||||||
return { id: tn, party: x.party, trackingNumber: tn, title: x.title, inMdl: x.inMdl, archiveRevisions: Object.keys(x.revs).sort(), revisionCell: '' };
|
return { id: tn, party: x.party, trackingNumber: tn, title: x.title, inMdl: x.inMdl, archiveRevisions: Object.keys(x.revs).sort(), revisionCell: '' };
|
||||||
});
|
});
|
||||||
finishLoad(rows);
|
finishLoad(rows);
|
||||||
}
|
}
|
||||||
// Walk a ticked directory recursively. A dir named "mdl" (or the ticked dir
|
// Recursively collect ZDDC-named archive files under a slot → tracking +
|
||||||
// itself being an mdl folder) yields *.yaml deliverables → inMdl + title;
|
// the set of revisions seen for each.
|
||||||
// every other ZDDC-named file is an archive revision of its tracking number.
|
async function walkArchiveInto(dirH, ensure, party) {
|
||||||
async function walkDirInto(dirH, ensure) {
|
|
||||||
var party = (dirH.name && String(dirH.name).replace(/\/$/, '')) || '';
|
|
||||||
if (party === 'mdl') return readMdlYamls(dirH, ensure);
|
|
||||||
for await (var entry of dirH.values()) {
|
for await (var entry of dirH.values()) {
|
||||||
var nm = String(entry.name).replace(/\/$/, '');
|
var nm = String(entry.name).replace(/\/$/, '');
|
||||||
if (entry.kind === 'directory') {
|
if (entry.kind === 'directory') {
|
||||||
if (nm.charAt(0) === '.' || nm.charAt(0) === '_' || nm === 'rsk') continue;
|
if (nm.charAt(0) === '.' || nm.charAt(0) === '_') continue;
|
||||||
var child = entry.getDirectoryHandle ? entry : await dirH.getDirectoryHandle(nm);
|
await walkArchiveInto(await dirH.getDirectoryHandle(nm), ensure, party);
|
||||||
if (nm === 'mdl') await readMdlYamls(child, ensure);
|
|
||||||
else await walkDirInto(child, ensure);
|
|
||||||
} else {
|
} else {
|
||||||
var p = window.zddc.parseFilename(nm);
|
var p = window.zddc.parseFilename(nm);
|
||||||
if (p && p.valid && p.trackingNumber) {
|
if (p && p.valid && p.trackingNumber) {
|
||||||
|
|
@ -632,21 +644,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function readMdlYamls(mdlH, ensure) {
|
|
||||||
for await (var ye of mdlH.values()) {
|
|
||||||
var ynm = String(ye.name).replace(/\/$/, '');
|
|
||||||
if (ye.kind !== 'file' || !isRowYaml(ynm)) continue;
|
|
||||||
var obj = null; try { obj = window.jsyaml.load(await (await ye.getFile()).text()); } catch (_) { /* skip */ }
|
|
||||||
var row = ensure(ynm.replace(/\.yaml$/i, ''));
|
|
||||||
row.inMdl = true; if (!row.title && obj && obj.title) row.title = obj.title;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function finishLoad(rows) {
|
function finishLoad(rows) {
|
||||||
C().setMdlList(rows);
|
C().setMdlList(rows);
|
||||||
showTab('existing');
|
openCatalog();
|
||||||
window.zddc.toast(rows.length
|
window.zddc.toast(rows.length
|
||||||
? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' from the selected directories. Drag files on, set revisions.')
|
? ('Catalog: ' + rows.length + ' tracking number' + (rows.length === 1 ? '' : 's') + ' (MDL ∪ archive). Filter, drag files on, set revisions.')
|
||||||
: 'No files or deliverables in the selected directories.', rows.length ? 'success' : 'warning');
|
: 'Nothing found in the MDL or archive.', rows.length ? 'success' : 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── events ─────────────────────────────────────────────────────────────
|
// ── events ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -851,7 +854,7 @@
|
||||||
var a = C().getAssignment(key);
|
var a = C().getAssignment(key);
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
if (a.mdlNodeId) {
|
if (a.mdlNodeId) {
|
||||||
showTab('existing');
|
openCatalog();
|
||||||
if (mdlTable) { mdlTable.renderBody(); }
|
if (mdlTable) { mdlTable.renderBody(); }
|
||||||
} else if (a.trackingNodeId) {
|
} else if (a.trackingNodeId) {
|
||||||
showTab('tracking'); collapsed = {}; render();
|
showTab('tracking'); collapsed = {}; render();
|
||||||
|
|
@ -877,9 +880,5 @@
|
||||||
activeAxis: activeAxis,
|
activeAxis: activeAxis,
|
||||||
setNameFilter: setNameFilter,
|
setNameFilter: setNameFilter,
|
||||||
reveal: reveal,
|
reveal: reveal,
|
||||||
// test seams (pure)
|
|
||||||
_detectScope: detectScope,
|
|
||||||
_latestRevOf: latestRevOf,
|
|
||||||
_walkDirInto: walkDirInto,
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -158,17 +158,11 @@
|
||||||
|
|
||||||
<!-- Target Trees (Classify & Copy mode) — default view -->
|
<!-- Target Trees (Classify & Copy mode) — default view -->
|
||||||
<main class="target-pane" id="targetPane">
|
<main class="target-pane" id="targetPane">
|
||||||
<div class="pane-header pane-header--target">
|
<div class="pane-header">
|
||||||
<p class="target-goal">Each file needs a <strong>tracking number</strong> (revision + status + title) and a <strong>transmittal folder</strong>. Name it — build one under <em>By tracking number</em> or reuse one under <em>By existing</em> — then route it under <em>By transmittal</em>.</p>
|
|
||||||
<div class="target-tabs" role="tablist">
|
<div class="target-tabs" role="tablist">
|
||||||
<div class="target-tabs__group">
|
|
||||||
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
<button class="target-tab active" id="trackingTab" role="tab">By tracking number</button>
|
||||||
<button class="target-tab" id="existingTab" role="tab" title="Reuse a tracking number already in the project MDL or archive — drag files onto a row to assign it.">By existing</button>
|
|
||||||
</div>
|
|
||||||
<span class="target-tabs__divider" aria-hidden="true"></span>
|
|
||||||
<div class="target-tabs__group">
|
|
||||||
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
|
<button class="target-tab" id="transmittalTab" role="tab">By transmittal</button>
|
||||||
</div>
|
<button id="catalogBtn" class="btn btn-secondary btn-sm target-tabs__catalog" title="Open the catalog — everything the project knows: MDL deliverables ∪ archive tracking numbers. Drag files onto a row to name them.">⊞ Catalog</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pane-header-right">
|
<div class="pane-header-right">
|
||||||
<span id="classifyStats" class="file-stats"></span>
|
<span id="classifyStats" class="file-stats"></span>
|
||||||
|
|
@ -200,18 +194,20 @@
|
||||||
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
placeholder="Filter the transmittal tree…" aria-label="Filter transmittal tree">
|
||||||
<div id="transmittalTree" class="target-tree"></div>
|
<div id="transmittalTree" class="target-tree"></div>
|
||||||
</section>
|
</section>
|
||||||
<!-- "By existing": the catalog — files + MDL deliverables from the
|
</div>
|
||||||
directories you tick, deduped to one row per tracking number at
|
<!-- Catalog overlay (everything the project knows): opened by the
|
||||||
the latest revision. Read-only on the MDL/archive; the Revision
|
Catalog button. Covers the target pane; the left filetree stays
|
||||||
column is classifier-local. The left filetree stays the drag source. -->
|
the drag source. Read-only on the MDL/archive; the Revision column
|
||||||
<section id="mdlPanel" class="target-panel" hidden>
|
is classifier-local. -->
|
||||||
<div class="target-panel__toolbar">
|
<section id="mdlPanel" class="catalog-overlay" hidden>
|
||||||
|
<div class="catalog-overlay__head">
|
||||||
|
<span class="catalog-overlay__title">Catalog — MDL ∪ archive</span>
|
||||||
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load…</button>
|
<button id="loadMdlBtn" class="btn btn-sm btn-secondary">⊞ Load…</button>
|
||||||
<span class="target-hint">“Load…”, tick the directories to scan, then drag files onto a row to assign its tracking number; set the Revision column (ctrl-shift select + ctrl-Enter to set many).</span>
|
<span class="target-hint">Drag files onto a row to assign its tracking number; set the Revision column (ctrl-shift select + ctrl-Enter to set many at once).</span>
|
||||||
|
<button id="catalogCloseBtn" class="catalog-overlay__close" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="mdlTree" class="target-tree"></div>
|
<div id="mdlTree" class="catalog-overlay__table"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1249,7 +1249,7 @@ test('classify: an MDL placement names a file; revision from the cell, transmitt
|
||||||
expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title
|
expect(r.fileTitleName).toContain('messy scan 47'); // title toggle → the file's own title
|
||||||
});
|
});
|
||||||
|
|
||||||
test('By existing: shows latest rev, drop on a row names the file, bulk revision applies', async ({ page }) => {
|
test('Catalog: shows archive revs, drop on a row names the file, bulk revision applies', async ({ page }) => {
|
||||||
await page.click('#modeClassifyBtn');
|
await page.click('#modeClassifyBtn');
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
const c = window.app.modules.classify, tt = window.app.modules.targetTree;
|
||||||
|
|
@ -1257,106 +1257,22 @@ test('By existing: shows latest rev, drop on a row names the file, bulk revision
|
||||||
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
|
const f = { originalFilename: 'scan', extension: 'pdf', folderPath: 'R' };
|
||||||
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
window.app.folderTree = [{ name: 'R', path: 'R', files: [f], children: [] }];
|
||||||
const key = c.srcKeyForFile(f);
|
const key = c.srcKeyForFile(f);
|
||||||
// Catalog rows = files ∪ MDL deliverables, deduped per tracking number.
|
// Catalog rows = MDL ∪ archive merged: one MDL+archive row, one archive-only.
|
||||||
c.setMdlList([
|
c.setMdlList([
|
||||||
{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'] },
|
{ id: 'm1', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0001', title: 'Spec', inMdl: true, archiveRevisions: ['A (IFR)', 'B (IFC)'] },
|
||||||
{ id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', inMdl: false, archiveRevisions: ['0 (IFC)'] },
|
{ id: 'm2', party: 'ACM', trackingNumber: 'ACM-PRJ-EL-SPC-0002', title: 'Spec 2', inMdl: false, archiveRevisions: ['0 (IFC)'] },
|
||||||
]);
|
]);
|
||||||
tt.showTab('existing'); // shows the catalog panel + builds the seltable into #mdlTree
|
tt.render(); // builds the catalog seltable into #mdlTree
|
||||||
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
|
const row = document.querySelector('#mdlTree .seltable__row[data-id="m1"]');
|
||||||
// Latest rev only: B (IFC) > A (IFR), so the cell shows B (IFC), not A (IFR).
|
const archiveRevsShown = !!row && row.textContent.includes('A (IFR)') && row.textContent.includes('B (IFC)');
|
||||||
const latestShown = !!row && row.textContent.includes('B (IFC)') && !row.textContent.includes('A (IFR)');
|
|
||||||
window.app.modules.dnd.setDrag([key]);
|
window.app.modules.dnd.setDrag([key]);
|
||||||
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1
|
row.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true })); // drop the file on m1
|
||||||
const placed = (c.getAssignment(key) || {}).mdlNodeId;
|
const placed = (c.getAssignment(key) || {}).mdlNodeId;
|
||||||
c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-Enter bulk path
|
c.setRevisionCells(['m1', 'm2'], 'A (IFR)'); // ctrl-Enter bulk path
|
||||||
return { hasRow: !!row, latestShown, placed, named: c.deriveTarget(f).filename };
|
return { hasRow: !!row, archiveRevsShown, placed, named: c.deriveTarget(f).filename };
|
||||||
});
|
});
|
||||||
expect(r.hasRow).toBe(true);
|
expect(r.hasRow).toBe(true);
|
||||||
expect(r.latestShown).toBe(true); // only the latest archive revision shown
|
expect(r.archiveRevsShown).toBe(true); // merged archive revisions shown (informational)
|
||||||
expect(r.placed).toBe('m1'); // drop = tracking number only
|
expect(r.placed).toBe('m1'); // drop = tracking number only
|
||||||
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // revision from the bulk-set column feeds the name
|
expect(r.named).toBe('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf'); // revision from the bulk-set column feeds the name
|
||||||
});
|
});
|
||||||
|
|
||||||
test('By existing: walkDirInto unions files + mdl deliverables, deduped to the latest revision', async ({ page }) => {
|
|
||||||
await page.click('#modeClassifyBtn');
|
|
||||||
const r = await page.evaluate(async () => {
|
|
||||||
const tt = window.app.modules.targetTree;
|
|
||||||
function fdir(name, children) {
|
|
||||||
return {
|
|
||||||
name, kind: 'directory',
|
|
||||||
async *values() { for (const ch of children) yield ch; },
|
|
||||||
async getDirectoryHandle(n) { const c = children.find(x => x.name === n && x.kind === 'directory'); if (!c) throw new Error('nf'); return c; },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function ffile(name, text) { return { name, kind: 'file', async getFile() { return { text: async () => text }; } }; }
|
|
||||||
// archive/PartyA/{mdl/<tn>.yaml, issued/T1/<A,B revs>}, archive/PartyB/issued/T2/<~A draft>
|
|
||||||
const root = fdir('archive', [
|
|
||||||
fdir('PartyA', [
|
|
||||||
fdir('mdl', [ffile('ACM-PRJ-EL-SPC-0001.yaml', 'title: Switchgear Spec\n')]),
|
|
||||||
fdir('issued', [fdir('T1', [
|
|
||||||
ffile('ACM-PRJ-EL-SPC-0001_A (IFR) - Spec.pdf', ''),
|
|
||||||
ffile('ACM-PRJ-EL-SPC-0001_B (IFC) - Spec.pdf', ''),
|
|
||||||
ffile('notes.txt', ''), // non-ZDDC → ignored
|
|
||||||
])]),
|
|
||||||
]),
|
|
||||||
fdir('PartyB', [fdir('issued', [fdir('T2', [
|
|
||||||
ffile('ACM-PRJ-EL-SPC-0001_~A (IFA) - Draft.pdf', ''), // older draft, other party
|
|
||||||
])])]),
|
|
||||||
fdir('_system', [fdir('issued', [ffile('ACM-PRJ-EL-SPC-9999_A (IFA) - hidden.pdf', '')])]), // skipped
|
|
||||||
]);
|
|
||||||
const byTn = Object.create(null);
|
|
||||||
const ensure = (tn) => byTn[tn] || (byTn[tn] = { tracking: tn, title: '', inMdl: false, party: '', revs: Object.create(null) });
|
|
||||||
await tt._walkDirInto(root, ensure);
|
|
||||||
const keys = Object.keys(byTn);
|
|
||||||
const x = byTn['ACM-PRJ-EL-SPC-0001'];
|
|
||||||
return {
|
|
||||||
trackingNumbers: keys,
|
|
||||||
inMdl: !!(x && x.inMdl),
|
|
||||||
title: x && x.title,
|
|
||||||
revs: x && Object.keys(x.revs).sort(),
|
|
||||||
latest: tt._latestRevOf(x && Object.keys(x.revs)),
|
|
||||||
latestDraftLoses: tt._latestRevOf(['~A (IFR)', 'A (IFC)']),
|
|
||||||
latestModifierWins: tt._latestRevOf(['A (IFR)', 'A+B1 (IFC)']),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
expect(r.trackingNumbers).toEqual(['ACM-PRJ-EL-SPC-0001']); // one row; _system skipped, .txt ignored
|
|
||||||
expect(r.inMdl).toBe(true); // the mdl/*.yaml registered it
|
|
||||||
expect(r.title).toBe('Switchgear Spec'); // title from the deliverable yaml
|
|
||||||
expect(r.revs).toEqual(['A (IFR)', 'B (IFC)', '~A (IFA)']); // revisions unioned across parties
|
|
||||||
expect(r.latest).toBe('B (IFC)'); // B > A > ~A
|
|
||||||
expect(r.latestDraftLoses).toBe('A (IFC)'); // ~A < A
|
|
||||||
expect(r.latestModifierWins).toBe('A+B1 (IFC)'); // A < A+B1
|
|
||||||
});
|
|
||||||
|
|
||||||
test('By existing: _detectScope routes by URL/protocol', async ({ page }) => {
|
|
||||||
const r = await page.evaluate(() => {
|
|
||||||
const tt = window.app.modules.targetTree;
|
|
||||||
return {
|
|
||||||
apps: tt._detectScope('/_apps/classifier.html', true, 'https:'),
|
|
||||||
project: tt._detectScope('/Project-1/archive/PartyA/incoming/', true, 'https:'),
|
|
||||||
offline: tt._detectScope('/anything', false, 'file:'),
|
|
||||||
offlineHttp: tt._detectScope('/x', true, 'file:'),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
expect(r.apps).toBe('all');
|
|
||||||
expect(r.project).toEqual({ one: 'Project-1' });
|
|
||||||
expect(r.offline).toBe('local');
|
|
||||||
expect(r.offlineHttp).toBe('local');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('By existing: dir-picker resolves the topmost ticked directories only', async ({ page }) => {
|
|
||||||
const r = await page.evaluate(() => {
|
|
||||||
const dp = window.app.modules.dirPicker;
|
|
||||||
const childOfA = { handle: 'A/x', checked: true, children: [] };
|
|
||||||
const A = { handle: 'A', checked: true, children: [childOfA] };
|
|
||||||
const grand = { handle: 'B/y/z', checked: false, children: [] };
|
|
||||||
const childOfB = { handle: 'B/y', checked: true, children: [grand] };
|
|
||||||
const B = { handle: 'B', checked: false, children: [childOfB] };
|
|
||||||
const unchecked = { handle: 'C', checked: false, children: [] };
|
|
||||||
return dp._collect([A, B, unchecked]);
|
|
||||||
});
|
|
||||||
// A is ticked (its ticked child A/x is dropped — covered by A); B itself isn't
|
|
||||||
// ticked but its child B/y is, so B/y is included; C contributes nothing.
|
|
||||||
expect(r).toEqual(['A', 'B/y']);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -2717,7 +2717,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -2824,7 +2824,7 @@ li.CodeMirror-hint-active {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing">⟳</button>
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1778,7 +1778,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -2770,7 +2770,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
archive=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
transmittal=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
transmittal=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
classifier=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
classifier=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
landing=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
landing=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
form=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
form=v0.0.27-beta · 2026-06-11 14:40:24 · bc762a7
|
||||||
tables=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
tables=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
|
||||||
browse=v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3
|
browse=v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7
|
||||||
|
|
|
||||||
|
|
@ -1245,53 +1245,6 @@ body.is-elevated::after {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shared selectable + autofilter table (seltable) + its hosting overlay ───
|
|
||||||
Used by the tables tool's "Add from archive". The classifier carries an
|
|
||||||
equivalent copy inline in its layout.css for the catalog. */
|
|
||||||
.seltable { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
|
||||||
.seltable__bar { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
|
||||||
.seltable__count { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
|
|
||||||
.seltable__scroll { flex: 1; min-height: 0; overflow: auto; }
|
|
||||||
.seltable__table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: 0.82rem; }
|
|
||||||
.seltable__table th, .seltable__table td { border-bottom: 1px solid var(--border); padding: 0.25rem 0.5rem; text-align: left; white-space: nowrap; }
|
|
||||||
.seltable__table thead th {
|
|
||||||
position: sticky; top: 0; z-index: 2; background: var(--bg-secondary, var(--bg));
|
|
||||||
color: var(--text-muted); font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.seltable__table thead tr.seltable__filters th { top: 1.55rem; padding: 0.15rem 0.35rem; }
|
|
||||||
.seltable__colfilter {
|
|
||||||
width: 100%; min-width: 5rem; padding: 0.15rem 0.35rem;
|
|
||||||
border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
background: var(--bg); color: var(--text); font-size: 0.74rem; font-weight: 400; text-transform: none; letter-spacing: 0;
|
|
||||||
}
|
|
||||||
.seltable__row { cursor: pointer; user-select: none; }
|
|
||||||
.seltable__row:hover { background: var(--bg-hover); }
|
|
||||||
.seltable__row.is-selected { background: var(--primary-light, rgba(37,99,235,0.12)); }
|
|
||||||
.seltable__row.is-selected:hover { background: var(--primary-light, rgba(37,99,235,0.18)); }
|
|
||||||
.seltable__row.drop-hover { outline: 2px solid var(--primary); outline-offset: -2px; }
|
|
||||||
|
|
||||||
/* ── "Add deliverables from archive" overlay (project MDL rollup) ─────────── */
|
|
||||||
.mdlarch-overlay {
|
|
||||||
position: fixed; inset: 0; z-index: 1000;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
display: flex; align-items: center; justify-content: center; padding: 1.5rem;
|
|
||||||
}
|
|
||||||
.mdlarch-overlay__box {
|
|
||||||
display: flex; flex-direction: column; min-height: 0;
|
|
||||||
width: min(960px, 95vw); height: min(80vh, 760px);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
.mdlarch-overlay__head { display: flex; align-items: center; gap: 0.75rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
|
||||||
.mdlarch-overlay__head h2 { margin: 0; font-size: 1.05rem; flex: 1; }
|
|
||||||
.mdlarch-overlay__close { border: none; background: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.25rem; }
|
|
||||||
.mdlarch-overlay__close:hover { color: var(--text); }
|
|
||||||
.mdlarch-overlay__status { padding: 0.5rem 1.1rem; color: var(--text-muted); font-size: 0.82rem; border-bottom: 1px solid var(--border); flex: 0 0 auto; }
|
|
||||||
.mdlarch-overlay__table { flex: 1; min-height: 0; display: flex; }
|
|
||||||
.mdlarch-overlay__table .seltable { height: 100%; flex: 1; }
|
|
||||||
.mdlarch-overlay__foot { display: flex; justify-content: flex-end; gap: 0.6rem; padding: 0.75rem 1.1rem; border-top: 1px solid var(--border); flex: 0 0 auto; }
|
|
||||||
|
|
||||||
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
|
/* tables/ — directory-of-YAML table view. Reuses tokens from shared/base.css. */
|
||||||
|
|
||||||
.table-main {
|
.table-main {
|
||||||
|
|
@ -1769,7 +1722,7 @@ body.is-elevated::after {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-12 02:15:36 · 93ed0d3</span></span>
|
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.27-beta · 2026-06-11 14:40:25 · bc762a7</span></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
@ -1790,7 +1743,6 @@ body.is-elevated::after {
|
||||||
<div class="table-toolbar__right">
|
<div class="table-toolbar__right">
|
||||||
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
|
<button type="button" id="table-save" class="btn btn-primary btn-sm" hidden>Save</button>
|
||||||
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
<button type="button" id="table-export-csv" class="btn btn-secondary btn-sm" hidden>Export CSV</button>
|
||||||
<button type="button" id="table-add-from-archive" class="btn btn-secondary btn-sm" hidden title="Register deliverables from existing archive files (project MDL rollup)">+ From archive</button>
|
|
||||||
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
<a id="table-add-row" class="btn btn-primary btn-sm" hidden>+ Add row</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4044,185 +3996,6 @@ body.is-elevated::after {
|
||||||
window.zddc.menu = { open: open, close: close };
|
window.zddc.menu = { open: open, close: close };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/**
|
|
||||||
* ZDDC — shared selectable + autofilter table (used by the classifier catalog
|
|
||||||
* and the tables tool's "Add from archive").
|
|
||||||
*
|
|
||||||
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined,
|
|
||||||
* each an AND of space-separated terms) plus an optional programmatic global
|
|
||||||
* filter, and powerful selection for building complex sets quickly:
|
|
||||||
* click replace selection + set anchor
|
|
||||||
* ctrl/cmd-click toggle one row
|
|
||||||
* shift-click range from the anchor (replaces the selection)
|
|
||||||
* ctrl-shift-click ADD the anchor→row range to the existing selection
|
|
||||||
* ctrl/cmd-Enter fire onActivate(selectedIds) — a bulk action
|
|
||||||
* Esc clear
|
|
||||||
* Ranges run over the CURRENTLY FILTERED order. Selection is keyed by a stable
|
|
||||||
* rowId so it survives filtering and re-render.
|
|
||||||
*/
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
if (!window.app) window.app = {};
|
|
||||||
if (!window.app.modules) window.app.modules = {};
|
|
||||||
|
|
||||||
function terms(q) { return String(q == null ? '' : q).trim().toLowerCase().split(/\s+/).filter(Boolean); }
|
|
||||||
function hit(text, ts) {
|
|
||||||
var t = String(text == null ? '' : text).toLowerCase();
|
|
||||||
for (var i = 0; i < ts.length; i++) { if (t.indexOf(ts[i]) === -1) return false; }
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function elt(tag, cls, text) {
|
|
||||||
var e = document.createElement(tag);
|
|
||||||
if (cls) e.className = cls;
|
|
||||||
if (text != null) e.textContent = text;
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
function create(opts) {
|
|
||||||
var container = opts.container;
|
|
||||||
var columns = opts.columns || [];
|
|
||||||
var rowId = opts.rowId || function (r) { return r.id; };
|
|
||||||
var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; };
|
|
||||||
var selected = Object.create(null); // id -> true
|
|
||||||
var anchorId = null;
|
|
||||||
var globalTerms = []; // programmatic global filter (tests/reveal)
|
|
||||||
var colFilters = Object.create(null); // colKey -> terms[] (the per-column autofilters)
|
|
||||||
|
|
||||||
function rows() { return getRows() || []; }
|
|
||||||
function colByKey(k) { for (var i = 0; i < columns.length; i++) { if (columns[i].key === k) return columns[i]; } return null; }
|
|
||||||
function colVal(col, row) { return col.get ? col.get(row) : (row[col.key] == null ? '' : row[col.key]); }
|
|
||||||
function rowBlob(row) { var s = ''; for (var i = 0; i < columns.length; i++) { s += colVal(columns[i], row) + ' '; } return s; }
|
|
||||||
function rowMatches(row) {
|
|
||||||
if (globalTerms.length && !hit(rowBlob(row), globalTerms)) return false;
|
|
||||||
for (var k in colFilters) {
|
|
||||||
var col = colByKey(k);
|
|
||||||
if (col && !hit(colVal(col, row), colFilters[k])) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function filtered() { return rows().filter(rowMatches); }
|
|
||||||
|
|
||||||
function getSelection() { return Object.keys(selected); }
|
|
||||||
function getFilteredRows() { return filtered(); }
|
|
||||||
function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); }
|
|
||||||
function setFilter(q) { globalTerms = terms(q); renderBody(); }
|
|
||||||
function setColFilter(colKey, q) { var t = terms(q); if (t.length) colFilters[colKey] = t; else delete colFilters[colKey]; renderBody(); }
|
|
||||||
function selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); }
|
|
||||||
function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); }
|
|
||||||
|
|
||||||
function onRowClick(e, row, fr) {
|
|
||||||
var ids = fr.map(rowId), id = rowId(row), idx = ids.indexOf(id), aIdx;
|
|
||||||
if (e.shiftKey && anchorId != null && (aIdx = ids.indexOf(anchorId)) >= 0) {
|
|
||||||
if (!(e.ctrlKey || e.metaKey)) selected = Object.create(null); // shift replaces; ctrl-shift adds
|
|
||||||
var lo = Math.min(aIdx, idx), hi = Math.max(aIdx, idx);
|
|
||||||
for (var i = lo; i <= hi; i++) selected[ids[i]] = true;
|
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
|
||||||
if (selected[id]) delete selected[id]; else selected[id] = true;
|
|
||||||
anchorId = id;
|
|
||||||
} else {
|
|
||||||
selected = Object.create(null); selected[id] = true; anchorId = id;
|
|
||||||
}
|
|
||||||
renderBody(); fireSel();
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodyEl = null, countEl = null;
|
|
||||||
function render() {
|
|
||||||
container.textContent = '';
|
|
||||||
container.classList.add('seltable');
|
|
||||||
var bar = elt('div', 'seltable__bar');
|
|
||||||
var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered');
|
|
||||||
allBtn.addEventListener('click', selectAllFiltered);
|
|
||||||
var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear');
|
|
||||||
clrBtn.addEventListener('click', clearSel);
|
|
||||||
countEl = elt('span', 'seltable__count');
|
|
||||||
bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl);
|
|
||||||
container.appendChild(bar);
|
|
||||||
|
|
||||||
var scroll = elt('div', 'seltable__scroll');
|
|
||||||
var table = elt('table', 'seltable__table');
|
|
||||||
var thead = elt('thead'), htr = elt('tr');
|
|
||||||
columns.forEach(function (c) { htr.appendChild(elt('th', c.cls || null, c.title || c.key)); });
|
|
||||||
if (opts.rowExtra) htr.appendChild(elt('th', 'seltable__extrah', opts.extraTitle || ''));
|
|
||||||
thead.appendChild(htr);
|
|
||||||
// Per-column autofilter row.
|
|
||||||
var ftr = elt('tr', 'seltable__filters');
|
|
||||||
columns.forEach(function (c) {
|
|
||||||
var th = elt('th');
|
|
||||||
if (c.filterable !== false) {
|
|
||||||
var inp = elt('input', 'seltable__colfilter'); inp.type = 'search'; inp.placeholder = 'filter…'; inp.spellcheck = false;
|
|
||||||
inp.setAttribute('data-no-select', '');
|
|
||||||
inp.addEventListener('input', function () { setColFilter(c.key, this.value); });
|
|
||||||
th.appendChild(inp);
|
|
||||||
}
|
|
||||||
ftr.appendChild(th);
|
|
||||||
});
|
|
||||||
if (opts.rowExtra) ftr.appendChild(elt('th'));
|
|
||||||
thead.appendChild(ftr);
|
|
||||||
table.appendChild(thead);
|
|
||||||
bodyEl = elt('tbody'); table.appendChild(bodyEl);
|
|
||||||
scroll.appendChild(table); container.appendChild(scroll);
|
|
||||||
|
|
||||||
container.tabIndex = 0;
|
|
||||||
container.addEventListener('keydown', function (e) {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); if (opts.onActivate) opts.onActivate(getSelection()); }
|
|
||||||
else if (e.key === 'Escape') { clearSel(); }
|
|
||||||
});
|
|
||||||
renderBody();
|
|
||||||
}
|
|
||||||
function renderBody() {
|
|
||||||
if (!bodyEl) return;
|
|
||||||
var fr = filtered();
|
|
||||||
bodyEl.textContent = '';
|
|
||||||
fr.forEach(function (row) {
|
|
||||||
var id = rowId(row);
|
|
||||||
var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : ''));
|
|
||||||
tr.dataset.id = id;
|
|
||||||
tr.addEventListener('click', function (e) {
|
|
||||||
if (e.target.closest('input,button,select,a,[data-no-select]')) return;
|
|
||||||
onRowClick(e, row, fr);
|
|
||||||
});
|
|
||||||
if (opts.onRowDrop) {
|
|
||||||
tr.addEventListener('dragover', function (e) {
|
|
||||||
if (window.app.modules.dnd && window.app.modules.dnd.active()) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; tr.classList.add('drop-hover'); }
|
|
||||||
});
|
|
||||||
tr.addEventListener('dragleave', function () { tr.classList.remove('drop-hover'); });
|
|
||||||
tr.addEventListener('drop', function (e) {
|
|
||||||
tr.classList.remove('drop-hover');
|
|
||||||
e.preventDefault();
|
|
||||||
var keys = window.app.modules.dnd ? window.app.modules.dnd.getDrag() : [];
|
|
||||||
if (window.app.modules.dnd) window.app.modules.dnd.clearDrag();
|
|
||||||
if (keys.length) opts.onRowDrop(id, keys);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
columns.forEach(function (c) {
|
|
||||||
var td = elt('td', c.cls || null);
|
|
||||||
if (c.render) c.render(row, td); else td.textContent = colVal(c, row);
|
|
||||||
tr.appendChild(td);
|
|
||||||
});
|
|
||||||
if (opts.rowExtra) { var ex = elt('td', 'seltable__extra'); opts.rowExtra(row, ex); tr.appendChild(ex); }
|
|
||||||
bodyEl.appendChild(tr);
|
|
||||||
});
|
|
||||||
if (countEl) {
|
|
||||||
var nSel = getSelection().length;
|
|
||||||
countEl.textContent = fr.length + ' shown' + (nSel ? ' · ' + nSel + ' selected' : '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
render: render, renderBody: renderBody,
|
|
||||||
getSelection: getSelection, getFilteredRows: getFilteredRows,
|
|
||||||
setFilter: setFilter, setColFilter: setColFilter, selectAllFiltered: selectAllFiltered, clear: clearSel,
|
|
||||||
clickRow: function (id, mods) {
|
|
||||||
var fr = filtered();
|
|
||||||
var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0];
|
|
||||||
if (row) onRowClick(Object.assign({ shiftKey: false, ctrlKey: false, metaKey: false }, mods || {}), row, fr);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app.modules.seltable = { create: create };
|
|
||||||
})();
|
|
||||||
|
|
||||||
// mode.js — picks table-mode vs form-mode at boot time and unhides the
|
// mode.js — picks table-mode vs form-mode at boot time and unhides the
|
||||||
// matching container. Both apps (tablesApp, formApp) ship in the same
|
// matching container. Both apps (tablesApp, formApp) ship in the same
|
||||||
// bundle but each only paints when its container is visible.
|
// bundle but each only paints when its container is visible.
|
||||||
|
|
@ -7798,191 +7571,6 @@ body.is-elevated::after {
|
||||||
}
|
}
|
||||||
})(window.tablesApp = window.tablesApp || {});
|
})(window.tablesApp = window.tablesApp || {});
|
||||||
|
|
||||||
// mdl-from-archive.js — "Add from archive" for the project MDL rollup.
|
|
||||||
//
|
|
||||||
// The MDL owns the workflow of registering deliverables; this is the catch-up
|
|
||||||
// path. On the project rollup (<project>/mdl/), walk the project archive into a
|
|
||||||
// shared seltable (autofilter + ctrl-shift selection), dedupe the selection to
|
|
||||||
// one deliverable per tracking number, and PUT a deliverable .yaml into each
|
|
||||||
// originator's archive/<originator>/mdl/. The body's identity fields are split
|
|
||||||
// from the tracking number positionally per the project's own table columns
|
|
||||||
// (originator is folder-pinned, so omitted); the server composes/validates the
|
|
||||||
// filename. Server-only.
|
|
||||||
(function (app) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
function T(m, l, o) { if (window.zddc && window.zddc.toast) window.zddc.toast(m, l, o); }
|
|
||||||
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
|
||||||
function ctxObj() { return (app && app.context) || {}; }
|
|
||||||
|
|
||||||
// The tracking-number identity fields, in order, from the table columns:
|
|
||||||
// everything between `originator` and `title` (e.g. phase, project, area,
|
|
||||||
// discipline, type, sequence, suffix). originator is folder-pinned.
|
|
||||||
function identityFields() {
|
|
||||||
var cols = (ctxObj().columns || []).map(function (c) { return c && c.field; }).filter(Boolean);
|
|
||||||
var oi = cols.indexOf('originator'), ti = cols.indexOf('title');
|
|
||||||
return cols.slice(oi >= 0 ? oi + 1 : 0, ti >= 0 ? ti : cols.length);
|
|
||||||
}
|
|
||||||
// tracking → { tracking, originator, body{identity fields + title} }, or null
|
|
||||||
// if it can't supply the originator + at least one identity segment.
|
|
||||||
function deliverableFromFile(f, idFields) {
|
|
||||||
var segs = String(f.tracking || '').split('-');
|
|
||||||
if (segs.length < 2) return null;
|
|
||||||
var rest = segs.slice(1), body = {};
|
|
||||||
idFields.forEach(function (name, i) { if (rest[i] != null && rest[i] !== '') body[name] = rest[i]; });
|
|
||||||
if (!Object.keys(body).length) return null;
|
|
||||||
body.title = f.title || '';
|
|
||||||
return { tracking: f.tracking, originator: segs[0], body: body };
|
|
||||||
}
|
|
||||||
function dedupe(files, idFields) {
|
|
||||||
var seen = Object.create(null), out = [];
|
|
||||||
(files || []).forEach(function (f) {
|
|
||||||
if (seen[f.tracking]) return;
|
|
||||||
var d = deliverableFromFile(f, idFields);
|
|
||||||
if (d) { seen[f.tracking] = true; out.push(d); }
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walkArchive(rootHandle) {
|
|
||||||
var out = [];
|
|
||||||
async function walk(dirH, parts) {
|
|
||||||
for await (var entry of dirH.values()) {
|
|
||||||
var nm = String(entry.name || '').replace(/\/$/, '');
|
|
||||||
if (entry.kind === 'directory') {
|
|
||||||
var c = nm.charAt(0);
|
|
||||||
if (c === '.' || c === '_' || nm === 'mdl' || nm === 'rsk') continue;
|
|
||||||
await walk(await dirH.getDirectoryHandle(nm), parts.concat(nm));
|
|
||||||
} else {
|
|
||||||
var p = window.zddc.parseFilename(nm);
|
|
||||||
if (p && p.valid && p.trackingNumber) {
|
|
||||||
out.push({
|
|
||||||
id: parts.concat(nm).join('/'),
|
|
||||||
party: parts[0] || '', slot: parts[1] || '', transmittal: parts[2] || '',
|
|
||||||
tracking: p.trackingNumber, revision: p.revision, status: p.status, title: p.title,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await walk(rootHandle, []);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
async function instantiateOne(archiveRoot, d) {
|
|
||||||
var dir = await archiveRoot.getDirectoryHandle(d.originator, { create: true });
|
|
||||||
dir = await dir.getDirectoryHandle('mdl', { create: true });
|
|
||||||
var fname = d.tracking + '.yaml';
|
|
||||||
try { await dir.getFileHandle(fname); return 'skipped'; } catch (e) { /* NotFound → create */ }
|
|
||||||
var fh = await dir.getFileHandle(fname, { create: true });
|
|
||||||
var w = await fh.createWritable();
|
|
||||||
await w.write(new Blob([window.jsyaml.dump(d.body)], { type: 'application/yaml' }));
|
|
||||||
await w.close();
|
|
||||||
return 'created';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── UI ───────────────────────────────────────────────────────────────────
|
|
||||||
var overlay = null, statusEl = null, table = null, files = [], archiveRoot = null;
|
|
||||||
function close() { if (overlay) { overlay.remove(); overlay = null; table = null; } }
|
|
||||||
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
|
|
||||||
|
|
||||||
function archiveBaseUrl() {
|
|
||||||
var proj = (location.pathname || '/').replace(/\/mdl\/.*$/, '/'); // <project>/
|
|
||||||
return location.origin + proj + 'archive/';
|
|
||||||
}
|
|
||||||
async function open() {
|
|
||||||
var src = window.zddc && window.zddc.source;
|
|
||||||
if (!src || (location.protocol !== 'http:' && location.protocol !== 'https:')) {
|
|
||||||
T('Adding from the archive needs the tables page served by a zddc-server.', 'error'); return;
|
|
||||||
}
|
|
||||||
buildOverlay();
|
|
||||||
try {
|
|
||||||
archiveRoot = new src.HttpDirectoryHandle(archiveBaseUrl(), 'archive');
|
|
||||||
setStatus('Scanning archive…');
|
|
||||||
files = await walkArchive(archiveRoot);
|
|
||||||
table.renderBody();
|
|
||||||
setStatus(files.length + ' document file' + (files.length === 1 ? '' : 's') + ' found. Filter + ctrl-shift select, then “Create deliverables”.');
|
|
||||||
} catch (e) { setStatus('Archive scan failed — ' + (e.message || e)); T('Archive scan failed — ' + (e.message || e), 'error'); }
|
|
||||||
}
|
|
||||||
function buildOverlay() {
|
|
||||||
close();
|
|
||||||
overlay = el('div', 'mdlarch-overlay');
|
|
||||||
var box = el('div', 'mdlarch-overlay__box');
|
|
||||||
var head = el('div', 'mdlarch-overlay__head');
|
|
||||||
head.appendChild(el('h2', null, 'Add deliverables from archive'));
|
|
||||||
var x = el('button', 'mdlarch-overlay__close', '×'); x.title = 'Close'; x.addEventListener('click', close);
|
|
||||||
head.appendChild(x); box.appendChild(head);
|
|
||||||
statusEl = el('div', 'mdlarch-overlay__status', 'Scanning archive…'); box.appendChild(statusEl);
|
|
||||||
var host = el('div', 'mdlarch-overlay__table'); box.appendChild(host);
|
|
||||||
var foot = el('div', 'mdlarch-overlay__foot');
|
|
||||||
var create = el('button', 'btn btn-primary', 'Create deliverables');
|
|
||||||
create.addEventListener('click', function () { runCreate(create); });
|
|
||||||
var cancel = el('button', 'btn btn-secondary', 'Close'); cancel.addEventListener('click', close);
|
|
||||||
foot.appendChild(create); foot.appendChild(cancel); box.appendChild(foot);
|
|
||||||
overlay.appendChild(box); document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
table = window.app.modules.seltable.create({
|
|
||||||
container: host,
|
|
||||||
extraTitle: '',
|
|
||||||
rows: function () { return files; },
|
|
||||||
rowId: function (r) { return r.id; },
|
|
||||||
columns: [
|
|
||||||
{ key: 'party', title: 'Party' },
|
|
||||||
{ key: 'slot', title: 'Slot' },
|
|
||||||
{ key: 'transmittal', title: 'Transmittal' },
|
|
||||||
{ key: 'tracking', title: 'Tracking number' },
|
|
||||||
{ key: 'revision', title: 'Rev', get: function (r) { return r.revision + (r.status ? ' (' + r.status + ')' : ''); } },
|
|
||||||
{ key: 'title', title: 'Title' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
table.render();
|
|
||||||
}
|
|
||||||
async function runCreate(btn) {
|
|
||||||
if (!table) return;
|
|
||||||
var sel = table.getSelection();
|
|
||||||
if (!sel.length) { T('Select some archive files first (filter + ctrl-shift).', 'warning'); return; }
|
|
||||||
var picked = {}; sel.forEach(function (i) { picked[i] = true; });
|
|
||||||
var deliverables = dedupe(files.filter(function (f) { return picked[f.id]; }), identityFields());
|
|
||||||
if (!deliverables.length) { T('None of the selected files split into deliverable fields.', 'warning'); return; }
|
|
||||||
if (!confirm('Create ' + deliverables.length + ' deliverable(s) in the project MDL?\n\nOne .yaml per tracking number, in archive/<originator>/mdl/. Already-present ones are skipped.')) return;
|
|
||||||
btn.disabled = true;
|
|
||||||
var s = { created: 0, skipped: 0, errors: 0 };
|
|
||||||
for (var i = 0; i < deliverables.length; i++) {
|
|
||||||
setStatus('Creating ' + (i + 1) + '/' + deliverables.length + ' — ' + deliverables[i].tracking);
|
|
||||||
try { s[await instantiateOne(archiveRoot, deliverables[i])]++; }
|
|
||||||
catch (e) { s.errors++; T('Failed to create ' + deliverables[i].tracking + ' — ' + (e.message || e), 'error'); }
|
|
||||||
}
|
|
||||||
btn.disabled = false;
|
|
||||||
setStatus(s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '.');
|
|
||||||
T('MDL: ' + s.created + ' created, ' + s.skipped + ' already there' + (s.errors ? (', ' + s.errors + ' failed') : '') + '. Reload to see them.', s.errors ? 'warning' : 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the toolbar button only on the project MDL rollup (addable:false +
|
|
||||||
// an mdl path), over http, gated on create permission. Called from main.js
|
|
||||||
// init once the context is known.
|
|
||||||
function setup(ctx) {
|
|
||||||
var btn = document.getElementById('table-add-from-archive');
|
|
||||||
if (!btn) return;
|
|
||||||
var onHttp = location.protocol === 'http:' || location.protocol === 'https:';
|
|
||||||
var isMdlRollup = ctx && ctx.addable === false && /\/mdl\/(table\.html)?$/.test(location.pathname || '');
|
|
||||||
if (!(onHttp && isMdlRollup)) return;
|
|
||||||
btn.hidden = false;
|
|
||||||
btn.addEventListener('click', open);
|
|
||||||
if (window.zddc && window.zddc.cap) {
|
|
||||||
window.zddc.cap.at(archiveBaseUrl().replace(location.origin, '')).then(function (view) {
|
|
||||||
var verbs = (view && view.path_verbs) || '';
|
|
||||||
if (verbs.indexOf('c') === -1) { btn.classList.add('is-disabled'); btn.title = "You don't have create access in this project's archive."; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.modules.mdlFromArchive = {
|
|
||||||
setup: setup, open: open,
|
|
||||||
// test seams
|
|
||||||
identityFields: identityFields, deliverableFromFile: deliverableFromFile,
|
|
||||||
dedupe: dedupe, walkArchive: walkArchive, instantiateOne: instantiateOne,
|
|
||||||
};
|
|
||||||
})(window.tablesApp);
|
|
||||||
|
|
||||||
(function (app) {
|
(function (app) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -8152,11 +7740,6 @@ body.is-elevated::after {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Add from archive" — shown only on the project MDL rollup (own gating).
|
|
||||||
if (app.modules.mdlFromArchive && app.modules.mdlFromArchive.setup) {
|
|
||||||
app.modules.mdlFromArchive.setup(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
||||||
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue