Files
mule-image/web/src/lib/services/adapters/review.ts
dtoro 24dfa996b3 web: de-brand PhotoPrism references in user-facing copy
Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts,
log header, login screen) with neutral terms like "the indexer", "the
library", "the server" — accurate regardless of backend. The login
header becomes "Mulimage" and drops the explicit PhotoPrism mention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00

176 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Adapter for the /review route. Fetches PhotoPrism's review queue and
* groups photos by the most likely reason the indexer flagged them for
* review.
*
* PhotoPrism exposes `Quality` as a single 17 score and the
* `review:true` DSL term filters to `Quality < 3`, but it never tells us
* *why* a given photo scored low. We derive that on the client from the
* other metadata it does return — `TakenSrc`, `CameraID`, `Resolution`,
* `Year`, `Type`. A photo can match multiple causes; the group it lands
* in is determined by the priority order below, while every matching
* cause is shown as a chip on the tile so the user sees the full
* picture.
*/
import { listPhotos } from '$lib/services/photoprism';
import type { PpPhoto } from '$lib/types/photoprism';
export type CauseKey =
| 'low_resolution'
| 'stripped_exif'
| 'implausible_year'
| 'non_image_type'
| 'quality_other';
export interface CauseMeta {
/** Group card header. */
title: string;
/** Short label on a tile chip. */
chip: string;
/** Sentence guiding the bulk decision. */
suggestion: string;
/** Which bulk action the suggestion line should preselect. */
suggestedAction: 'approve' | 'archive' | 'manual';
}
export const CAUSES: Record<CauseKey, CauseMeta> = {
low_resolution: {
title: 'Low resolution',
chip: '< 2 MP',
suggestion:
"Mostly messenger / web-saved images. Archive all if you don't want them in the timeline.",
suggestedAction: 'archive'
},
stripped_exif: {
title: 'EXIF stripped',
chip: 'no EXIF',
suggestion:
"Date and camera info were missing — usually downloaded keepers worth reviewing one-by-one. Open the first to fix the date, then bulk-approve.",
suggestedAction: 'manual'
},
implausible_year: {
title: 'Implausible year',
chip: 'bad year',
suggestion:
"Filenames suggest a date the indexer doesn't trust. Open one to set the real TakenAt, then bulk-approve the rest.",
suggestedAction: 'manual'
},
non_image_type: {
title: 'Animated / vector / scan',
chip: 'special type',
suggestion: 'Decide per item — no obvious bulk move.',
suggestedAction: 'manual'
},
quality_other: {
title: 'Other quality issues',
chip: 'low quality',
suggestion:
'The indexer flagged these but the metadata looks fine. Open the first one to investigate.',
suggestedAction: 'manual'
}
};
/**
* Priority order for picking the primary cause when a photo matches
* several. `low_resolution` ranks first because it's the most actionable
* — under-2 MP photos are almost always non-keepers, regardless of how
* the EXIF looks. `stripped_exif` catches the higher-res survivors so
* the user only sees them as their own group.
*/
const CAUSE_PRIORITY: CauseKey[] = [
'low_resolution',
'stripped_exif',
'implausible_year',
'non_image_type',
'quality_other'
];
/**
* All causes that apply to a photo. Stable order = priority order, so
* the first entry of the returned array is the primary cause.
*/
export function deriveCauses(photo: PpPhoto): CauseKey[] {
const causes: CauseKey[] = [];
const now = new Date().getUTCFullYear();
// Resolution is the integer megapixel count PhotoPrism stored at
// index time; 0 means "couldn't compute" (treat as low). Anything
// under 3 MP is the messenger / web-image bucket.
if (typeof photo.Resolution === 'number' && photo.Resolution < 3) {
causes.push('low_resolution');
}
// Stripped-EXIF lumps the two signals that almost always co-occur:
// no trusted date (TakenSrc empty or guessed from filename) and an
// unknown camera (CameraID 1 is PhotoPrism's "Unknown" sentinel).
// Splitting them produced two heavily-overlapping groups; in
// practice the user wants one decision per group.
if (!photo.TakenSrc || photo.TakenSrc === 'name' || photo.CameraID === 1) {
causes.push('stripped_exif');
}
if (typeof photo.Year === 'number' && photo.Year > 0) {
if (photo.Year < 1900 || photo.Year > now + 1) {
causes.push('implausible_year');
}
}
// Animated GIFs and vector images are perfectly valid but PhotoPrism
// doesn't trust the timeline placement; scanned documents look like
// photos but rarely belong with them.
if (photo.Type === 'animated' || photo.Type === 'vector') {
causes.push('non_image_type');
}
// Catch-all: PhotoPrism flagged it but nothing above explained why.
if (causes.length === 0) {
causes.push('quality_other');
}
// Sort by global priority so the primary cause comes first.
causes.sort((a, b) => CAUSE_PRIORITY.indexOf(a) - CAUSE_PRIORITY.indexOf(b));
return causes;
}
export interface ReviewGroup {
cause: CauseKey;
meta: CauseMeta;
/** All photos whose primary cause is this group's cause. */
photos: PpPhoto[];
}
/**
* Fetch the review queue and bucket every photo into a ReviewGroup by
* its primary cause. Empty buckets are filtered out so the page renders
* only the causes that actually have hits today.
*/
export async function listReviewGroups(): Promise<ReviewGroup[]> {
// `count: 1000` is well above the realistic review-queue size for
// this LXC (290 today). PhotoPrism caps `count` server-side at 1000
// — paginating further would require multiple calls and a merge,
// which we'll bolt on if the queue ever crosses the cap.
const photos = await listPhotos({
q: 'review:true',
count: 1000,
order: 'newest',
merged: true
});
const buckets = new Map<CauseKey, PpPhoto[]>();
for (const p of photos) {
const primary = deriveCauses(p)[0];
const arr = buckets.get(primary) ?? [];
arr.push(p);
buckets.set(primary, arr);
}
// Return in priority order so the cause that matters most ranks
// first on the page, regardless of how big each bucket is.
return CAUSE_PRIORITY.flatMap((cause) => {
const ps = buckets.get(cause);
if (!ps || ps.length === 0) return [];
return [{ cause, meta: CAUSES[cause], photos: ps }];
});
}