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:
175
web/src/lib/services/adapters/review.ts
Normal file
175
web/src/lib/services/adapters/review.ts
Normal 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 1–7 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 }];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user