feat(web): scope library views to per-user BasePath

PhotoPrism's user entity carries a per-user BasePath; the web app now
mirrors that scope client-side so each user sees only their own subtree
in the sidebar, timeline, folder counts, and heap-convert target picker.
Admin without a BasePath is unchanged. Also removes the redundant
"✕ <folder>" pill below the folder tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:18:13 +02:00
parent bd39d310ab
commit 79a9ef49d4
7 changed files with 151 additions and 46 deletions

View File

@@ -7,6 +7,7 @@
* filter shape (favorites → `favorite:true`, archive → `archived:true`,
* etc.), and the search box on top stacks an additional `q` term.
*/
import { toOriginalsPath, userBasePath } from '$lib/stores/session.svelte';
export type Section =
| 'all-photos'
@@ -93,24 +94,30 @@ export function filtersToQ(f: FilterState = filters): string {
default:
break;
}
// `/` is the root-folder sentinel. PhotoPrism's `path:` operator can't
// express "exact root match" (path:"" / path:/ both fall back to "no
// filter"), so we leave the server query unfiltered and let the
// timeline post-filter to `Path === ''` client-side.
// `path:` clause. `folderPath` stays user-relative throughout the store
// (URLs, sidebar, click handlers); we resolve it to a server-absolute
// path here via `toOriginalsPath`, which prefixes the user's BasePath
// when one is set.
//
// For any non-root folder we want EVERY photo under that subtree, not
// just photos whose `photo_path` is an exact match. PhotoPrism's
// `path:` operator is exact-by-default but supports a trailing `*`
// wildcard:
// path:"2024" → matches only photos directly at `2024/` (none,
// if all files live in date-stamped sub-folders)
// path:"2024*" → matches `2024`, `2024/01`, `2024/02/...`, etc.
// path:"2024/01*" → matches `2024/01` plus descendants — still
// correct for a leaf folder.
// Always append `*` so internal tree nodes return the union of all
// descendant photos and leaves keep returning their direct contents.
if (f.folderPath && f.folderPath !== '/') {
parts.push(`path:${quoteIfNeeded(f.folderPath + '*')}`);
// Cases:
// bp="", folderPath="/" → no `path:` term (whole library)
// bp="", folderPath="2024" → path:"2024*"
// bp="u/a", folderPath="/" → path:"u/a*" (user's root)
// bp="u/a", folderPath="2024" → path:"u/a/2024*"
//
// PhotoPrism's `path:` operator is exact-by-default but accepts a
// trailing `*` wildcard. We always append `*` so internal tree nodes
// return the union of all descendant photos and leaves keep returning
// their direct contents. The pre-BasePath comment about the root
// sentinel still applies for admins without BasePath: `/` collapses
// to "no filter" so the server returns the full library.
const bp = userBasePath();
const isRoot = !f.folderPath || f.folderPath === '/';
if (!(isRoot && bp === '')) {
const serverPath = toOriginalsPath(f.folderPath);
if (serverPath) {
parts.push(`path:${quoteIfNeeded(serverPath + '*')}`);
}
}
if (f.search) parts.push(quoteIfNeeded(f.search));
return parts.join(' ');

View File

@@ -147,3 +147,47 @@ export function videoUrl(hash: string, format = 'avc'): string {
if (!session.previewToken) return '';
return `/api/v1/videos/${hash}/${session.previewToken}/${format}`;
}
/**
* The signed-in user's library root, originals-relative, no leading/trailing
* slash. `""` means "whole library" — used today by admin accounts whose
* BasePath isn't configured in PhotoPrism. Non-empty values gate every place
* that crosses the user↔server seam (sidebar tree, timeline `path:` filter,
* folder counts, heap convert) so each user sees only their own subtree.
*/
export function userBasePath(): string {
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* Translate a user-relative path (what the sidebar and URL deal in) to a
* server-absolute, originals-relative path (what PhotoPrism's `path:`
* operator and the sidecar's filesystem ops want).
*
* "" or "/" → BasePath (user's root)
* "2024/01" → "<basePath>/2024/01"
* null → "" (caller decides to omit the filter entirely)
*/
export function toOriginalsPath(uiPath: string | null): string {
if (uiPath === null) return '';
const bp = userBasePath();
const rel = uiPath.replace(/^\/+|\/+$/g, '');
if (rel === '') return bp;
return bp === '' ? rel : `${bp}/${rel}`;
}
/**
* Inverse of `toOriginalsPath` — strips the user's BasePath prefix so the
* UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
* are equal to the BasePath collapse to `""` (the user's root sentinel).
* Paths outside the BasePath are returned as-is, but callers should
* already have filtered those out via `listFolders`'s post-filter.
*/
export function toUserPath(serverPath: string): string {
const bp = userBasePath();
const sp = serverPath.replace(/^\/+|\/+$/g, '');
if (bp === '') return sp;
if (sp === bp) return '';
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1);
return sp;
}