Sidebar: scope count badges to current user's library
PhotoPrism's /api/v1/config.count is library-wide and the same value for every authenticated session. That made non-admins (and admins with a non-empty BasePath) see badges that didn't match what the timeline actually showed them. Replaces the direct `configQuery.data?.count?.<bucket>` reads in LeftSidebar with per-bucket queries against PhotoPrism's /photos endpoint. The new `countPhotos(q)` helper sets `count=10000` and reads the X-Count response header to get the true total in one round -trip (PhotoPrism's ACL filter is what scopes the result, so the header reflects "what this session can see"). Each bucket query appends `path:"<BasePath>*"` so admins-with-a- BasePath stay scoped too; non-admins without a BasePath short-circuit to `uid:none` (their effective visibility is zero, no point querying). Admins without a BasePath skip the scoped queries entirely and keep using the precomputed /config totals — same network footprint as before for the common case. Affected badges: Favorites, Hidden, Archive, Review, Tags (labels component). Map already used `geoQuery` whose result is ACL-filtered server-side, so its badge is per-user-correct without changes. The `favorites` field was missing from PpClientConfig.count's TypeScript type; added it. Resolves the `test`-user complaint: sidebar showed the admin library's totals next to Review / Hidden / Archive / Favorites because those numbers came from /config, not from a user-scoped query.
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
aggregateKeywords,
|
aggregateKeywords,
|
||||||
|
countPhotos,
|
||||||
createFolder,
|
createFolder,
|
||||||
createHeap,
|
createHeap,
|
||||||
deleteFolder,
|
deleteFolder,
|
||||||
@@ -95,12 +96,61 @@
|
|||||||
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
|
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
|
||||||
// to any authenticated session regardless of role — the timeline
|
// to any authenticated session regardless of role — the timeline
|
||||||
// itself IS scoped per-user, but the precomputed counters aren't.
|
// itself IS scoped per-user, but the precomputed counters aren't.
|
||||||
// Showing those numbers in a non-admin's sidebar is misleading
|
// `isAdminUser` controls the cheap path: an admin without a
|
||||||
// (e.g. the `test` user with role=guest saw the admin's library
|
// BasePath gets the precomputed totals from /config directly. Every
|
||||||
// totals next to Review / Hidden / Archive). Until PhotoPrism gains
|
// other case (non-admin, or admin scoped to a subfolder) goes
|
||||||
// per-user count scoping, just suppress the global-derived badges
|
// through `countPhotos()` which appends a `path:<base>*` filter so
|
||||||
// for anyone who isn't the admin.
|
// the badge matches what the user can actually see.
|
||||||
const isAdminUser = $derived(session.user?.Role === 'admin');
|
const isAdminUser = $derived(session.user?.Role === 'admin');
|
||||||
|
const wantScoped = $derived(!isAdminUser || userBasePath() !== '');
|
||||||
|
|
||||||
|
// Builds a DSL clause that mirrors PhotoPrism's ACL scoping. An
|
||||||
|
// admin with `BasePath === ""` gets a no-op clause and the global
|
||||||
|
// query; everyone else gets a `path:` clause anchored to their
|
||||||
|
// BasePath so unrelated folders never contribute to the badge.
|
||||||
|
// Non-admins with no BasePath have nothing they can see, so we
|
||||||
|
// short-circuit to a query that returns zero (`uid:none`).
|
||||||
|
function scoped(filter: string): string {
|
||||||
|
const bp = userBasePath();
|
||||||
|
if (isAdminUser && bp === '') return filter;
|
||||||
|
if (!isAdminUser && bp === '') return 'uid:none';
|
||||||
|
return `${filter} path:"${bp}*"`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopedCountQuery(key: string, filter: string) {
|
||||||
|
return createQuery<number>(() => ({
|
||||||
|
queryKey: ['photos', 'scoped-count', key, userBasePath(), isAdminUser],
|
||||||
|
queryFn: () => countPhotos(scoped(filter)),
|
||||||
|
enabled: isAuthenticated() && wantScoped,
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One query per badge. Admins with no BasePath skip all of these
|
||||||
|
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
||||||
|
// used directly — same chrome as before that fix, no extra
|
||||||
|
// round-trips.
|
||||||
|
const favoritesCountQuery = scopedCountQuery('favorites', 'favorite:true');
|
||||||
|
const reviewCountQuery = scopedCountQuery('review', 'review:true');
|
||||||
|
const hiddenCountQuery = scopedCountQuery('hidden', 'hidden:true');
|
||||||
|
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
||||||
|
const labelsCountQuery = scopedCountQuery('labels', 'all:true label:*');
|
||||||
|
|
||||||
|
function bucketCount(
|
||||||
|
key: 'favorites' | 'review' | 'hidden' | 'archived' | 'labels',
|
||||||
|
query: { data: number | undefined; isPending: boolean }
|
||||||
|
): number | undefined {
|
||||||
|
if (wantScoped) {
|
||||||
|
if (query.isPending) return undefined;
|
||||||
|
return query.data;
|
||||||
|
}
|
||||||
|
// Admin + no BasePath: use the precomputed PhotoPrism counters
|
||||||
|
// (no extra round-trip).
|
||||||
|
const c = configQuery.data?.count;
|
||||||
|
if (!c) return undefined;
|
||||||
|
if (key === 'labels') return c.labels;
|
||||||
|
return c[key];
|
||||||
|
}
|
||||||
|
|
||||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||||
queryKey: ['marks'],
|
queryKey: ['marks'],
|
||||||
@@ -229,6 +279,15 @@
|
|||||||
: (scopedRootCountQuery.data?.[''] ?? 0)
|
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Favorites / Review / Hidden / Archive nav entries use these
|
||||||
|
// derived values rather than peeking at configQuery directly so the
|
||||||
|
// scoped path is invisible to the views[]/manageViews[] declarations.
|
||||||
|
const favoritesBadge = $derived(bucketCount('favorites', favoritesCountQuery));
|
||||||
|
const reviewBadge = $derived(bucketCount('review', reviewCountQuery));
|
||||||
|
const hiddenBadge = $derived(bucketCount('hidden', hiddenCountQuery));
|
||||||
|
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
||||||
|
const labelsBadge = $derived(bucketCount('labels', labelsCountQuery));
|
||||||
|
|
||||||
const createMut = createMutation(() => ({
|
const createMut = createMutation(() => ({
|
||||||
mutationFn: (title: string) => createHeap(title),
|
mutationFn: (title: string) => createHeap(title),
|
||||||
onSuccess: (h) => {
|
onSuccess: (h) => {
|
||||||
@@ -437,23 +496,23 @@
|
|||||||
// separate "everything regardless of folder" destination would just
|
// separate "everything regardless of folder" destination would just
|
||||||
// duplicate it for users whose photos live under the root.
|
// duplicate it for users whose photos live under the root.
|
||||||
const views: ViewItem[] = [
|
const views: ViewItem[] = [
|
||||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => (isAdminUser ? geoQuery.data?.features?.length : undefined) },
|
// Map's `geoQuery` already returns the GeoJSON the user is
|
||||||
// Tags hosts four tabs (Labels (auto) / Keywords / Ratings / Colors);
|
// permitted to see (PhotoPrism's /geo applies the session ACL),
|
||||||
// the badge sums each tab's badge so the sidebar number is the
|
// so the badge is per-user-correct without extra scoping.
|
||||||
// total of what the inner tabs show. Keywords is lazy — it only
|
{ kind: 'route', href: '/map', label: 'Map', getCount: () => geoQuery.data?.features?.length },
|
||||||
// contributes after /tags?tab=keywords has been visited once.
|
// Tags rolls up labels + keywords + ratings + colors. Labels
|
||||||
// Suppressed for non-admins because every contributing query is
|
// flows through countPhotos (scoped); keywords/ratings/colors are
|
||||||
// library-wide rather than per-user.
|
// from library-wide marks tables and only contribute when we're
|
||||||
|
// in admin-without-BasePath mode (their sources don't scope).
|
||||||
{
|
{
|
||||||
kind: 'route',
|
kind: 'route',
|
||||||
href: '/tags',
|
href: '/tags',
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
getCount: () => {
|
getCount: () => {
|
||||||
if (!isAdminUser) return undefined;
|
if (labelsBadge === undefined) return undefined;
|
||||||
const labels = configQuery.data?.count?.labels;
|
if (wantScoped) return labelsBadge;
|
||||||
if (labels === undefined) return undefined;
|
|
||||||
const keywords = keywordsQuery.data?.length ?? 0;
|
const keywords = keywordsQuery.data?.length ?? 0;
|
||||||
return labels + keywords + ratingsCount + colorsCount;
|
return labelsBadge + keywords + ratingsCount + colorsCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -464,14 +523,15 @@
|
|||||||
href: '/review',
|
href: '/review',
|
||||||
label: 'Review',
|
label: 'Review',
|
||||||
getCount: () => {
|
getCount: () => {
|
||||||
if (!isAdminUser) return undefined;
|
if (reviewBadge === undefined) return undefined;
|
||||||
const review = configQuery.data?.count?.review;
|
// The two duplicates queries are library-wide; only admins
|
||||||
if (review === undefined) return undefined;
|
// without a BasePath roll them into the Review badge.
|
||||||
return review + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
|
if (wantScoped) return reviewBadge;
|
||||||
|
return reviewBadge + (stacksQuery.data?.length ?? 0) + (crossFolderQuery.data?.groups.length ?? 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => (isAdminUser ? configQuery.data?.count?.hidden : undefined) },
|
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => hiddenBadge },
|
||||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => (isAdminUser ? configQuery.data?.count?.archived : undefined) }
|
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
|
||||||
];
|
];
|
||||||
|
|
||||||
function isRouteActive(href: string): boolean {
|
function isRouteActive(href: string): boolean {
|
||||||
|
|||||||
@@ -155,6 +155,28 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count photos matching a DSL query, scoped to whatever the caller's
|
||||||
|
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
|
||||||
|
* only" endpoint, but the `X-Count` header on `/photos` returns the
|
||||||
|
* page size — which equals the total when `count` is set above the
|
||||||
|
* library size. 10000 is generously above the realistic per-user
|
||||||
|
* library and well under PhotoPrism's server-side ceiling, so a single
|
||||||
|
* round-trip yields the true total without paginating.
|
||||||
|
*
|
||||||
|
* Used by the LeftSidebar to render bucket badges that reflect what
|
||||||
|
* the signed-in user actually sees, not the global library aggregate
|
||||||
|
* exposed by `/config.count`.
|
||||||
|
*/
|
||||||
|
export async function countPhotos(q: string): Promise<number> {
|
||||||
|
const resp = await http.get('/photos', {
|
||||||
|
params: { count: 10000, offset: 0, merged: false, q }
|
||||||
|
});
|
||||||
|
const header = resp.headers['x-count'];
|
||||||
|
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPhoto(uid: string): Promise<PpPhoto> {
|
export async function getPhoto(uid: string): Promise<PpPhoto> {
|
||||||
const { data } = await http.get<PpPhoto>(`/photos/${uid}`);
|
const { data } = await http.get<PpPhoto>(`/photos/${uid}`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface PpClientConfig {
|
|||||||
archived?: number;
|
archived?: number;
|
||||||
hidden?: number;
|
hidden?: number;
|
||||||
review?: number;
|
review?: number;
|
||||||
|
favorites?: number;
|
||||||
albums?: number;
|
albums?: number;
|
||||||
moments?: number;
|
moments?: number;
|
||||||
months?: number;
|
months?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user