x
CI / changes (push) Has been cancelled
CI / Build (push) Has been cancelled
Deploy Docs / build (push) Has been cancelled
Docker Build and Test / Build and Test Docker Image (push) Has been cancelled
Docker Publish (release, main, dry-run) / Pre-publish Tests (push) Has been cancelled
CI / Frontend — svelte-check, ESLint, Stylelint, Prettier (push) Has been cancelled
CI / Message-bus spec — AsyncAPI + TypeScript DTO drift (push) Has been cancelled
CI / Migration ordering (new migrations postdate target branch) (push) Has been cancelled
CI / Rustfmt (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Wasm — fmt + clippy (push) Has been cancelled
CI / Wasm — release tests (push) Has been cancelled
CI / Plugins — fixtures + runtime tests (push) Has been cancelled
CI / Server Unit and Functionnal Tests (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / API, WebDAV & OIDC tests (push) Has been cancelled
CI / Bundled-assets binary — embed + SPA-serve integration (push) Has been cancelled
CI / WebDAV RFC 4918 — litmus (59/59) (push) Has been cancelled
CI / CalDAV + CardDAV — python-caldav (push) Has been cancelled
CI / Frontend end-to-end tests (via Playwright) (push) Has been cancelled
Deploy Docs / deploy (push) Has been cancelled
Docker Publish (release, main, dry-run) / Build & Push Multi-Arch (push) Has been cancelled

This commit is contained in:
2026-09-20 00:28:52 +08:00
parent d33d1932b6
commit b7640e9be4
34 changed files with 2026 additions and 302 deletions
@@ -0,0 +1,241 @@
// 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<FileItem | FolderItem>;
/** Selected ids — the page's SvelteSet mirror of the list's selection. */
getSelected: () => ReadonlySet<string>;
clearSelection: () => void;
/** After a successful delete: reload the listing or re-run the search. */
onChanged: () => void | Promise<void>;
/** 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<string>) => 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<string, ActionTarget>();
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<void> {
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<void> {
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<string, FileItem | FolderItem>();
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<void> {
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<void> {
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;
}