feat: PhotoPrism M0 bring-up — compose stack, web client, sidecar, migrate
Replace the legacy mule-image backend with PhotoPrism plus a thin SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't expose (file rename), and add a two-phase migrator (metadata via PUT, heaps → albums) for the existing Postgres library. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
87
web/src/lib/services/bulk.ts
Normal file
87
web/src/lib/services/bulk.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { updated, errors } = await batchEdit(ids, async (id) => {
|
||||
const p = await freshPhoto(id);
|
||||
const body = typeof patch === 'function' ? patch(p) : patch;
|
||||
// An empty body is a no-op signal — e.g. "keyword already present".
|
||||
if (Object.keys(body).length === 0) return p;
|
||||
return updatePhoto(p, body);
|
||||
});
|
||||
|
||||
invalidatePhotos(ids);
|
||||
|
||||
if (errors.length) {
|
||||
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`);
|
||||
} else {
|
||||
toast.success(`${label} · ${ids.length}`);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user