Issue 1 — colors/labels not applying in bulk: - sidecar validColors only accepted 4 of the 8 UI swatches, so teal/blue/ purple/pink returned "invalid color" and rolled back the whole bulk txn. Add teal, blue, purple, pink to validColors. - Add invalidateFacets() and call it on the success path of bulk marks, patchTargets, and single-photo edits so the Colors/Ratings/Notes facet sections refresh immediately instead of waiting out staleTime. Issue 2 — archived photos linger in the grid: - Add a UI-only removedIds set to the bulkAction store; archive/delete/ restore/keep call markRemoved() on success so tiles vanish instantly, cleared once the server-reconcile refetch lands (no cache eviction). Issue 3 — per-photo progress state: - Wire startBulk/doneBulk/failBulk into all metadata applies, bulk (BulkMetadataSidebar) and single (RightSidebar), so colors/ratings/ notes/dates/keywords show the spinner -> check -> X overlay. Issue 4 — remove Left-sidebar count badges: - Drop count badges from root folder, Archive, heaps, Notes, and the folder tree, plus the now-dead count queries and unused imports. Facet drill-panel counts are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
4.3 KiB
TypeScript
115 lines
4.3 KiB
TypeScript
import { toast } from 'svelte-sonner';
|
|
import { batchEdit } from './batch';
|
|
import { getPhoto, updatePhoto, type UpdatePhotoBody } from './photoprism';
|
|
import { queryClient } from '$lib/queryClient';
|
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
|
import type { PpPhoto } from '$lib/types/photoprism';
|
|
|
|
/**
|
|
* Shared helpers for bulk metadata mutations across the timeline. Three call
|
|
* sites: the keyboard culling layer (`gridKeyNav`), the `BulkActionBar` row,
|
|
* and the bulk metadata sidebar shown on multi-select. Each needs the same
|
|
* "fetch full photo body → deep-merge patch → PUT → invalidate" round-trip
|
|
* (PhotoPrism's PUT only persists nested fields when the body is whole), so
|
|
* the wiring lives here to keep the call sites declarative.
|
|
*/
|
|
|
|
/** Fetch the freshest photo body, seeding the per-photo cache. PhotoPrism's
|
|
* PUT needs the full body to persist `Rating` / `Color` / `Details.*`; the
|
|
* cache lookup means the subsequent patch pass reuses this fetch. */
|
|
export async function freshPhoto(uid: string): Promise<PpPhoto> {
|
|
const cached = queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
|
if (cached) return cached;
|
|
const p = await getPhoto(uid);
|
|
queryClient.setQueryData(['photo', uid], p);
|
|
return p;
|
|
}
|
|
|
|
export function invalidatePhotos(uids: string[]): void {
|
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
|
for (const id of uids) {
|
|
void queryClient.invalidateQueries({ queryKey: ['photo', id] });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh the sidebar facet sections after a metadata mutation. The Colors /
|
|
* Ratings panels read `['marks']` + `['photos','marks-pool']`; Notes reads
|
|
* `['photos','with-notes']`; keywords / labels / people read their own keys.
|
|
* Optimistic cache writes keep the active tile in sync, but the facet panels
|
|
* sit on separate queries that otherwise stay stale until their staleTime
|
|
* expires — so call this on the success path of any marks/keyword/note apply.
|
|
*/
|
|
export function invalidateFacets(): void {
|
|
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['photos', 'marks-pool'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['photos', 'with-notes'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['photos', 'keywords'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['labels'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['subjects'] });
|
|
}
|
|
|
|
export function invalidateAllPhotoCaches(): void {
|
|
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['labels'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
|
}
|
|
|
|
/**
|
|
* Apply a patch to every uid. The patch can be a static body or a per-photo
|
|
* function (used by keyword merges which need to read each photo's current
|
|
* Details.Keywords before extending it). When `inverseBuilder` is provided,
|
|
* an undo entry is registered that restores each photo's pre-patch state.
|
|
*/
|
|
export async function patchTargets(
|
|
ids: string[],
|
|
patch: UpdatePhotoBody | ((p: PpPhoto) => UpdatePhotoBody),
|
|
label: string,
|
|
inverseBuilder?: (photo: PpPhoto) => UpdatePhotoBody
|
|
): Promise<void> {
|
|
if (ids.length === 0) return;
|
|
|
|
const inverses = inverseBuilder
|
|
? new Map<string, UpdatePhotoBody>(
|
|
await Promise.all(
|
|
ids.map(async (id) => {
|
|
const p = await freshPhoto(id);
|
|
return [id, inverseBuilder(p)] as const;
|
|
})
|
|
)
|
|
)
|
|
: null;
|
|
|
|
const tid = toast.loading(`${label} · ${ids.length}…`);
|
|
|
|
const { updated, errors } = await batchEdit(ids, async (id) => {
|
|
const p = await freshPhoto(id);
|
|
const body = typeof patch === 'function' ? patch(p) : patch;
|
|
if (Object.keys(body).length === 0) return p;
|
|
return updatePhoto(p, body);
|
|
});
|
|
|
|
invalidatePhotos(ids);
|
|
invalidateFacets();
|
|
|
|
if (errors.length) {
|
|
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid });
|
|
} else {
|
|
toast.success(`${label} · ${ids.length}`, { id: tid });
|
|
}
|
|
|
|
if (inverses) {
|
|
pushUndo(`${label} (${ids.length})`, async () => {
|
|
await batchEdit(ids, async (id) => {
|
|
const p = await freshPhoto(id);
|
|
const inv = inverses.get(id) ?? {};
|
|
if (Object.keys(inv).length === 0) return p;
|
|
return updatePhoto(p, inv);
|
|
});
|
|
invalidatePhotos(ids);
|
|
});
|
|
}
|
|
}
|