The per-folder "direct+total folders / files" badge always showed the raw scanned totals, even while a filter narrowed the tree — so "9+1799 folders, 0+6203 files" stayed put no matter what you filtered to. computeVisible already does a single filtered pass (applying the Show checkboxes via classifyAllows and the autofilter via nameHit); accumulate per-folder visible direct/total counts there and have populateCount use them whenever a filter is active (raw totals otherwise). So the badge now shows the post-filter totals for both the autofilter and the Show checkboxes — a collapsed folder's badge tells you how many matching items are inside. Test: a filtered tree's root badge drops from "2 folders, 0+3 files" to "1 folder, 0+1 file". Classifier suites 69 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
940 lines
38 KiB
JavaScript
940 lines
38 KiB
JavaScript
/**
|
||
* Folder Tree Module
|
||
* Handles folder tree rendering and multi-select
|
||
*/
|
||
(function() {
|
||
'use strict';
|
||
|
||
// ── Sorting ────────────────────────────────────────────────────────────
|
||
// Render the tree in a stable, human order: case-insensitive, natural
|
||
// (so "Rev 2" sorts before "Rev 10"). Non-mutating — sort copies at render.
|
||
function cmpName(a, b) { return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); }
|
||
function sortedFolders(list) { return (list || []).slice().sort(function (a, b) { return cmpName(a.name, b.name); }); }
|
||
function sortedFiles(list) {
|
||
return (list || []).slice().sort(function (a, b) {
|
||
return cmpName(window.zddc.joinExtension(a.originalFilename, a.extension), window.zddc.joinExtension(b.originalFilename, b.extension));
|
||
});
|
||
}
|
||
|
||
// ── Classify & Copy helpers ────────────────────────────────────────────
|
||
function classifyOn() {
|
||
var c = window.app.modules.classify;
|
||
return c && c.isEnabled();
|
||
}
|
||
// All file objects in a folder's (already-scanned) subtree — group-drag.
|
||
function subtreeFiles(folder, out) {
|
||
out = out || [];
|
||
(folder.files || []).forEach(function (f) { out.push(f); });
|
||
(folder.children || []).forEach(function (c) { subtreeFiles(c, out); });
|
||
return out;
|
||
}
|
||
function keysFor(files) {
|
||
var c = window.app.modules.classify;
|
||
return files.map(function (f) { return c.srcKeyForFile(f); });
|
||
}
|
||
// A small status dot reflecting a file's classification state.
|
||
var STATE_TITLE = {
|
||
none: 'unassigned', tracking: 'has tracking number, needs a transmittal',
|
||
transmittal: 'in a transmittal, needs a tracking number',
|
||
partial: 'placed, but the name is incomplete', done: 'fully classified',
|
||
excluded: 'excluded — will not be copied',
|
||
};
|
||
function stateDot(state) {
|
||
var dot = document.createElement('span');
|
||
dot.className = 'cl-dot cl-dot--' + state;
|
||
dot.title = STATE_TITLE[state] || '';
|
||
return dot;
|
||
}
|
||
|
||
// ── Classify-mode source-tree filters ──────────────────────────────────
|
||
// The goal in either target tab is to assign-or-exclude every file. Each
|
||
// file falls in one bucket FOR THE ACTIVE AXIS — unassigned / assigned /
|
||
// 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, 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.partial && showFilters.assigned && showFilters.excluded; }
|
||
function activeAxis() {
|
||
var tt = window.app.modules.targetTree;
|
||
return (tt && tt.activeAxis) ? tt.activeAxis() : 'tracking';
|
||
}
|
||
function axisField(ax) { return ax === 'transmittal' ? 'transmittalNodeId' : 'trackingNodeId'; }
|
||
// Bucket a file relative to the active axis (tracking | transmittal):
|
||
// 'excluded' | 'assigned' (on this axis) | 'partial' (assigned on a DIFFERENT
|
||
// axis only — the to-do for this tab) | 'unassigned' (no axis).
|
||
function fileCategory(file) {
|
||
var c = window.app.modules.classify;
|
||
var a = c.getAssignment(c.srcKeyForFile(file));
|
||
if (a && a.excluded) return 'excluded';
|
||
var ax = activeAxis();
|
||
if (a && a[axisField(ax)]) return 'assigned';
|
||
var others = ['tracking', 'transmittal'].filter(function (x) { return x !== ax; });
|
||
var any = a && others.some(function (x) { return a[axisField(x)]; });
|
||
return any ? 'partial' : 'unassigned';
|
||
}
|
||
function classifyAllows(file) { return !classifyOn() || !!showFilters[fileCategory(file)]; }
|
||
|
||
// ── name filter (the autofilter box above the tree) ────────────────────
|
||
// Live substring search over each file's full path+name (and folder names),
|
||
// ANDing space-separated terms. Matches reveal their whole folder hierarchy.
|
||
var nameFilter = '', filterTerms = [];
|
||
function setNameFilter(q) {
|
||
nameFilter = (q || '').trim();
|
||
filterTerms = nameFilter.toLowerCase().split(/\s+/).filter(Boolean);
|
||
render();
|
||
}
|
||
function filterActive() { return filterTerms.length > 0; }
|
||
function nameHit(text) {
|
||
if (!filterTerms.length) return true;
|
||
var t = String(text || '').toLowerCase();
|
||
for (var i = 0; i < filterTerms.length; i++) { if (t.indexOf(filterTerms[i]) === -1) return false; }
|
||
return true;
|
||
}
|
||
// Anything narrowing the tree (a name search, a show-filter off, or hiding empties).
|
||
function anyFilter() { return filterActive() || (classifyOn() && (!allFiltersOn() || !showEmpty)); }
|
||
// One pass → the set of folder paths + file keys to render. A file shows when
|
||
// it passes the show-filters AND (no name search, OR an ancestor folder
|
||
// matched, OR its own path/name matches). A folder shows when it (or an
|
||
// ancestor) matches, or anything inside it shows — so the path to a hit is
|
||
// always revealed.
|
||
var visible = null; // { folders, files } while filtering, else null
|
||
function computeVisible() {
|
||
var c = window.app.modules.classify;
|
||
var folders = Object.create(null), files = Object.create(null), counts = Object.create(null);
|
||
var nf = filterActive();
|
||
function walk(folder, ancMatched) {
|
||
var selfMatch = nf && nameHit(folder.path || folder.name);
|
||
var matched = ancMatched || selfMatch;
|
||
var show = false, hasFile = false, descMatch = false;
|
||
// Post-filter counts for the row's "direct+total" badge: direct =
|
||
// immediate visible children/files, total = visible across the subtree.
|
||
var dDir = 0, tDir = 0, dFile = 0, tFile = 0;
|
||
(folder.children || []).forEach(function (ch) {
|
||
var r = walk(ch, matched);
|
||
if (r.show) { show = true; dDir++; tDir += 1 + r.tDir; }
|
||
if (r.hasFile) hasFile = true;
|
||
if (r.subtreeMatch) descMatch = true; // a child leads to a match
|
||
tFile += r.tFile;
|
||
});
|
||
(folder.files || []).forEach(function (f) {
|
||
hasFile = true;
|
||
if (!classifyAllows(f)) return;
|
||
var fileMatch = nf && nameHit(c.srcKeyForFile(f));
|
||
if (!nf || matched || fileMatch) { files[c.srcKeyForFile(f)] = true; show = true; dFile++; }
|
||
if (fileMatch) descMatch = true; // a match sits directly in this folder
|
||
});
|
||
tFile += dFile;
|
||
if (matched) show = true;
|
||
// "Show Empty" off → hide folders whose whole subtree holds no files.
|
||
if (!hasFile && !showEmpty && !matched) show = false;
|
||
if (show) folders[folder.path] = true;
|
||
counts[folder.path] = { dDir: dDir, tDir: tDir, dFile: dFile, tFile: tFile };
|
||
return { show: show, hasFile: hasFile, subtreeMatch: descMatch || selfMatch, tDir: tDir, tFile: tFile };
|
||
}
|
||
(window.app.folderTree || []).forEach(function (root) { walk(root, false); });
|
||
return { folders: folders, files: files, counts: counts };
|
||
}
|
||
function folderShown(folder) { return !visible || !!visible.folders[folder.path]; }
|
||
function fileShown(file) {
|
||
if (!classifyAllows(file)) return false;
|
||
return !visible || !!visible.files[window.app.modules.classify.srcKeyForFile(file)];
|
||
}
|
||
// All scanned files (for the per-bucket counts on the filter checkboxes).
|
||
function allClassifyFiles() {
|
||
var out = [];
|
||
(window.app.folderTree || []).forEach(function (f) { subtreeFiles(f, out); });
|
||
return out;
|
||
}
|
||
function updateFilterCounts() {
|
||
if (!classifyOn()) return;
|
||
var n = { unassigned: 0, partial: 0, assigned: 0, excluded: 0 };
|
||
allClassifyFiles().forEach(function (f) { n[fileCategory(f)]++; });
|
||
['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] + ')';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Render the folder tree
|
||
*/
|
||
function render() {
|
||
const container = window.app.dom.folderTree;
|
||
// Preserve scroll across re-render — toggling a Show filter shouldn't
|
||
// jump the view back to the top.
|
||
const prevScroll = container.scrollTop;
|
||
wireClassifyInteractions();
|
||
container.innerHTML = '';
|
||
updateFilterCounts();
|
||
visible = anyFilter() ? computeVisible() : null;
|
||
|
||
if (window.app.folderTree.length === 0) {
|
||
container.innerHTML = '<div class="tree-empty">No folders found</div>';
|
||
return;
|
||
}
|
||
|
||
sortedFolders(window.app.folderTree).forEach(folder => {
|
||
if (!folderShown(folder)) return;
|
||
const element = createFolderElement(folder);
|
||
container.appendChild(element);
|
||
});
|
||
if (!container.children.length) {
|
||
container.innerHTML = '<div class="tree-empty">'
|
||
+ (filterActive() ? 'No files match “' + nameFilter + '”.' : 'Nothing matches the current filters.') + '</div>';
|
||
}
|
||
|
||
updateSelectedCount();
|
||
container.scrollTop = prevScroll;
|
||
}
|
||
|
||
/**
|
||
* Populate a folder row's count element with "direct+total" counts, e.g.
|
||
* "(2+10 folders, 15+300 files)" — direct (immediate children) shows as
|
||
* soon as the folder's own directory is read; the total (whole subtree)
|
||
* grows and flashes grey until the subtree is fully scanned, then goes
|
||
* solid. The "+total" part is omitted once scanning is done and there's
|
||
* nothing deeper (direct == total).
|
||
*/
|
||
function populateCount(el, folder) {
|
||
el.textContent = '';
|
||
el.classList.remove('done');
|
||
const st = folder.scanState;
|
||
if (st === 'pending') return;
|
||
if (st === 'zip-pending') { el.textContent = '(zip — open to scan)'; return; }
|
||
if (st === 'scanning') { el.textContent = 'scanning…'; return; }
|
||
|
||
const done = st === 'done';
|
||
// When fully scanned both numbers are blue; .done turns the labels blue too.
|
||
if (done) el.classList.add('done');
|
||
// While a filter (autofilter or a Show checkbox) is narrowing the tree,
|
||
// the badge counts what's VISIBLE; otherwise the raw scanned totals.
|
||
const vc = visible && visible.counts && visible.counts[folder.path];
|
||
const dDir = vc ? vc.dDir : (folder.subdirCount || 0);
|
||
const tDir = vc ? vc.tDir : (folder.runDirs || 0);
|
||
const dFile = vc ? vc.dFile : (folder.fileCount || 0);
|
||
const tFile = vc ? vc.tFile : (folder.runFiles || 0);
|
||
|
||
const frag = document.createDocumentFragment();
|
||
frag.appendChild(document.createTextNode('('));
|
||
if (dDir > 0 || tDir > 0) {
|
||
appendPair(frag, dDir, tDir, done);
|
||
appendLabel(frag, tDir === 1 ? ' folder, ' : ' folders, ');
|
||
}
|
||
appendPair(frag, dFile, tFile, done);
|
||
appendLabel(frag, tFile === 1 ? ' file)' : ' files)');
|
||
el.appendChild(frag);
|
||
}
|
||
|
||
// The "folders"/"files" word labels — blue only once the row is .done.
|
||
function appendLabel(frag, text) {
|
||
const s = document.createElement('span');
|
||
s.className = 'ct-label';
|
||
s.textContent = text;
|
||
frag.appendChild(s);
|
||
}
|
||
|
||
// Append "<direct>" (always a completed/blue number) and, when there's a
|
||
// subtree (or scanning is ongoing), "+<total>" with the total in a span
|
||
// that greys + pulses until final, then turns blue.
|
||
function appendPair(frag, direct, total, done) {
|
||
const d = document.createElement('span');
|
||
d.className = 'ct-direct';
|
||
d.textContent = String(direct);
|
||
frag.appendChild(d);
|
||
if (!done || total > direct) {
|
||
frag.appendChild(document.createTextNode('+'));
|
||
const t = document.createElement('span');
|
||
t.className = 'ct-total' + (done ? '' : ' pending');
|
||
t.textContent = String(total);
|
||
frag.appendChild(t);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a folder element
|
||
*/
|
||
function createFolderElement(folder, level = 0) {
|
||
const div = document.createElement('div');
|
||
|
||
const item = document.createElement('div');
|
||
item.className = 'folder-item';
|
||
// Grey the row until its subtree is fully scanned (scanState 'done');
|
||
// 'scanning' rows also get a subtle pulse via CSS.
|
||
if (folder.scanState && folder.scanState !== 'done') {
|
||
item.classList.add('scanning');
|
||
}
|
||
item.dataset.path = folder.path;
|
||
item.style.paddingLeft = `${level * 1.5}rem`;
|
||
|
||
// Check if selected
|
||
if (window.app.selectedFolders.has(folder.path)) {
|
||
item.classList.add('selected');
|
||
}
|
||
|
||
// Classify mode: the folder row is a drag source for a group-drag of
|
||
// every file in its subtree.
|
||
if (classifyOn()) {
|
||
item.draggable = true;
|
||
item.addEventListener('dragstart', function (e) {
|
||
e.stopPropagation();
|
||
var files = subtreeFiles(folder);
|
||
if (!files.length) { e.preventDefault(); return; }
|
||
window.app.modules.dnd.setDrag(keysFor(files), e);
|
||
});
|
||
}
|
||
|
||
// Toggle button: shown when the folder has children OR hasn't been
|
||
// scanned yet (it might have children — expanding triggers its scan).
|
||
const toggle = document.createElement('span');
|
||
toggle.className = 'folder-toggle';
|
||
const mightHaveChildren = (folder.children && folder.children.length > 0)
|
||
|| folder.scanState === 'pending'
|
||
|| folder.scanState === 'zip-pending'
|
||
// Classify mode: a folder with files (even none of subfolders) is
|
||
// expandable so its files can be revealed and dragged.
|
||
|| (classifyOn() && folder.files && folder.files.length > 0);
|
||
if (mightHaveChildren) {
|
||
toggle.textContent = folder.expanded ? '▼' : '▶';
|
||
toggle.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const recursive = e.ctrlKey || e.metaKey;
|
||
toggleFolder(folder, recursive);
|
||
});
|
||
} else {
|
||
toggle.textContent = ' ';
|
||
}
|
||
item.appendChild(toggle);
|
||
|
||
// Folder icon (different for ZIP files)
|
||
const icon = document.createElement('span');
|
||
icon.className = 'folder-icon';
|
||
if (folder.isZipRoot) {
|
||
icon.innerHTML = '📦'; // 📦
|
||
} else if (folder.isVirtualDir) {
|
||
icon.innerHTML = '📂'; // 📂
|
||
} else {
|
||
icon.innerHTML = '📁'; // 📁
|
||
}
|
||
item.appendChild(icon);
|
||
|
||
// Classify mode: an aggregate state dot for the folder's subtree, and a
|
||
// struck-through name when the WHOLE subtree is excluded (mirrors files).
|
||
if (classifyOn()) {
|
||
const agg = aggregateState(subtreeFiles(folder));
|
||
if (agg) item.appendChild(stateDot(agg));
|
||
if (agg === 'excluded') item.classList.add('excluded');
|
||
}
|
||
|
||
// Folder name
|
||
const name = document.createElement('span');
|
||
name.className = 'folder-name';
|
||
name.textContent = folder.name;
|
||
item.appendChild(name);
|
||
|
||
// Subfolder / file counts (immediate). Greyed via the row's .scanning
|
||
// class until the subtree is fully scanned.
|
||
const count = document.createElement('span');
|
||
count.className = 'folder-count';
|
||
populateCount(count, folder);
|
||
item.appendChild(count);
|
||
|
||
// Extract button for ZIP roots
|
||
if (folder.isZipRoot) {
|
||
const extractBtn = document.createElement('button');
|
||
extractBtn.className = 'btn btn-sm zip-extract-btn';
|
||
extractBtn.textContent = '📤 Extract';
|
||
extractBtn.title = 'Extract ZIP contents to folder';
|
||
extractBtn.addEventListener('click', async (e) => {
|
||
e.stopPropagation();
|
||
await handleExtractZip(folder);
|
||
});
|
||
item.appendChild(extractBtn);
|
||
}
|
||
|
||
// Click handler for selection
|
||
item.addEventListener('click', (e) => {
|
||
handleFolderClick(folder, e);
|
||
});
|
||
|
||
div.appendChild(item);
|
||
|
||
// Children render ONLY when the user has expanded this folder. The
|
||
// autofilter and Show toggles never change expand/collapse state — they
|
||
// hide/show rows in place. A collapsed folder stays collapsed even if it
|
||
// contains matches (it's still shown, so the user can open it); this lets
|
||
// you filter within one subtree without the rest expanding.
|
||
if (folder.expanded && folder.children && folder.children.length > 0) {
|
||
const childrenDiv = document.createElement('div');
|
||
childrenDiv.className = 'folder-children';
|
||
sortedFolders(folder.children).forEach(child => {
|
||
if (!folderShown(child)) return;
|
||
const childElement = createFolderElement(child, level + 1);
|
||
childrenDiv.appendChild(childElement);
|
||
});
|
||
div.appendChild(childrenDiv);
|
||
}
|
||
|
||
// Classify mode: list this folder's own files (draggable leaves) only
|
||
// when the user has expanded it (the filter never force-expands).
|
||
if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) {
|
||
const filesDiv = document.createElement('div');
|
||
filesDiv.className = 'folder-children folder-files';
|
||
sortedFiles(folder.files).forEach(function (file) {
|
||
if (!fileShown(file)) return;
|
||
filesDiv.appendChild(createFileElement(file, level + 1));
|
||
});
|
||
div.appendChild(filesDiv);
|
||
}
|
||
|
||
return div;
|
||
}
|
||
|
||
/**
|
||
* Create a draggable source-file row (classify mode only).
|
||
*/
|
||
function createFileElement(file, level) {
|
||
const c = window.app.modules.classify;
|
||
const item = document.createElement('div');
|
||
item.className = 'file-item';
|
||
item.style.paddingLeft = `${level * 1.5}rem`;
|
||
item.draggable = true;
|
||
item.title = 'Click to preview · drag onto a tracking folder or transmittal to assign';
|
||
const key = c.srcKeyForFile(file);
|
||
item.dataset.key = key;
|
||
const st = c.fileState(file);
|
||
if (st === 'excluded') item.classList.add('excluded');
|
||
|
||
item.appendChild(stateDot(st));
|
||
|
||
const icon = document.createElement('span');
|
||
icon.className = 'file-icon';
|
||
icon.innerHTML = '📄'; // 📄
|
||
item.appendChild(icon);
|
||
|
||
const name = document.createElement('span');
|
||
name.className = 'file-name';
|
||
name.textContent = zddc.joinExtension(file.originalFilename, file.extension);
|
||
item.appendChild(name);
|
||
|
||
item.addEventListener('dragstart', function (e) {
|
||
e.stopPropagation();
|
||
window.app.modules.dnd.setDrag([key], e);
|
||
});
|
||
return item;
|
||
}
|
||
|
||
/**
|
||
* Handle folder click with multi-select support
|
||
*/
|
||
function handleFolderClick(folder, event) {
|
||
if (event.ctrlKey || event.metaKey) {
|
||
// Ctrl+Click: Toggle selection
|
||
if (window.app.selectedFolders.has(folder.path)) {
|
||
window.app.selectedFolders.delete(folder.path);
|
||
} else {
|
||
window.app.selectedFolders.add(folder.path);
|
||
}
|
||
} else if (event.shiftKey) {
|
||
// Shift+Click: Range selection
|
||
const visibleFolders = getVisibleFolders();
|
||
const currentIndex = visibleFolders.findIndex(f => f.path === folder.path);
|
||
|
||
if (currentIndex >= 0 && window.app.lastSelectedFolderPath) {
|
||
const lastIndex = visibleFolders.findIndex(f => f.path === window.app.lastSelectedFolderPath);
|
||
|
||
if (lastIndex >= 0) {
|
||
const start = Math.min(currentIndex, lastIndex);
|
||
const end = Math.max(currentIndex, lastIndex);
|
||
|
||
// Select range
|
||
for (let i = start; i <= end; i++) {
|
||
window.app.selectedFolders.add(visibleFolders[i].path);
|
||
}
|
||
}
|
||
} else {
|
||
window.app.selectedFolders.add(folder.path);
|
||
}
|
||
} else {
|
||
// Normal click: Single selection
|
||
window.app.selectedFolders.clear();
|
||
window.app.selectedFolders.add(folder.path);
|
||
}
|
||
|
||
// Remember last selected for shift-click
|
||
window.app.lastSelectedFolderPath = folder.path;
|
||
|
||
// Re-render tree
|
||
render();
|
||
|
||
// Load files from selected folders
|
||
loadFilesFromSelectedFolders();
|
||
}
|
||
|
||
/**
|
||
* Handle ZIP extraction
|
||
*/
|
||
async function handleExtractZip(folder) {
|
||
if (!folder.isZipRoot || !folder.zipPath) return;
|
||
|
||
try {
|
||
const confirmed = confirm(`Extract "${folder.name}" to a new folder?\n\nThis will create a folder named "${folder.name.replace(/\.zip$/i, '')}" with the ZIP contents.`);
|
||
if (!confirmed) return;
|
||
|
||
// Show extracting state
|
||
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-btn`);
|
||
if (btn) {
|
||
btn.textContent = '⏳ Extracting...';
|
||
btn.disabled = true;
|
||
}
|
||
|
||
await window.app.modules.scanner.extractZip(folder.zipPath);
|
||
|
||
// Auto-refresh preserving tree state
|
||
await window.app.modules.scanner.scanDirectory(window.app.rootHandle, true);
|
||
} catch (err) {
|
||
console.error('Error extracting ZIP:', err);
|
||
alert('Error extracting ZIP: ' + err.message);
|
||
|
||
// Reset button
|
||
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-btn`);
|
||
if (btn) {
|
||
btn.textContent = '📤 Extract';
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle folder expansion
|
||
*/
|
||
function toggleFolder(folder, recursive = false) {
|
||
folder.expanded = !folder.expanded;
|
||
|
||
if (recursive && folder.children) {
|
||
// Recursively expand/collapse all children
|
||
const newState = folder.expanded;
|
||
function setAllExpanded(f) {
|
||
f.expanded = newState;
|
||
if (f.children) {
|
||
f.children.forEach(setAllExpanded);
|
||
}
|
||
}
|
||
folder.children.forEach(setAllExpanded);
|
||
}
|
||
|
||
render();
|
||
|
||
// Opening a not-yet-complete folder jumps its subtree to the front of
|
||
// the scan so its contents are complete on open (re-renders as it
|
||
// fills in). Background scanning continues for everything else.
|
||
if (folder.expanded && folder.scanState !== 'done'
|
||
&& window.app.modules.scanner && window.app.modules.scanner.ensureScanned) {
|
||
window.app.modules.scanner.ensureScanned(folder).then(render).catch(() => {});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load files from all selected folders
|
||
*/
|
||
async function loadFilesFromSelectedFolders() {
|
||
// Use store to manage files
|
||
window.app.modules.store.setSelectedFolders(Array.from(window.app.selectedFolders));
|
||
}
|
||
|
||
/**
|
||
* Find folder by path in tree
|
||
*/
|
||
function findFolderByPath(path) {
|
||
function search(folders) {
|
||
for (const folder of folders) {
|
||
if (folder.path === path) {
|
||
return folder;
|
||
}
|
||
if (folder.children) {
|
||
const found = search(folder.children);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
return search(window.app.folderTree);
|
||
}
|
||
|
||
/**
|
||
* Update selected folders count
|
||
*/
|
||
function updateSelectedCount() {
|
||
const el = window.app.dom.selectedFoldersCount;
|
||
if (!el) return; // count no longer shown in the folder-tree header
|
||
const count = window.app.selectedFolders.size;
|
||
el.textContent = `${count} folder${count !== 1 ? 's' : ''} selected`;
|
||
}
|
||
|
||
/**
|
||
* Build folder tree from scanned data
|
||
*/
|
||
function buildTree(rootHandle, foldersMap) {
|
||
const tree = [];
|
||
|
||
// Convert flat map to tree structure
|
||
function buildNode(handle, path) {
|
||
// For virtual folders, look up by path string; for real folders, use handle
|
||
let files;
|
||
if (handle.isVirtualDir || handle.isZipRoot) {
|
||
files = foldersMap.get(handle.virtualPath || handle.zipPath) || [];
|
||
} else {
|
||
files = foldersMap.get(handle) || [];
|
||
}
|
||
|
||
// Add folderPath to each file for folder highlighting (filter out null files)
|
||
files.filter(file => file !== null).forEach(file => {
|
||
if (!file.isDirectory) {
|
||
file.folderPath = path;
|
||
}
|
||
});
|
||
|
||
// Filter out null files for the node
|
||
const validFiles = files.filter(f => f !== null);
|
||
|
||
const node = {
|
||
name: handle.name,
|
||
path: path,
|
||
handle: handle,
|
||
files: validFiles,
|
||
fileCount: validFiles.length,
|
||
children: [],
|
||
expanded: false
|
||
};
|
||
|
||
// Mark ZIP-related nodes
|
||
if (handle.isZipRoot) {
|
||
node.isZipRoot = true;
|
||
node.zipPath = handle.zipPath;
|
||
}
|
||
if (handle.isVirtualDir) {
|
||
node.isVirtualDir = true;
|
||
node.zipPath = handle.zipPath;
|
||
}
|
||
|
||
return node;
|
||
}
|
||
|
||
// Recursively build tree
|
||
function addChildren(node) {
|
||
// Get subdirectories (filter out null files first)
|
||
// For virtual folders, look up by path string
|
||
let files;
|
||
if (node.handle.isVirtualDir || node.handle.isZipRoot) {
|
||
files = foldersMap.get(node.handle.virtualPath || node.handle.zipPath) || [];
|
||
} else {
|
||
files = foldersMap.get(node.handle) || [];
|
||
}
|
||
const validFiles = files.filter(f => f !== null);
|
||
const subdirs = validFiles.filter(f => f.isDirectory);
|
||
|
||
subdirs.forEach(subdir => {
|
||
const childPath = node.path + '/' + subdir.handle.name;
|
||
const childNode = buildNode(subdir.handle, childPath);
|
||
addChildren(childNode);
|
||
node.children.push(childNode);
|
||
});
|
||
|
||
// Update file count to exclude directories and null files
|
||
node.files = validFiles.filter(f => !f.isDirectory);
|
||
node.fileCount = node.files.length;
|
||
}
|
||
|
||
// Build root
|
||
const root = buildNode(rootHandle, rootHandle.name);
|
||
addChildren(root);
|
||
|
||
// Expand root by default
|
||
root.expanded = true;
|
||
|
||
tree.push(root);
|
||
return tree;
|
||
}
|
||
|
||
/**
|
||
* Get all currently visible folders (expanded tree)
|
||
*/
|
||
function getVisibleFolders() {
|
||
const visible = [];
|
||
|
||
function traverse(folders) {
|
||
for (const folder of folders) {
|
||
visible.push(folder);
|
||
if (folder.expanded && folder.children) {
|
||
traverse(folder.children);
|
||
}
|
||
}
|
||
}
|
||
|
||
traverse(window.app.folderTree);
|
||
return visible;
|
||
}
|
||
|
||
/**
|
||
* Select all visible folders
|
||
*/
|
||
function selectAllVisible() {
|
||
const visible = getVisibleFolders();
|
||
window.app.selectedFolders.clear();
|
||
visible.forEach(f => window.app.selectedFolders.add(f.path));
|
||
render();
|
||
loadFilesFromSelectedFolders();
|
||
}
|
||
|
||
/**
|
||
* Expand all folders in tree
|
||
*/
|
||
function expandAll() {
|
||
function setAllExpanded(folder) {
|
||
folder.expanded = true;
|
||
if (folder.children) {
|
||
folder.children.forEach(setAllExpanded);
|
||
}
|
||
}
|
||
|
||
window.app.folderTree.forEach(setAllExpanded);
|
||
render();
|
||
}
|
||
|
||
/**
|
||
* Select all folders in tree
|
||
*/
|
||
function selectAll() {
|
||
function collectAllPaths(folders, paths = []) {
|
||
folders.forEach(folder => {
|
||
paths.push(folder.path);
|
||
if (folder.children) {
|
||
collectAllPaths(folder.children, paths);
|
||
}
|
||
});
|
||
return paths;
|
||
}
|
||
|
||
const allPaths = collectAllPaths(window.app.folderTree);
|
||
allPaths.forEach(path => window.app.selectedFolders.add(path));
|
||
|
||
render();
|
||
loadFilesFromSelectedFolders();
|
||
}
|
||
|
||
/**
|
||
* Set up keyboard shortcuts for folder tree
|
||
*/
|
||
function setupKeyboardShortcuts() {
|
||
const container = window.app.dom.folderTree;
|
||
|
||
container.addEventListener('keydown', (e) => {
|
||
// Ctrl+A: Select all visible
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||
e.preventDefault();
|
||
selectAllVisible();
|
||
}
|
||
});
|
||
|
||
// Make container focusable
|
||
container.tabIndex = 0;
|
||
}
|
||
|
||
// ── Classify interactions (exclude menu, cross-tree reveal) ─────────────
|
||
var classifyWired = false;
|
||
function wireClassifyInteractions() {
|
||
if (classifyWired) return;
|
||
classifyWired = true;
|
||
var ft = window.app.dom.folderTree;
|
||
if (!ft) { classifyWired = false; return; }
|
||
ft.addEventListener('contextmenu', onContextMenu);
|
||
// Single-click a source file → preview it (the "look at it, then assign"
|
||
// half of the workflow). Drag still assigns; right-click excludes.
|
||
ft.addEventListener('click', function (e) {
|
||
if (!classifyOn()) return;
|
||
var fe = e.target.closest('.file-item');
|
||
if (!fe || !fe.dataset.key) return;
|
||
var file = findFileByKey(fe.dataset.key);
|
||
if (file && window.app.modules.preview && window.app.modules.preview.previewFile) {
|
||
window.app.modules.preview.previewFile(file);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Aggregate classification state across a folder's loaded subtree files.
|
||
function aggregateState(files) {
|
||
if (!files.length) return null;
|
||
var c = window.app.modules.classify;
|
||
var ex = 0, done = 0, placed = 0;
|
||
files.forEach(function (f) {
|
||
var s = c.fileState(f);
|
||
if (s === 'excluded') ex++;
|
||
else if (s === 'done') done++;
|
||
else if (s !== 'none') placed++;
|
||
});
|
||
if (ex === files.length) return 'excluded';
|
||
var active = files.length - ex;
|
||
if (active > 0 && done === active) return 'done';
|
||
if (done > 0 || placed > 0) return 'partial';
|
||
return 'none';
|
||
}
|
||
|
||
function findFolderByPath(path) {
|
||
var hit = null;
|
||
(function walk(nodes) {
|
||
(nodes || []).forEach(function (n) {
|
||
if (hit) return;
|
||
if (n.path === path) { hit = n; return; }
|
||
walk(n.children);
|
||
});
|
||
})(window.app.folderTree);
|
||
return hit;
|
||
}
|
||
function findFileByKey(key) {
|
||
var c = window.app.modules.classify, hit = null;
|
||
(function walk(nodes) {
|
||
(nodes || []).forEach(function (n) {
|
||
if (hit) return;
|
||
(n.files || []).forEach(function (f) { if (!hit && c.srcKeyForFile(f) === key) hit = f; });
|
||
walk(n.children);
|
||
});
|
||
})(window.app.folderTree);
|
||
return hit;
|
||
}
|
||
function expandToPath(folderPath) {
|
||
(function walk(nodes) {
|
||
(nodes || []).forEach(function (n) {
|
||
if (n.path === folderPath || folderPath.indexOf(n.path + '/') === 0) {
|
||
n.expanded = true;
|
||
walk(n.children);
|
||
}
|
||
});
|
||
})(window.app.folderTree);
|
||
}
|
||
|
||
// Reveal a source file (target → source). Expands its folder chain, renders,
|
||
// scrolls + flashes the row.
|
||
function revealFile(key) {
|
||
var file = findFileByKey(key);
|
||
if (!file) return;
|
||
expandToPath(file.folderPath);
|
||
render();
|
||
var rows = window.app.dom.folderTree.querySelectorAll('.file-item');
|
||
var row = Array.prototype.filter.call(rows, function (r) { return r.dataset.key === key; })[0];
|
||
if (row) {
|
||
row.scrollIntoView({ block: 'center' });
|
||
row.classList.add('match-highlight');
|
||
setTimeout(function () { row.classList.remove('match-highlight'); }, 1500);
|
||
}
|
||
}
|
||
|
||
// ── per-zip mode toggle (single file ⇄ expandable folder) ───────────────
|
||
function persistTreeChange() {
|
||
var ws = window.app.modules.workspace;
|
||
if (ws && ws.onRescanned) ws.onRescanned();
|
||
}
|
||
async function expandZip(file) {
|
||
if (!file.handle && !window.app.rootHandle) {
|
||
if (window.zddc) window.zddc.toast('Connect the source directory first to expand this archive.', 'warning');
|
||
return;
|
||
}
|
||
try {
|
||
var node = await window.app.modules.scanner.expandZipAsFolder(file);
|
||
if (node) { render(); persistTreeChange(); }
|
||
} catch (e) {
|
||
if (window.zddc) window.zddc.toast('Couldn’t expand the archive — ' + (e.message || e), 'error');
|
||
}
|
||
}
|
||
function collapseZip(zipNode) {
|
||
if (!zipNode) return;
|
||
window.app.modules.scanner.collapseZipToFile(zipNode);
|
||
render();
|
||
persistTreeChange();
|
||
}
|
||
|
||
// ── context menu (exclude / include / clear / zip mode) ─────────────────
|
||
var menuEl = null;
|
||
function hideMenu() { if (menuEl) { menuEl.remove(); menuEl = null; } }
|
||
function showMenu(x, y, items) {
|
||
hideMenu();
|
||
menuEl = document.createElement('div');
|
||
menuEl.className = 'cl-menu';
|
||
items.forEach(function (it) {
|
||
var b = document.createElement('button');
|
||
b.className = 'cl-menu__item';
|
||
b.textContent = it.label;
|
||
b.addEventListener('click', function () { hideMenu(); it.fn(); });
|
||
menuEl.appendChild(b);
|
||
});
|
||
menuEl.style.left = x + 'px';
|
||
menuEl.style.top = y + 'px';
|
||
document.body.appendChild(menuEl);
|
||
setTimeout(function () {
|
||
document.addEventListener('click', hideMenu, { once: true });
|
||
document.addEventListener('scroll', hideMenu, { once: true, capture: true });
|
||
}, 0);
|
||
}
|
||
function onContextMenu(e) {
|
||
if (!classifyOn()) return;
|
||
var c = window.app.modules.classify;
|
||
var fileEl = e.target.closest('.file-item');
|
||
var folderEl = e.target.closest('.folder-item');
|
||
if (!fileEl && !folderEl) return;
|
||
e.preventDefault();
|
||
var items = [];
|
||
if (fileEl) {
|
||
var key = fileEl.dataset.key;
|
||
var a = c.getAssignment(key);
|
||
var excluded = !!(a && a.excluded);
|
||
items.push({ label: excluded ? 'Include in copy' : 'Exclude from copy', fn: function () { c.setExcluded([key], !excluded); } });
|
||
if (a && (a.trackingNodeId || a.transmittalNodeId)) {
|
||
if (a.trackingNodeId) items.push({ label: 'Clear tracking', fn: function () { c.place([key], null, 'tracking'); } });
|
||
if (a.transmittalNodeId) items.push({ label: 'Clear transmittal', fn: function () { c.place([key], null, 'transmittal'); } });
|
||
}
|
||
var file = findFileByKey(key);
|
||
if (file && file.isVirtual) {
|
||
items.push({ label: 'Collapse archive to single file', fn: function () { collapseZip(findFolderByPath(file.zipPath)); } });
|
||
} else if (file && file.extension === 'zip') {
|
||
items.push({ label: 'Expand as folder', fn: function () { expandZip(file); } });
|
||
}
|
||
} else {
|
||
var folder = findFolderByPath(folderEl.dataset.path);
|
||
if (folder && folder.isZipRoot) {
|
||
items.push({ label: 'Collapse to single file', fn: function () { collapseZip(folder); } });
|
||
}
|
||
var keys = keysFor(subtreeFiles(folder || { files: [], children: [] }));
|
||
if (keys.length) {
|
||
var allExcl = keys.every(function (k) { var a = c.getAssignment(k); return a && a.excluded; });
|
||
items.push({
|
||
label: (allExcl ? 'Include' : 'Exclude') + ' folder (' + keys.length + ' file' + (keys.length === 1 ? '' : 's') + ')',
|
||
fn: function () { c.setExcluded(keys, !allExcl); },
|
||
});
|
||
}
|
||
if (!items.length) return;
|
||
}
|
||
showMenu(e.clientX, e.clientY, items);
|
||
}
|
||
|
||
// Export module
|
||
window.app.modules.tree = {
|
||
render,
|
||
buildTree,
|
||
loadFilesFromSelectedFolders,
|
||
setupKeyboardShortcuts,
|
||
expandAll,
|
||
selectAll,
|
||
revealFile,
|
||
setShowFilters,
|
||
setNameFilter
|
||
};
|
||
})();
|