/** * OxiCloud — Batch Toolbar Module * * Manages the floating selection bar that appears when items are selected, * and executes batch operations (delete, move, download, favorites). * * Selection state (_selected, handleToggleItem, selectAll, …) is kept here * while the main file manager still uses its own delegation (ui.js). * Once ui.js is migrated to ResourceListComponent (plan step B5), all * selection mechanics will live in the component and this module will * shrink to only the toolbar UI and batch-operation API calls. */ import { loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { showConfirmDialog, ui } from '../../app/ui.js'; import { i18n } from '../../core/i18n.js'; import { buildBatchDownloadUrl, triggerBrowserDownload } from '../../utils/download.js'; import { favorites } from '../library/favorites.js'; import { contextMenus } from './contextMenus.js'; import { getAuthHeaders } from './fileOperations.js'; /** * @import {ItemTypeEnum, LightItem} from '../../core/types.js' * @import {BatchResult} from './fileOperations.js' * @import {ResourceListComponent} from '../../components/resourceList.js' */ const batchToolbar = { /** @type {Map} items: Map */ _selected: new Map(), /** Last clicked index for Shift-range selection */ _lastClickedIndex: -1, /** Whether the selection bar is currently visible */ _barVisible: false, /** * The `ResourceListComponent` currently managing the active view. * When set, keyboard shortcuts (Ctrl+A, Escape) delegate to the component * so its internal selection state stays consistent. * @type {ResourceListComponent | null} */ _activeComponent: null, /** * Register (or unregister) the component that owns the current view's * selection state. Pass `null` when leaving a component-managed view. * @param {ResourceListComponent | null} component */ setActiveComponent(component) { this._activeComponent = component; }, // ── Public API ────────────────────────────────────────── get count() { return this._selected.size; }, get items() { return Array.from(this._selected.values()); }, get hasSelection() { return this._selected.size > 0; }, get files() { return this.items.filter((i) => i.type === 'file'); }, get folders() { return this.items.filter((i) => i.type === 'folder'); }, // ── Helpers for i18n ──────────────────────────────────── /** * * @param {string} key * @param {any} vars * @returns */ _t(key, vars) { const val = i18n.t(key, vars); return val !== key ? val : null; }, // ── Selection state management ────────────────────────── /** * * @param {string} id * @param {string} name * @param {ItemTypeEnum} type * @param {string} parentId * @returns */ toggle(id, name, type, parentId) { if (this._selected.has(id)) { this._selected.delete(id); return false; } this._selected.set(id, { id, name, type, parentId }); return true; }, /** * * @param {string} id * @param {string} name * @param {ItemTypeEnum} type * @param {string} parentId * @returns */ select(id, name, type, parentId) { this._selected.set(id, { id, name, type, parentId }); }, /** * * @param {string} id */ deselect(id) { this._selected.delete(id); }, clear() { this._selected.clear(); this._lastClickedIndex = -1; // Scope the DOM sweep to the files list — the only container this // toolbar manages (see `selectAll`) — instead of the whole document, // and only touch checkboxes that are actually checked. const list = document.getElementById('files-list'); list?.querySelectorAll('.file-item.selected').forEach((el) => { el.classList.remove('selected'); }); list?.querySelectorAll('.item-checkbox:checked').forEach((cb) => { /** @type {HTMLInputElement} */ (cb).checked = false; }); // Reset the active component's internal selection state without going // through onSelectionChange (which would re-enter this method). if (this._activeComponent) { this._activeComponent._selected.clear(); this._activeComponent._lastClickedIndex = -1; this._activeComponent._syncSelectAllCheckbox(); } this._syncUI(); }, selectAll() { this._selectAllInContainer('files-list', '.file-item'); this._syncUI(); }, toggleAll() { const allItems = this._getAllVisibleItems(); if (this._selected.size >= allItems.length && allItems.length > 0) { this.clear(); } else { this.selectAll(); } }, /** * @typedef {Object} ItemSelection * @property {string[]} fileIds list of files' id * @property {string[]} folderIds list of folders' id */ /** * get selection * @param {string} [targtFolderId] an optional targget (will be removed from selected item) * @return {ItemSelection} */ getSelection(targtFolderId) { /** @type {Array} */ const fileIds = []; /** @type {Array} */ const folderIds = []; // `_selected` is the source of truth (every selection path keeps it // in sync) — no need to re-derive the selection from a DOM scan. for (const sel of this._selected.values()) { if (sel.type === 'file') { fileIds.push(sel.id); } else if (targtFolderId && targtFolderId !== sel.id) { // ignore the selected folder if it is the drop target itself folderIds.push(sel.id); } } return { fileIds: fileIds, folderIds: folderIds }; }, /** * @param {string} action move|copy * @param {BatchResult} result result of batch */ showBatchResult(action, result) { if (action === 'copy') { if (result.errors > 0) { ui.showNotification(i18n.t('notif.batchCopy'), i18n.t('notif.batchCopyBody', { success: result.success, errors: result.errors })); } else { ui.showNotification(i18n.t('notif.itemsCopied'), i18n.t('notif.itemsCopiedBody', { count: result.success })); } } else { if (result.errors > 0) { ui.showNotification(i18n.t('notif.batchMove'), i18n.t('notif.batchMoveBody', { success: result.success, errors: result.errors })); } else { ui.showNotification(i18n.t('notif.itemsMoved'), i18n.t('notif.itemsMovedBody', { count: result.success })); } } }, // ── DOM helpers ───────────────────────────────────────── /** * * @param {HTMLDivElement} el */ _selectElement(el) { const info = this._extractInfo(el); if (info) { this.select(info.id, info.name, info.type, info.parentId); el.classList.add('selected'); } }, /** * * @param {string} containerId * @param {string} selector * @returns {void} */ _selectAllInContainer(containerId, selector) { const container = /** @type {HTMLDivElement} */ (document.getElementById(containerId)); if (!container) return; /** @type {NodeListOf} */ (container.querySelectorAll(selector)).forEach((el) => { this._selectElement(el); }); }, /** * * @returns {HTMLDivElement[]} */ _getAllVisibleItems() { return /** @type {HTMLDivElement[]} */ ([...document.querySelectorAll('.file-item')]); }, /** * @param {HTMLDivElement} el * @returns {LightItem} */ _extractInfo(el) { if (el.dataset.folderId && el.dataset.folderName !== undefined) { return { id: el.dataset.folderId, name: el.dataset.folderName, type: 'folder', parentId: el.dataset.parentId || '' }; } if (el.dataset.fileId) { return { id: el.dataset.fileId, name: el.dataset.fileName, type: 'file', parentId: el.dataset.folderId || '' }; } return null; }, // ── Click handler (shared by grid + list) ─────────────── /** * @param {HTMLDivElement} el * @param {MouseEvent} event */ handleToggleItem(el, event) { const items = this._getAllVisibleItems(); const index = items.indexOf(el); const info = this._extractInfo(el); if (!info) return; if (event?.shiftKey && this._lastClickedIndex >= 0 && index >= 0) { const start = Math.min(this._lastClickedIndex, index); const end = Math.max(this._lastClickedIndex, index); for (let i = start; i <= end; i++) { this._selectElement(items[i]); const iInfo = this._extractInfo(items[i]); if (iInfo) { const sel = iInfo.type === 'folder' ? `[data-folder-id="${iInfo.id}"]` : `[data-file-id="${iInfo.id}"]`; document.querySelectorAll(sel).forEach((e) => { e.classList.add('selected'); const checkbox = /** @type {HTMLInputElement} */ (e.querySelector('input[type="checkbox"]')); if (checkbox) checkbox.checked = true; }); } } } else { const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId); el.classList.toggle('selected', nowSelected); const checkbox = /** @type {HTMLInputElement} */ (el.querySelector('input[type="checkbox"]')); if (checkbox) checkbox.checked = nowSelected; } this._lastClickedIndex = index; this._syncUI(); this._syncSelectAllCheckbox(); }, /** Main UI sync — called after every selection change */ _syncUI() { const n = this._selected.size; const multiSelectButtons = document.getElementById('multi-select-buttons'); const defaultButtons = document.getElementById('default-buttons'); if (n > 0) { this._barVisible = true; const countText = n === 1 ? this._t('batch.one_selected') || '1 item selected' : this._t('batch.n_selected', { count: n }) || `${n} items selected`; document.getElementById('batch-bar-count').innerText = countText; defaultButtons?.classList.add('hidden'); multiSelectButtons?.classList.remove('hidden'); } else { this._barVisible = false; // Hide grid bar multiSelectButtons?.classList.add('hidden'); defaultButtons?.classList.remove('hidden'); } // Sync individual item checkboxes this._syncItemCheckboxes(); // Sync select-all checkbox state (for non-selection-mode) this._syncSelectAllCheckbox(); }, /** Wire click handlers on batch action buttons (idempotent per render) */ _wireBarButtons() { const del = document.getElementById('batch-delete'); const move = document.getElementById('batch-move'); const dl = document.getElementById('batch-download'); const fav = document.getElementById('batch-fav'); const closeBtn = document.getElementById('batch-selection-close'); if (del) del.onclick = () => this.batchDelete(); if (move) move.onclick = () => this.batchMove(); if (dl) dl.onclick = () => this.batchDownload(); if (fav) fav.onclick = () => this.batchFavorites(); if (closeBtn) closeBtn.onclick = () => this.clear(); }, _syncItemCheckboxes() { // When a ResourceListComponent is active it owns checkbox state — skip. if (this._activeComponent) return; document.querySelectorAll('.file-item').forEach((el) => { const cb = /** @type {HTMLInputElement} */ (el.querySelector('.item-checkbox')); if (cb) cb.checked = el.classList.contains('selected'); }); }, _syncSelectAllCheckbox() { const cb = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox')); if (!cb) return; const all = this._getAllVisibleItems(); if (all.length === 0) { cb.checked = false; cb.indeterminate = false; } else if (this._selected.size >= all.length) { cb.checked = true; cb.indeterminate = false; } else if (this._selected.size > 0) { cb.checked = false; cb.indeterminate = true; } else { cb.checked = false; cb.indeterminate = false; } }, // ── Batch operations ──────────────────────────────────── /** Batch delete (move to trash) */ async batchDelete() { const items = this.items; if (items.length === 0) return; const n = items.length; const msg = n === 1 ? this._t('dialogs.confirm_delete_file', { name: items[0].name }) || `Are you sure you want to move "${items[0].name}" to trash?` : this._t('batch.confirm_delete', { count: n }) || `Are you sure you want to move ${n} items to trash?`; const confirmed = await showConfirmDialog({ title: this._t('dialogs.confirm_delete') || 'Move to trash', message: msg, confirmText: this._t('actions.delete') || 'Delete' }); if (!confirmed) return; const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id); const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id); try { const response = await fetch('/api/batch/trash', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }) }); const data = await response.json(); const success = data.stats?.successful || 0; const errors = data.stats?.failed || 0; this.clear(); loadFiles(); if (errors > 0) { ui.showNotification(i18n.t('notif.batchDelete'), i18n.t('notif.batchDeleteBody', { success, errors })); } else { ui.showNotification(i18n.t('notif.movedToTrash'), i18n.t('notif.movedToTrashBody', { count: success })); } } catch (e) { console.error('Batch trash error:', e); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.trashItemsError')); this.clear(); loadFiles(); } }, /** Batch move — reuse existing move dialog */ async batchMove() { const items = this.items; if (items.length === 0) return; app.moveDialogMode = 'batch'; app.batchMoveItems = items; app.selectedTargetFolderId = ''; const dialog = document.getElementById('move-file-dialog'); const dialogHeader = dialog.querySelector('.rename-dialog-header'); const n = items.length; const titleText = this._t('batch.move_title', { count: n }) || `Move ${n} item${n !== 1 ? 's' : ''}`; dialogHeader.innerHTML = ` ${titleText}`; const excludeIds = items.filter((i) => i.type === 'folder').map((i) => i.id); await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch'); dialog.style.display = 'flex'; }, /** Batch download — downloads all selected items as a single ZIP */ async batchDownload() { const items = this.items; if (items.length === 0) return; ui.showNotification(i18n.t('notif.preparingDownload'), i18n.t('notif.preparingDownloadBody')); const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id); const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id); const zipName = `oxicloud-download-${Date.now()}.zip`; // Browser-native download via the GET variant of the endpoint: the // ZIP streams to disk instead of being buffered whole in the tab's // memory (a multi-GB selection used to risk crashing the tab). const url = buildBatchDownloadUrl(fileIds, folderIds); if (url.length <= 4000) { triggerBrowserDownload(url, zipName); return; } // Selections too large for a URL (~100+ items) keep the buffered // POST path — the id list only fits in a request body. try { const response = await fetch('/api/batch/download', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }) }); if (!response.ok) throw new Error(`Server returned ${response.status}`); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; link.download = zipName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(blobUrl); } catch (e) { console.error('Batch download error:', e); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.downloadItemsError')); } }, /** Batch add to favorites — single API call */ async batchFavorites() { const items = this.items; if (items.length === 0 || !favorites) return; // Filter out items already in favourites const toAdd = items.filter((i) => !favorites.isFavorite(i.id, i.type)); if (toAdd.length === 0) { this.clear(); ui.showNotification(this._t('favorites.add') || 'Favorites', 'All selected items are already favorites'); return; } try { const response = await fetch('/api/favorites/batch', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ items: toAdd.map((i) => ({ item_id: i.id, item_type: i.type })) }) }); if (!response.ok) throw new Error(`Server returned ${response.status}`); const data = await response.json(); const inserted = data.stats?.inserted || 0; // Re-fetch the isFavorite cache from the server. await favorites._fetchFromServer(); this.clear(); loadFiles(); if (inserted > 0) { ui.showNotification(this._t('favorites.add') || 'Added to favorites', `${inserted} item${inserted !== 1 ? 's' : ''} added to favorites`); } else { ui.showNotification(this._t('favorites.add') || 'Favorites', 'All selected items are already favorites'); } } catch (e) { console.error('Batch favorites error:', e); ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.favoritesAddError')); } }, // ── Initialization ────────────────────────────────────── init() { // Wire the initial select-all checkbox this._injectListHeaderCheckbox(); // Keyboard shortcuts document.addEventListener('keydown', (e) => { const target = /** @type {Element} */ (e.target); if (target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return; const selectAllCheckbox = /** @type {HTMLInputElement} */ (document.getElementById('select-all-checkbox')); // ctrl+a / cmd+a — delegate to active component when present if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if (this._activeComponent) { this._activeComponent.selectAll(); } else { if (selectAllCheckbox) selectAllCheckbox.checked = true; this.selectAll(); } e.preventDefault(); } if (e.key === 'Escape') { if (this._activeComponent && this._activeComponent._selected.size > 0) { this._activeComponent.clearSelection(); } else if (this.hasSelection) { this.clear(); if (selectAllCheckbox) selectAllCheckbox.checked = false; } } if (e.key === 'Delete' && this.hasSelection) this.batchDelete(); }); this._wireBarButtons(); }, // FIXME: competition with _ _injectListHeaderCheckbox() { const selectAllCheckbox = document.getElementById('select-all-checkbox'); if (!selectAllCheckbox) return; selectAllCheckbox.addEventListener('change', () => this.toggleAll()); } }; export { batchToolbar };