ZDDC/classifier/js/tree.js
ZDDC 47cf58b0e9 feat(classifier): drag-and-drop assignment (phase 3)
In Classify & Copy mode the left tree now lists each folder's files as
draggable rows (with a classification state dot), and folder rows are
draggable for a group-drag of the whole subtree. Target-tree nodes are drop
zones: a tracking folder (any node) or a transmittal bin; dropping assigns the
dragged source key(s) along that axis via classify.place().

- dnd.js: drag-payload bus (keys held in a module var since dataTransfer can't
  be read during dragover; carries a marker for the copy cursor).
- tree.js: createFileElement + group-drag dragstart; classify-mode file rows.
- target-tree.js: setupDropZone with dragover highlight + drop assignment
  (tracking = any node, transmittal = bins only).
- app.js: source tree re-renders on classify state change.
- 2 DnD drop-handler tests (14 total green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:23:38 -05:00

680 lines
24 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;
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 = '&#128230;'; // 📦
} else if (folder.isVirtualDir) {
icon.innerHTML = '&#128194;'; // 📂
} else {
icon.innerHTML = '&#128193;'; // 📁
}
item.appendChild(icon);
// 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;
item.appendChild(stateDot(c.fileState(file)));
const icon = document.createElement('span');
icon.className = 'file-icon';
icon.innerHTML = '&#128196;'; // 📄
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;
}
// Export module
window.app.modules.tree = {
render,
buildTree,
loadFilesFromSelectedFolders,
setupKeyboardShortcuts,
expandAll,
selectAll
};
})();