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
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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user