- Each source file row shows a classification state dot (unassigned → has-tracking/transmittal → done), and each folder shows an aggregate dot over its subtree. - Right-click a file or folder to Exclude/Include from the copy (folder applies to its whole subtree) or clear an axis; excluded files are struck through and never copied. - Cross-tree find is bidirectional: click a placed file in the target pane to reveal+flash it in the source tree (expanding its folders); click a source file to switch the target pane to its placed axis and flash the node. - Target pane now reverse-looks-up over ALL scanned files (the left tree), not the selection-scoped grid, with placements grouped in one pass per render. - classify.getAssignment() read-only accessor; 5 new tests (18 total green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
826 lines
30 KiB
JavaScript
826 lines
30 KiB
JavaScript
/**
|
|
* Folder Tree Module
|
|
* Handles folder tree rendering and multi-select
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
/**
|
|
* Render the folder tree
|
|
*/
|
|
function render() {
|
|
const container = window.app.dom.folderTree;
|
|
wireClassifyInteractions();
|
|
container.innerHTML = '';
|
|
|
|
if (window.app.folderTree.length === 0) {
|
|
container.innerHTML = '<div class="tree-empty">No folders found</div>';
|
|
return;
|
|
}
|
|
|
|
window.app.folderTree.forEach(folder => {
|
|
const element = createFolderElement(folder);
|
|
container.appendChild(element);
|
|
});
|
|
|
|
updateSelectedCount();
|
|
}
|
|
|
|
/**
|
|
* 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');
|
|
const dDir = folder.subdirCount || 0, tDir = folder.runDirs || 0;
|
|
const dFile = folder.fileCount || 0, 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';
|
|
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.
|
|
if (classifyOn()) {
|
|
const agg = aggregateState(subtreeFiles(folder));
|
|
if (agg) item.appendChild(stateDot(agg));
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Extract All button for folders with ZIP descendants (but not ZIP roots themselves)
|
|
if (!folder.isZipRoot && !folder.isVirtualDir) {
|
|
const zipCount = countZipDescendants(folder);
|
|
if (zipCount > 0) {
|
|
const extractAllBtn = document.createElement('button');
|
|
extractAllBtn.className = 'btn btn-sm zip-extract-all-btn';
|
|
extractAllBtn.textContent = `📤 Extract All (${zipCount})`;
|
|
extractAllBtn.title = `Extract all ${zipCount} ZIP file(s) in this folder`;
|
|
extractAllBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
await handleExtractAllZips(folder);
|
|
});
|
|
item.appendChild(extractAllBtn);
|
|
}
|
|
}
|
|
|
|
// Click handler for selection
|
|
item.addEventListener('click', (e) => {
|
|
handleFolderClick(folder, e);
|
|
});
|
|
|
|
div.appendChild(item);
|
|
|
|
// Children (if expanded)
|
|
if (folder.expanded && folder.children && folder.children.length > 0) {
|
|
const childrenDiv = document.createElement('div');
|
|
childrenDiv.className = 'folder-children';
|
|
folder.children.forEach(child => {
|
|
const childElement = createFolderElement(child, level + 1);
|
|
childrenDiv.appendChild(childElement);
|
|
});
|
|
div.appendChild(childrenDiv);
|
|
}
|
|
|
|
// Classify mode: list this folder's own files (draggable leaves) when
|
|
// expanded, so they can be dropped onto the target trees.
|
|
if (classifyOn() && folder.expanded && folder.files && folder.files.length > 0) {
|
|
const filesDiv = document.createElement('div');
|
|
filesDiv.className = 'folder-children folder-files';
|
|
folder.files.forEach(function (file) {
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count ZIP descendants in a folder
|
|
*/
|
|
function countZipDescendants(folder) {
|
|
let count = 0;
|
|
if (folder.children) {
|
|
for (const child of folder.children) {
|
|
if (child.isZipRoot) {
|
|
count++;
|
|
}
|
|
count += countZipDescendants(child);
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Get all ZIP folders as flat list
|
|
*/
|
|
function getZipDescendants(folder, zips = []) {
|
|
if (folder.children) {
|
|
for (const child of folder.children) {
|
|
if (child.isZipRoot) {
|
|
zips.push(child);
|
|
}
|
|
getZipDescendants(child, zips);
|
|
}
|
|
}
|
|
return zips;
|
|
}
|
|
|
|
/**
|
|
* Handle extracting all ZIPs in a folder
|
|
*/
|
|
async function handleExtractAllZips(folder) {
|
|
const zips = getZipDescendants(folder);
|
|
if (zips.length === 0) return;
|
|
|
|
const confirmed = confirm(`Extract ${zips.length} ZIP file(s)?\n\nThis will create folders for each ZIP with their contents.`);
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
// Show extracting state on button
|
|
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-all-btn`);
|
|
if (btn) {
|
|
btn.textContent = '⏳ Extracting...';
|
|
btn.disabled = true;
|
|
}
|
|
|
|
// Extract all ZIPs
|
|
for (const zip of zips) {
|
|
if (zip.zipPath) {
|
|
await window.app.modules.scanner.extractZip(zip.zipPath);
|
|
}
|
|
}
|
|
|
|
// Auto-refresh preserving tree state
|
|
await window.app.modules.scanner.scanDirectory(window.app.rootHandle, true);
|
|
} catch (err) {
|
|
console.error('Error extracting ZIPs:', err);
|
|
alert('Error extracting ZIPs: ' + err.message);
|
|
|
|
// Reset button
|
|
const btn = document.querySelector(`.folder-item[data-path="${folder.path}"] .zip-extract-all-btn`);
|
|
if (btn) {
|
|
btn.textContent = `📤 Extract All (${zips.length})`;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Toggle folder expansion
|
|
*/
|
|
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 count = window.app.selectedFolders.size;
|
|
window.app.dom.selectedFoldersCount.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);
|
|
ft.addEventListener('click', function (e) {
|
|
if (!classifyOn()) return;
|
|
var fe = e.target.closest('.file-item');
|
|
if (fe && fe.dataset.key && window.app.modules.targetTree) {
|
|
window.app.modules.targetTree.reveal(fe.dataset.key);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// ── context menu (exclude / include / clear) ───────────────────────────
|
|
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'); } });
|
|
}
|
|
} else {
|
|
var folder = findFolderByPath(folderEl.dataset.path);
|
|
var keys = keysFor(subtreeFiles(folder || { files: [], children: [] }));
|
|
if (!keys.length) return;
|
|
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); },
|
|
});
|
|
}
|
|
showMenu(e.clientX, e.clientY, items);
|
|
}
|
|
|
|
// Export module
|
|
window.app.modules.tree = {
|
|
render,
|
|
buildTree,
|
|
loadFilesFromSelectedFolders,
|
|
setupKeyboardShortcuts,
|
|
expandAll,
|
|
selectAll,
|
|
revealFile
|
|
};
|
|
})();
|