// Shared batch-actions composable for resource list views (files page, // search results, …). // // Extracted verbatim from the files page so every surface that can select // items shares one implementation of the batch favorite / download / // delete / move / copy flows. Surfaces differ in (a) the id→item index, // (b) what "refresh" means (folder reload vs search re-run) and (c) how a // favorites flip reaches the rows — those differences are injected via // the callbacks in `ResourceActionsOptions`. import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; import { downloadBatch } from '$lib/api/endpoints/batch'; import { deleteFile, fileDownloadUrl } from '$lib/api/endpoints/files'; import { deleteFolder } from '$lib/api/endpoints/folders'; import { addFavoritesBatch } from '$lib/api/endpoints/favorites'; import { t } from '$lib/i18n/index.svelte'; import { confirmDialog } from '$lib/stores/dialogs.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { errorToast } from '$lib/utils/errors'; import { mapLimit } from '$lib/utils/mapLimit'; /** A minimal actionable item reference (dialog props, favorites payload). */ export interface ActionTarget { id: string; name: string; kind: ItemType; } export interface ResourceActionsOptions { /** Current on-screen rows, read at action time (fresh, never stale). */ getItems: () => ReadonlyArray; /** Selected ids — the page's SvelteSet mirror of the list's selection. */ getSelected: () => ReadonlySet; clearSelection: () => void; /** After a successful delete: reload the listing or re-run the search. */ onChanged: () => void | Promise; /** Extra bookkeeping after a delete (e.g. the session/quota refresh). */ afterDelete?: () => void; /** * After favorites succeed, update rows in place (keeps scroll position on * infinite-scroll pages). Defaults to flipping `is_favorite` on the items * returned by `getItems()` — sufficient for plain DTO `$state` arrays. */ onFavoritesApplied?: (ids: ReadonlySet) => void; } /** Name for a server-zipped multi-item archive (matches the legacy format). */ export function batchZipName(): string { // eslint-disable-next-line svelte/prefer-svelte-reactivity -- filename stamp, never read reactively const stamp = new Date().toISOString().replace('T', ' ').replace(/\..*/, '').replace(/:/g, '-'); return `oxicloud ${stamp}.zip`; } /** Trigger a browser download of `blob` as `name`. */ function saveBlob(blob: Blob, name: string): void { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } export function useResourceActions(opts: ResourceActionsOptions) { // Move/copy dialog state, owned here so both surfaces bind one dialog to // `moveDialog.*` instead of re-implementing the open/mode/items triple. const moveDialog = $state({ open: false, mode: 'move' as 'move' | 'copy', item: null as ActionTarget | null, items: null as ActionTarget[] | null }); function openBatchDialog(mode: 'move' | 'copy', items: ActionTarget[]): void { moveDialog.items = items; moveDialog.item = null; moveDialog.mode = mode; moveDialog.open = true; } /** Context-menu / single-row entry points. */ function openMove(target: ActionTarget): void { moveDialog.item = target; moveDialog.items = null; moveDialog.mode = 'move'; moveDialog.open = true; } function openCopy(target: ActionTarget): void { moveDialog.item = target; moveDialog.items = null; moveDialog.mode = 'copy'; moveDialog.open = true; } function selectionTargets(): ActionTarget[] { // One O(M) index build instead of an O(N·M) `find` per selected id. // Folders win id collisions, matching the old folder-first probe. // eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read const byId = new Map(); for (const f of opts.getItems()) byId.set(f.id, { id: f.id, name: f.name, kind: kindOf(f) }); return [...opts.getSelected()] .map((id) => byId.get(id) ?? null) .filter((x): x is ActionTarget => x !== null); } /** * Download the whole selection as a single zip via POST /api/batch/download — * folders are included (the old per-item loop silently skipped them). A lone * file still streams directly so it keeps its original name/extension. */ async function batchDownload(): Promise { const targets = selectionTargets(); if (targets.length === 0) return; const fileTargets = targets.filter((it) => it.kind === 'file'); const folderTargets = targets.filter((it) => it.kind === 'folder'); // Single file, no folders → direct download (preserves the real name). if (fileTargets.length === 1 && folderTargets.length === 0) { const file = opts.getItems().find((f) => f.id === fileTargets[0].id); if (file) { const a = document.createElement('a'); a.href = fileDownloadUrl(file.id); a.download = file.name; document.body.appendChild(a); a.click(); a.remove(); } return; } try { const blob = await downloadBatch( fileTargets.map((it) => it.id), folderTargets.map((it) => it.id) ); saveBlob(blob, batchZipName()); } catch (e) { errorToast(e); } } /** Batch add the selection to favorites — single /api/favorites/batch call. */ async function batchFavorites(): Promise { const items = opts.getItems(); // Build an id → item index so the "already favorite" filter is // O(1) per selection member instead of an O(N·M) scan. Reused // after success to flip `is_favorite` in place on each row. // eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read const byId = new Map(); for (const it of items) byId.set(it.id, it); const targets = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false)); if (targets.length === 0) { ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info'); opts.clearSelection(); return; } try { await addFavoritesBatch(targets.map((it) => ({ item_id: it.id, item_type: it.kind }))); // eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read const flipped = new Set(targets.map((it) => it.id)); if (opts.onFavoritesApplied) opts.onFavoritesApplied(flipped); else for (const id of flipped) { const row = byId.get(id); if (row) row.is_favorite = true; } ui.notify(t('files.added_favorites', 'Added to favorites'), 'success'); opts.clearSelection(); } catch (e) { errorToast(e); } } async function batchDelete(): Promise { const selected = opts.getSelected(); const ids = [...selected]; const ok = await confirmDialog({ title: t('files.batch_delete', 'Delete selected'), message: t('files.confirm_batch_delete', { n: ids.length }, 'Move {{n}} items to trash?'), confirmText: t('common.delete', 'Delete'), danger: true }); if (!ok) return; // Bounded fan-out instead of a serial await per item: 100 deletes at // ~30 ms RTT collapse from ~3 s of waterfall to a few round-trip // windows. Failures toast individually and the rest still proceed. const items = opts.getItems(); // eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read const folderIdSet = new Set(items.filter((it) => !isFileItem(it)).map((it) => it.id)); await mapLimit(ids, 6, async (id) => { try { if (folderIdSet.has(id)) await deleteFolder(id); else await deleteFile(id); } catch (e) { errorToast(e); } }); opts.clearSelection(); await opts.onChanged(); opts.afterDelete?.(); } function batchMove(): void { const items = selectionTargets(); if (items.length) openBatchDialog('move', items); } function batchCopy(): void { const items = selectionTargets(); if (items.length) openBatchDialog('copy', items); } /** Pass as the MoveDialog `onmoved` handler. */ async function handleMoved(): Promise { opts.clearSelection(); await opts.onChanged(); } return { selectionTargets, batchFavorites, batchDownload, batchDelete, batchMove, batchCopy, openMove, openCopy, handleMoved, moveDialog }; } function kindOf(item: FileItem | FolderItem): ItemType { return isFileItem(item) ? 'file' : 'folder'; } /** `FileItem | FolderItem` uses duck typing (`mime_type`) rather than a tag field. */ export function isFileItem(item: FileItem | FolderItem): item is FileItem { return 'mime_type' in item; }