/** * Shared action helpers for the photo-app's review / archive flows. * * Three callers reach for these: * 1. `gridKeyNav` — the document-level keyboard action (S / X) * 2. `BulkActionBar` — the footer button row that appears on selection * 3. `/review` CauseGroupCard — per-cause "Dismiss all" / "Archive all" * * Centralising the toast text, undo wiring, focus advance, and cache * invalidation here keeps the three surfaces in lockstep — change the * toast wording in one place and everywhere shows the same verb. */ import { toast } from 'svelte-sonner'; import { batchEdit } from './batch'; import { invalidatePhotos } from './bulk'; import { approvePhoto, batchArchive, batchRestore, buildTakenAtPatch, likePhoto, unlikePhoto, updatePhoto } from './photoprism'; import { queryClient } from '$lib/queryClient'; import { clearSelection, focusAfter } from '$lib/stores/selection.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte'; import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath'; import { photoNameAndDir, type PpPhoto } from '$lib/types/photoprism'; /** Walk every cache that might hold a photo's metadata — timeline list * (flat or infinite), review-groups bucket, per-photo detail — without * forcing a refetch. Returns undefined when the uid hasn't been seen. * Shared by callers that need to look up photo state by uid from * outside a component (gridKeyNav, photoActions). */ export function cachedPhoto(uid: string): PpPhoto | undefined { const lists = queryClient.getQueriesData({ queryKey: ['photos'] }); for (const [, data] of lists) { if (!data) continue; if (Array.isArray(data)) { const hit = (data as PpPhoto[]).find((p) => p.UID === uid); if (hit) return hit; continue; } const pages = (data as { pages?: PpPhoto[][] }).pages; if (!Array.isArray(pages)) continue; for (const pg of pages) { const hit = pg?.find?.((p) => p.UID === uid); if (hit) return hit; } } const review = queryClient.getQueryData<{ photos?: PpPhoto[] }[]>(['review-groups']); if (review) { for (const group of review) { const hit = group.photos?.find((p) => p.UID === uid); if (hit) return hit; } } return queryClient.getQueryData(['photo', uid]); } /** * Dismiss photos out of the review queue by bumping their quality * score above PhotoPrism's threshold. One-way: there's no * `/unapprove` endpoint, so we do NOT push an undo entry — a re-keyed * action would just be a no-op on already-approved photos. */ export async function dismissPhotos(uids: string[]): Promise { if (uids.length === 0) return; const tid = toast.loading(`Dismissing ${uids.length}…`); const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id)); focusAfter(uids); clearSelection(); invalidatePhotos(uids); void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); if (errors.length) { toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, { id: tid, description: errors[0].message }); return; } toast.success(`Dismissed ${uids.length}`, { id: tid }); } /** * Walk the selected uids, applying each photo's path-derived date * suggestion (when one exists) before approving it. UIDs without a * suggestion fall through to a plain approve. Used by the EXIF Stripped * review tab — the `📅 Accept date & Keep` button and the bare `a` * keyboard shortcut both route here so wording / focus / toast * behaviour stay in lockstep. */ export async function acceptDateAndKeep(uids: string[]): Promise { if (uids.length === 0) return; const tid = toast.loading(`Updating & keeping ${uids.length}…`); const { updated, errors } = await batchEdit(uids, async (id) => { const p = cachedPhoto(id); if (p) { const { fileName, path } = photoNameAndDir(p); const guess = suggestDateFromPath({ fileName, originalName: p.OriginalName, path }); if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`)); } await approvePhoto(id); return id; }); focusAfter(uids); clearSelection(); invalidatePhotos(uids); void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); if (errors.length) { toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid, description: errors[0].message }); return; } toast.success(`Kept ${uids.length}`, { id: tid }); } /** Patch `Favorite` on every cached copy of the uids (timeline pages, * per-photo detail) so hearts flip instantly without a refetch. */ function patchFavoriteCaches(uids: string[], value: boolean): void { const target = new Set(uids); const lists = queryClient.getQueriesData({ queryKey: ['photos'] }); for (const [key, data] of lists) { if (!data) continue; if (Array.isArray(data)) { queryClient.setQueryData( key, (data as PpPhoto[]).map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p)) ); continue; } const pages = (data as { pages?: PpPhoto[][] }).pages; if (!Array.isArray(pages)) continue; queryClient.setQueryData(key, { ...(data as object), pages: pages.map((pg) => pg.map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p)) ) }); } for (const uid of uids) { const p = queryClient.getQueryData(['photo', uid]); if (p) queryClient.setQueryData(['photo', uid], { ...p, Favorite: value }); } } /** * Toggle PhotoPrism's native favorite flag on a set of photos. Target * state comes from the first uid (mixed selections converge). Optimistic * cache flip with rollback; undo re-toggles. */ export async function toggleFavorite(uids: string[]): Promise { if (uids.length === 0) { toast.message('Nothing to favorite', { description: 'Click a photo or select some first' }); return; } const value = !(cachedPhoto(uids[0])?.Favorite ?? false); patchFavoriteCaches(uids, value); const { errors } = await batchEdit(uids, (id) => (value ? likePhoto(id) : unlikePhoto(id))); if (errors.length) { patchFavoriteCaches(uids, !value); toast.error(`Favorite failed on ${errors.length}`, { description: errors[0].message }); return; } toast.success( value ? uids.length === 1 ? 'Added to favorites' : `Favorited ${uids.length}` : uids.length === 1 ? 'Removed from favorites' : `Unfavorited ${uids.length}` ); pushUndo(value ? `Favorited ${uids.length}` : `Unfavorited ${uids.length}`, async () => { patchFavoriteCaches(uids, !value); await batchEdit(uids, (id) => (value ? unlikePhoto(id) : likePhoto(id))); }); } /** * Archive photos. Reversible via the undo stack (Restore on ⌘Z). */ export async function archivePhotos(uids: string[]): Promise { if (uids.length === 0) return; const tid = toast.loading(`Archiving ${uids.length}…`); try { await batchArchive(uids); } catch (err) { toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid }); return; } pushUndo(`Archived ${uids.length}`, async () => { await batchRestore(uids); invalidatePhotos(uids); void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); }); focusAfter(uids); clearSelection(); invalidatePhotos(uids); void queryClient.invalidateQueries({ queryKey: ['review-groups'] }); toast.success(`Archived ${uids.length}`, { id: tid }); }