Review view: cause-grouped UI with actions + suggestions

Builds a dedicated /review route that mirrors /duplicates' chrome:
- /review/+page.svelte mounts Toolbar + ReviewView + RightSidebar
- CauseGroupCard.svelte renders one card per cause with Approve all
  and Archive all bulk actions plus a per-cause suggestion line
- CauseBadges.svelte shows every matching cause as chips on each tile
- services/adapters/review.ts fetches review:true and groups photos
  by primary cause; current taxonomy is low_resolution >
  stripped_exif > implausible_year > non_image_type > quality_other
  (low_resolution ranks first because it's the most actionable signal)

Sidebar gains an opt-in showRelated prop that adds three
RelatedStrip panels (same folder / camera / year) for the
'decide these together' workflow.

LeftSidebar's Review entry switches from a section filter to a
route link so /review picks up the click.

PpPhoto gains the missing Resolution field PhotoPrism actually
returns on list responses.
This commit is contained in:
Claudio
2026-05-18 09:03:30 +00:00
parent 9d955d6b94
commit ca9f6e6bd2
9 changed files with 863 additions and 2 deletions

View File

@@ -0,0 +1,175 @@
/**
* 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 PhotoPrism 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:
'PhotoPrism 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 }];
});
}