feat(settings,index): per-user index sub-path, strip dead PP settings, scope duplicates

Lets a user pick a sub-folder under their library as a working index root,
stored server-side (new sidecar user_prefs table). The Library tree, reindex,
and both duplicate views (stacks + cross-folder scan) now re-root to it via a
single userLibraryBase() helper. Also fixes the cross-folder scan/archive
endpoints, which previously walked/touched the whole originals root instead
of being scoped per-user (archive now rejects out-of-scope paths, 403).

Removes PhotoPrism settings (Search/Maps/Server-UI/Features/Import) that only
steered PhotoPrism's own bundled SPA and were never read by mulimage's UI.

Also fixes the Library tree occasionally getting stuck on "Loading folders…"
by dropping gcTime:0 and gating the spinner on isLoading instead of isPending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 22:41:12 +02:00
parent ba5684d120
commit 634abc2a95
12 changed files with 513 additions and 542 deletions

View File

@@ -57,6 +57,10 @@ export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): voi
// (Hit this with the `test` user seeing the admin's library counts
// in the left sidebar.)
queryClient.clear();
// The index sub-path is per-user; drop the prior identity's value so the
// app re-roots to the new user's whole folder until the ['prefs'] query
// rehydrates it from the sidecar.
prefs.indexSubpath = '';
session.id = resp.id;
session.accessToken = resp.access_token;
session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
@@ -71,6 +75,7 @@ export function clearSession(): void {
session.previewToken = null;
session.downloadToken = null;
session.user = null;
prefs.indexSubpath = '';
if (browser) localStorage.removeItem(STORAGE_KEY);
// Same reasoning as adoptSession — wipe the cache so the next user
// who logs in (or the login screen itself) doesn't render with the
@@ -163,43 +168,73 @@ export function videoUrl(hash: string, format = 'avc'): string {
/**
* 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.
* BasePath isn't configured in PhotoPrism. This is the user's *whole* folder
* as set on their PhotoPrism account; the working library root the rest of
* the app re-roots to is `userLibraryBase()` (BasePath + chosen sub-path).
*/
export function userBasePath(): string {
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* Per-user "index sub-path": a folder *under* the user's BasePath that they've
* chosen as their working library root. Stored server-side by the sidecar
* (keyed by username) and hydrated into this reactive state at startup via the
* `['prefs']` query. Empty string = "whole folder" (no narrowing). Normalized
* to no leading/trailing slash.
*/
export const prefs = $state<{ indexSubpath: string }>({ indexSubpath: '' });
export function setIndexSubpathState(sub: string): void {
prefs.indexSubpath = (sub ?? '').replace(/^\/+|\/+$/g, '');
}
/**
* The effective working library root, originals-relative, no leading/trailing
* slash: the user's BasePath narrowed by their chosen index sub-path. This is
* the single point the whole app re-roots through — `toOriginalsPath` /
* `toUserPath` (and thus the sidebar tree, timeline `path:` filter, folder
* counts, folder CRUD, reindex) all derive from it. When both are empty it's
* `""` (whole library), matching the prior BasePath-only behavior.
*/
export function userLibraryBase(): string {
const bp = userBasePath();
const sub = prefs.indexSubpath;
if (sub === '') return bp;
return bp === '' ? sub : `${bp}/${sub}`;
}
/**
* 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).
* operator and the sidecar's filesystem ops want). Relative to the effective
* library root (`userLibraryBase()`), so the chosen index sub-path is folded
* in automatically.
*
* "" or "/" → BasePath (user's root)
* "2024/01" → "<basePath>/2024/01"
* "" or "/" → libraryBase (user's working root)
* "2024/01" → "<libraryBase>/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 base = userLibraryBase();
const rel = uiPath.replace(/^\/+|\/+$/g, '');
if (rel === '') return bp;
return bp === '' ? rel : `${bp}/${rel}`;
if (rel === '') return base;
return base === '' ? rel : `${base}/${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.
* Inverse of `toOriginalsPath` — strips the effective library-root prefix so
* the UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
* are equal to the root collapse to `""` (the user's root sentinel). Paths
* outside the root 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 base = userLibraryBase();
const sp = serverPath.replace(/^\/+|\/+$/g, '');
if (bp === '') return sp;
if (sp === bp) return '';
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1);
if (base === '') return sp;
if (sp === base) return '';
if (sp.startsWith(base + '/')) return sp.slice(base.length + 1);
return sp;
}