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:
@@ -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(' ');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user