- Favorites use PhotoPrism's own like/unlike endpoint (not a mule-only mark) so they sync to third-party gallery apps, with heart controls in the tile, sidebar, and an `f` shortcut. - Lightbox: wheel-zoom around cursor, double-click to 2.5x, drag-to-pan, auto-upgrades to the fit_2048 tile past 1.25x zoom. - Sidebar: copy-EXIF button, clickable Camera/Lens values that jump to a filtered timeline (camera:/lens: DSL), matching the existing Country link. - Fix filtersToQ() quoting the entire search string whenever it contained a colon, which silently turned any raw DSL operator (camera:, taken:2024, etc.) into a literal phrase search — discovered while verifying the new jump-links against production. - Disable TanStack Query's refetchOnWindowFocus: the indexer WebSocket already invalidates photo queries on real changes, so the focus refetch was just a redundant full-timeline re-render on tab-switch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
213 lines
7.2 KiB
TypeScript
213 lines
7.2 KiB
TypeScript
/**
|
|
* 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<PpPhoto>(['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<void> {
|
|
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<void> {
|
|
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<PpPhoto>(['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<void> {
|
|
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<void> {
|
|
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 });
|
|
}
|