feat(web): /tags tabs + keyword surfacing + dup tab restyle
- /tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors), URL-driven with pagination on the label + keyword grids; ratings and colors stay as fixed buckets. - /duplicates tabs (Stacks / Cross-folder) restyled to pill row in the Toolbar to match /tags; tab state moved into the route and bound to ?tab=... - New aggregateKeywords() service fans out per-photo getPhoto calls so user-typed Details.Keywords surface on /tags (PhotoPrism's /labels only returns classifier output). - RightSidebar renders photo.Labels[] as dashed-border chips after the Keywords section, each linking to /?q=label:slug. - /colors and /ratings routes redirect to /tags?tab=colors|ratings so old bookmarks still land somewhere useful; LeftSidebar drops their entries and the Tags badge now sums labels + ratings + colors. - listFolderCounts dedupes by UID (merged=false returns one row per FILE, so HEIC+JPG / Live Photo / RAW+JPG pairs were inflating folder counts ~2x). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from 'axios';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte';
|
||||
import { primaryFile } from '$lib/types/photoprism';
|
||||
import type {
|
||||
PpClientConfig,
|
||||
PpPhoto,
|
||||
@@ -385,6 +386,62 @@ export interface PpLabel {
|
||||
Thumb?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-set keyword aggregation. PhotoPrism's `/labels` endpoint only
|
||||
* surfaces classifier-derived labels; user-typed keywords live in
|
||||
* `Details.Keywords` (a flat comma-separated string) which is *not*
|
||||
* included in the `/photos` list response. The only path to it is the
|
||||
* single-photo endpoint, so we fetch the library top-end (capped at
|
||||
* PhotoPrism's 1000-row ceiling), fan out `getPhoto` calls in batches,
|
||||
* and aggregate.
|
||||
*
|
||||
* Slow but cached upstream via TanStack — keys under the `['photos', …]`
|
||||
* prefix so the existing photo-mutation invalidations cascade to it.
|
||||
*/
|
||||
export interface AggregatedKeyword {
|
||||
keyword: string;
|
||||
count: number;
|
||||
/** UID of an arbitrary photo carrying this keyword — used as the
|
||||
* thumbnail source so the tile is visually consistent with
|
||||
* classifier-label tiles. */
|
||||
sampleUid: string;
|
||||
sampleHash: string;
|
||||
}
|
||||
|
||||
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
||||
const list = await listPhotos({ count: 1000, merged: true });
|
||||
const buckets = new Map<string, AggregatedKeyword>();
|
||||
const concurrency = 8;
|
||||
for (let i = 0; i < list.length; i += concurrency) {
|
||||
const slice = list.slice(i, i + concurrency);
|
||||
const fulls = await Promise.all(
|
||||
slice.map((p) => getPhoto(p.UID).catch(() => null))
|
||||
);
|
||||
for (let j = 0; j < slice.length; j++) {
|
||||
const photo = slice[j];
|
||||
const full = fulls[j];
|
||||
if (!full) continue;
|
||||
const raw = full.Details?.Keywords ?? '';
|
||||
if (!raw) continue;
|
||||
for (const kw of raw.split(',').map((k) => k.trim()).filter(Boolean)) {
|
||||
const bucket = buckets.get(kw);
|
||||
if (bucket) {
|
||||
bucket.count++;
|
||||
continue;
|
||||
}
|
||||
const hash = photo.Hash ?? primaryFile(photo).Hash;
|
||||
buckets.set(kw, {
|
||||
keyword: kw,
|
||||
count: 1,
|
||||
sampleUid: photo.UID,
|
||||
sampleHash: hash
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export async function listLabels(): Promise<PpLabel[]> {
|
||||
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||
// low-confidence classifier hits, manually-removed labels). They're
|
||||
|
||||
Reference in New Issue
Block a user