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

@@ -24,7 +24,7 @@
type PpFolder type PpFolder
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { filters, setSection } from '$lib/stores/filters.svelte'; import { filters, setSection } from '$lib/stores/filters.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte'; import FolderTree, { buildTree } from './FolderTree.svelte';
interface Props { interface Props {
@@ -95,10 +95,13 @@
// pickedPath === '' is the root selection; falsy check would // pickedPath === '' is the root selection; falsy check would
// wrongly block it. Distinguish `null` (nothing picked) from `''`. // wrongly block it. Distinguish `null` (nothing picked) from `''`.
if (!heap || pickedPath === null) return; if (!heap || pickedPath === null) return;
// pickedPath is user-relative (listFolders strips BasePath). The
// sidecar moves files on disk so it needs a server-absolute path —
// translate before submitting.
convertMut.mutate({ convertMut.mutate({
uid: heap.UID, uid: heap.UID,
body: { body: {
targetFolder: pickedPath, targetFolder: toOriginalsPath(pickedPath),
mode, mode,
subfolder: subfolder.trim() || null, subfolder: subfolder.trim() || null,
deleteHeap: mode === 'move' && deleteHeap deleteHeap: mode === 'move' && deleteHeap

View File

@@ -34,7 +34,7 @@
setSection, setSection,
type Section type Section
} from '$lib/stores/filters.svelte'; } from '$lib/stores/filters.svelte';
import { isAuthenticated, session } from '$lib/stores/session.svelte'; import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte'; import FolderTree, { buildTree } from './FolderTree.svelte';
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte'; import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
import HeapConvertDialog from './HeapConvertDialog.svelte'; import HeapConvertDialog from './HeapConvertDialog.svelte';
@@ -158,15 +158,24 @@
})); }));
const folderCounts = $derived(folderCountsQuery.data ?? {}); const folderCounts = $derived(folderCountsQuery.data ?? {});
// Root entry shows the whole library — see applyFolderScope() on / // Root entry shows "the user's library"for admins without a
// timeline. PhotoPrism's `count.all` from /api/v1/config is the // BasePath that's still the whole library, served cheaply from
// authoritative library total (kept in sync server-side), so use it // /api/v1/config's `count.all`. For any user with a non-empty
// directly. Earlier this subtracted Σ(folderCounts) from total, which // BasePath the precomputed total is wrong (it's library-wide), so we
// worked when folderCounts were direct-child only; now that the // ask the sidecar for a recursive count rooted at the user's
// sidecar fan-out recurses (see handlers_folders.go), every photo // BasePath — listFolderCounts maps `""` through toOriginalsPath, which
// gets summed once per ancestor — the subtraction would double-count // resolves to the BasePath itself, and the sidecar fan-out recurses.
// and drive rootCount to 0. const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
const rootCount = $derived(configQuery.data?.count?.all ?? 0); queryKey: ['photos', 'root-count', userBasePath()],
queryFn: () => listFolderCounts(['']),
enabled: isAuthenticated() && userBasePath() !== '',
staleTime: 60_000
}));
const rootCount = $derived(
userBasePath() === ''
? (configQuery.data?.count?.all ?? 0)
: (scopedRootCountQuery.data?.[''] ?? 0)
);
const createMut = createMutation(() => ({ const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title), mutationFn: (title: string) => createHeap(title),
@@ -565,18 +574,6 @@
counts={folderCounts} counts={folderCounts}
/> />
{/if} {/if}
{#if filters.folderPath && filters.folderPath !== '/'}
<button
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}}
title="Clear folder filter"
>
<span class="truncate">{filters.folderPath}</span>
</button>
{/if}
</div> </div>
<!-- Views — everyday browse entries (section + route mixed) under <!-- Views — everyday browse entries (section + route mixed) under

View File

@@ -23,6 +23,7 @@
type PpLogEntry, type PpLogEntry,
type PpSettings type PpSettings
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { userBasePath } from '$lib/stores/session.svelte';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -71,7 +72,16 @@
} }
// ── Index tab ───────────────────────────────────────────────────────── // ── Index tab ─────────────────────────────────────────────────────────
let indexForm = $state<IndexBody>({ path: '/', rescan: false, cleanup: false }); // Default the reindex path to the user's BasePath when scoping is on,
// so non-admins (and admins-with-BasePath) only rescan their own
// subtree. PhotoPrism's /index expects originals-relative paths with
// a leading slash; `'/'` means the whole library.
const _bp = userBasePath();
let indexForm = $state<IndexBody>({
path: _bp === '' ? '/' : `/${_bp}`,
rescan: false,
cleanup: false
});
const startIndexMut = createMutation(() => ({ const startIndexMut = createMutation(() => ({
mutationFn: (b: IndexBody) => startIndex(b), mutationFn: (b: IndexBody) => startIndex(b),
onSuccess: (r) => toast.success(r.message || 'Indexing complete'), onSuccess: (r) => toast.success(r.message || 'Indexing complete'),

View File

@@ -1,7 +1,14 @@
import axios, { AxiosError, type AxiosInstance } from 'axios'; import axios, { AxiosError, type AxiosInstance } from 'axios';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { adoptSession, clearSession, session } from '$lib/stores/session.svelte'; import {
adoptSession,
clearSession,
session,
toOriginalsPath,
toUserPath,
userBasePath
} from '$lib/stores/session.svelte';
import { primaryFile } from '$lib/types/photoprism'; import { primaryFile } from '$lib/types/photoprism';
import type { import type {
PpClientConfig, PpClientConfig,
@@ -325,13 +332,26 @@ export interface PpFolder {
* Recursive list of subfolders under originals/. `uncached=true` because * Recursive list of subfolders under originals/. `uncached=true` because
* PhotoPrism's folder cache lags new folders by a noticeable interval and * PhotoPrism's folder cache lags new folders by a noticeable interval and
* mule-image's folder tree expects to surface mutations immediately. * mule-image's folder tree expects to surface mutations immediately.
*
* Scoped to the signed-in user's `BasePath` on the way out: server-absolute
* `Path` values get rewritten to user-relative (e.g. `users/alice/2024/01`
* → `2024/01`) so every downstream consumer (FolderTree, sidebar, heap
* convert picker) sees folders relative to the user's root. The BasePath
* row itself is dropped — the sidebar synthesises the root entry. When
* BasePath is empty (today's admin default) this is a no-op.
*/ */
export async function listFolders(): Promise<PpFolder[]> { export async function listFolders(): Promise<PpFolder[]> {
const { data } = await http.get<{ folders?: PpFolder[] }>( const { data } = await http.get<{ folders?: PpFolder[] }>(
'/folders/originals', '/folders/originals',
{ params: { recursive: true, uncached: true, files: false } } { params: { recursive: true, uncached: true, files: false } }
); );
return data.folders ?? []; const bp = userBasePath();
const folders = data.folders ?? [];
if (bp === '') return folders;
return folders
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
.map((f) => ({ ...f, Path: toUserPath(f.Path) }))
.filter((f) => f.Path !== '');
} }
/** /**
@@ -340,6 +360,11 @@ export async function listFolders(): Promise<PpFolder[]> {
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as * `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
* `/folders/originals`, but the photo counts come from `X-Files` and * `/folders/originals`, but the photo counts come from `X-Files` and
* `X-Folders` response headers since the body only lists subfolders. * `X-Folders` response headers since the body only lists subfolders.
*
* BasePath does NOT apply: `/folders/import` is a separate root from
* originals (PhotoPrism's `import.path`, not under originals/), so we
* don't filter the result by the signed-in user's BasePath. If/when
* per-user inbox isolation is needed, that's a PhotoPrism-side feature.
*/ */
export interface ImportInfo { export interface ImportInfo {
files: number; files: number;
@@ -385,11 +410,21 @@ export async function getImportInfo(): Promise<ImportInfo> {
*/ */
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> { export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
if (paths.length === 0) return {}; if (paths.length === 0) return {};
const data = (await sidecar('POST', '/folders/counts', { paths })) as Record< // The sidecar walks the real filesystem and queries PhotoPrism with
// server-absolute paths, but callers hand us user-relative paths
// (because that's what `listFolders` returns post-scoping). Translate
// on the way out, then re-key the response back to user-relative on
// the way in so callers' map keys line up with their input array.
const serverPaths = paths.map((p) => toOriginalsPath(p));
const data = (await sidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
string, string,
number number
>; >;
return data; const out: Record<string, number> = {};
for (let i = 0; i < paths.length; i += 1) {
out[paths[i]] = data[serverPaths[i]] ?? 0;
}
return out;
} }
// ── Geo ────────────────────────────────────────────────────────────────────── // ── Geo ──────────────────────────────────────────────────────────────────────

View File

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

View File

@@ -147,3 +147,47 @@ export function videoUrl(hash: string, format = 'avc'): string {
if (!session.previewToken) return ''; if (!session.previewToken) return '';
return `/api/v1/videos/${hash}/${session.previewToken}/${format}`; 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;
}

View File

@@ -11,6 +11,15 @@ export interface PpUser {
DisplayName?: string; DisplayName?: string;
Email?: string; Email?: string;
Role: PpRole; Role: PpRole;
// Per-user library scoping. Originals-relative paths (no leading or
// trailing slash). `BasePath === ""` means the user sees the whole
// library (today's admin default). Non-empty values drive client-side
// scoping of the sidebar tree and timeline `path:` filter so users
// only see their own subtree. PhotoPrism's server-side `acl` filter
// already scopes non-admins to BasePath; the client mirrors that so
// admins-with-a-BasePath behave the same way.
BasePath?: string;
UploadPath?: string;
} }
export interface PpClientConfig { export interface PpClientConfig {