From 634abc2a958944d4acab0d08de5086e722fba0fd Mon Sep 17 00:00:00 2001 From: dtoro Date: Tue, 30 Jun 2026 22:41:12 +0200 Subject: [PATCH] feat(settings,index): per-user index sub-path, strip dead PP settings, scope duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sidecar/db.go | 17 +- sidecar/handlers_dups.go | 51 ++- sidecar/handlers_prefs.go | 117 +++++++ sidecar/main.go | 7 +- .../duplicates/DuplicatesView.svelte | 5 +- .../layout/GeneralSettingsDialog.svelte | 305 +----------------- .../lib/components/layout/LeftSidebar.svelte | 63 +++- .../layout/MoveToFolderDialog.svelte | 4 +- .../components/layout/SettingsDialog.svelte | 266 +++++++-------- web/src/lib/services/photoprism.ts | 138 ++++---- web/src/lib/stores/session.svelte.ts | 71 ++-- web/src/routes/review/+page.svelte | 11 +- 12 files changed, 513 insertions(+), 542 deletions(-) create mode 100644 sidecar/handlers_prefs.go diff --git a/sidecar/db.go b/sidecar/db.go index 67eba69..a970de6 100644 --- a/sidecar/db.go +++ b/sidecar/db.go @@ -26,6 +26,21 @@ type Mark struct { // nothing surprising lands in the schema. func (Mark) TableName() string { return "marks" } +// UserPref holds the per-user, server-side preferences PhotoPrism's account +// model has no slot for. Today that's just `IndexPath` — the originals- +// relative sub-folder (under the user's BasePath) the web client re-roots the +// Library tree to and scopes the reindex to. Empty string = "whole folder". +// Keyed by username so each user has independent prefs, matching `Mark`. +type UserPref struct { + UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"` + IndexPath string `gorm:"size:1024;column:index_path" json:"indexPath"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"-"` +} + +// TableName pins the table name (GORM would pluralise to `user_prefs` anyway, +// but pin it explicitly to stay consistent with Mark). +func (UserPref) TableName() string { return "user_prefs" } + // asJSON returns the wire shape clients expect — same flat object the // Node prototype emitted. An empty Mark (rating=nil, color=nil) renders // as `{}` which the client treats as "no mark on this photo". @@ -56,7 +71,7 @@ func openDB(dsn string) (*gorm.DB, error) { if err != nil { return nil, err } - if err := db.AutoMigrate(&Mark{}); err != nil { + if err := db.AutoMigrate(&Mark{}, &UserPref{}); err != nil { return nil, err } return db, nil diff --git a/sidecar/handlers_dups.go b/sidecar/handlers_dups.go index de62a75..0341afa 100644 --- a/sidecar/handlers_dups.go +++ b/sidecar/handlers_dups.go @@ -9,10 +9,12 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) const quarantineDir = ".duplicates" @@ -36,17 +38,41 @@ type dupListPhoto struct { Files []ppFile `json:"Files"` } -func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc { +func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) start := time.Now() - slog.Info("dup.scan starting", "root", cfg.OriginalsRoot) - all, err := walkFiles(cfg.OriginalsRoot) + // Scope the walk to the user's effective library root (BasePath + + // chosen index sub-path), same as the folders/timeline/reindex scope — + // otherwise a narrowed root would still surface every other user's + // files in the cross-folder duplicate scan. "" means whole library + // (today's admin-without-BasePath default). + root := effectiveLibraryRoot(c, db) + scanRoot := cfg.OriginalsRoot + if root != "" { + abs, err := resolveUnderRoot(cfg.OriginalsRoot, root, true) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid library root"}) + return + } + scanRoot = abs + } + slog.Info("dup.scan starting", "root", scanRoot) + + all, err := walkFiles(scanRoot) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + // walkFiles computes RelPath relative to scanRoot; re-prefix with the + // scoped sub-path so RelPath stays originals-root-relative, matching + // what handleDupArchive (and the rest of the API) expects. + if root != "" { + for i := range all { + all[i].RelPath = root + "/" + all[i].RelPath + } + } // Group by size first: byte-identical files necessarily share size, // so size-collision is a cheap O(N) prefilter that lets us skip @@ -149,7 +175,7 @@ type dupArchiveErr struct { Error string `json:"error"` } -func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc { +func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) var body dupArchiveBody @@ -158,6 +184,23 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc { return } + // Authz: every path must live under the caller's effective library + // root. The scan above already only ever returns paths from there, + // but this endpoint takes paths straight from the request body, so a + // scoped (non-admin, or admin-with-sub-path) user could otherwise + // pass an arbitrary originals-relative path and archive (move) files + // outside their own folder. + root := effectiveLibraryRoot(c, db) + if root != "" { + for _, p := range body.Paths { + clean := strings.Trim(p, "/") + if clean != root && !strings.HasPrefix(clean, root+"/") { + c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"}) + return + } + } + } + // Each archive batch lands in its own timestamped subdir so the // user can browse what was quarantined when (and recover by hand // if they change their mind). diff --git a/sidecar/handlers_prefs.go b/sidecar/handlers_prefs.go new file mode 100644 index 0000000..59bca34 --- /dev/null +++ b/sidecar/handlers_prefs.go @@ -0,0 +1,117 @@ +package main + +import ( + "errors" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// Per-user preferences the PhotoPrism account model can't hold. Currently a +// single field — the index sub-path the web client re-roots the Library tree +// to and scopes the reindex to. Stored in the sidecar's own DB keyed by +// username (see UserPref in db.go); never touches PhotoPrism's auth_users. + +// prefsBody is the wire shape for GET responses and PUT requests alike. +type prefsBody struct { + IndexPath string `json:"indexPath"` +} + +// loadUserPref reads the row for a user, returning a zero-value pref (empty +// IndexPath) when none exists yet — the "whole folder" default. +func loadUserPref(db *gorm.DB, userName string) (UserPref, error) { + var p UserPref + err := db.Where("user_name = ?", userName).First(&p).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return UserPref{UserName: userName}, nil + } + return p, err +} + +func handlePrefsGet(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + p, err := loadUserPref(db, ctxUserName(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, prefsBody{IndexPath: p.IndexPath}) + } +} + +// handlePrefsPut validates the requested index sub-path lives under the user's +// BasePath (an existing directory, no traversal) and upserts it. An empty +// string clears the sub-path back to "whole folder". +func handlePrefsPut(cfg *Config, db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var body prefsBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + + // Normalise to originals-relative, no leading/trailing slashes — + // the same shape the web client and auth_users.base_path use. + sub := strings.Trim(strings.TrimSpace(body.IndexPath), "/") + + if sub != "" { + // The sub-path is relative to the user's BasePath; resolve the + // combined originals-relative path and require it to be an + // existing directory inside the originals root. resolveUnderRoot + // already rejects traversal and symlink escapes. + base := strings.Trim(ctxBasePath(c), "/") + combined := sub + if base != "" { + combined = base + "/" + sub + } + abs, err := resolveUnderRoot(cfg.OriginalsRoot, combined, true) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index path: " + err.Error()}) + return + } + info, err := os.Stat(abs) + if err != nil || !info.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "index path is not a folder"}) + return + } + } + + userName := ctxUserName(c) + p := UserPref{UserName: userName, IndexPath: sub, UpdatedAt: time.Now().UTC()} + // Upsert: a clear (sub == "") persists an empty string rather than + // deleting the row, so the GET path stays a single code branch. + if err := db.Save(&p).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, prefsBody{IndexPath: sub}) + } +} + +// effectiveLibraryRoot returns the requesting user's working library root, +// originals-relative with no leading/trailing slash: their BasePath narrowed +// by their chosen index sub-path (if any). Mirrors the web client's +// `userLibraryBase()` — handlers that walk the filesystem on a user's behalf +// (duplicate scan/archive) should scope to this instead of cfg.OriginalsRoot +// so a narrowed root also narrows what those handlers can see or touch. +// Returns "" for "whole library" (no BasePath and no sub-path set — today's +// admin default). +func effectiveLibraryRoot(c *gin.Context, db *gorm.DB) string { + base := strings.Trim(ctxBasePath(c), "/") + pref, err := loadUserPref(db, ctxUserName(c)) + sub := "" + if err == nil { + sub = strings.Trim(pref.IndexPath, "/") + } + if sub == "" { + return base + } + if base == "" { + return sub + } + return base + "/" + sub +} diff --git a/sidecar/main.go b/sidecar/main.go index c0acb91..4c3a0b3 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -81,6 +81,9 @@ func main() { // under one group keeps the middleware wiring obvious. auth := r.Group("/api/sidecar", requireSession(pp)) { + auth.GET("/prefs", handlePrefsGet(db)) + auth.PUT("/prefs", handlePrefsPut(cfg, db)) + auth.GET("/photos/marks", handleMarksAll(db)) auth.GET("/photos/:uid/marks", handleMarkGet(db)) auth.PUT("/photos/:uid/marks", handleMarkPut(db)) @@ -97,8 +100,8 @@ func main() { auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) auth.POST("/photos/move", handlePhotosMove(cfg, pp)) - auth.GET("/duplicates/scan", handleDupScan(cfg, pp)) - auth.POST("/duplicates/archive", handleDupArchive(cfg, pp)) + auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db)) + auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db)) // User-scoped proxies — require PpDSN connection. if ppDb != nil { diff --git a/web/src/lib/components/duplicates/DuplicatesView.svelte b/web/src/lib/components/duplicates/DuplicatesView.svelte index bdd7c7c..9ef7b49 100644 --- a/web/src/lib/components/duplicates/DuplicatesView.svelte +++ b/web/src/lib/components/duplicates/DuplicatesView.svelte @@ -28,6 +28,7 @@ type CrossFolderScanResult } from '$lib/services/photoprism'; import type { DuplicateGroup } from '$lib/services/adapters/duplicates'; + import { userLibraryBase } from '$lib/stores/session.svelte'; import StackGroupCard from './StackGroupCard.svelte'; import CrossFolderGroupCard from './CrossFolderGroupCard.svelte'; import { EmptyState, InlineLoader } from '$lib/components/feedback'; @@ -50,14 +51,14 @@ // "Rescan filesystem" button invalidates to force a re-scan after // the user has moved files around. const crossQuery = createQuery(() => ({ - queryKey: ['duplicates-cross-folder'], + queryKey: ['duplicates-cross-folder', userLibraryBase()], queryFn: scanCrossFolderDuplicates, enabled: activeTab === 'cross-folder', staleTime: 5 * 60_000 })); function rescan() { - void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] }); + void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder', userLibraryBase()] }); } $effect(() => { diff --git a/web/src/lib/components/layout/GeneralSettingsDialog.svelte b/web/src/lib/components/layout/GeneralSettingsDialog.svelte index 3d57b7a..b66d0ea 100644 --- a/web/src/lib/components/layout/GeneralSettingsDialog.svelte +++ b/web/src/lib/components/layout/GeneralSettingsDialog.svelte @@ -1,24 +1,20 @@ @@ -198,7 +91,7 @@ - {#each ['ui', 'search', 'maps', 'account'] as const as t (t)} + {#each ['ui', 'account'] as const as t (t)} - +

@@ -239,126 +131,10 @@ Light/dark for this app. Persists locally; no Save needed.

- - {#if settingsQuery.isPending} -

Loading server settings…

- {:else if settingsQuery.isError} -

Could not load server settings.

- {:else if draft} -
-

- Server UI -

- - - - - - -
- {/if}
- {#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'} - -

Loading settings…

-
- {:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'} - -

- Could not load settings. -

-
- {:else if draft} - - - - - - - - - - - - {/if} - - +

@@ -448,59 +224,6 @@ - - - - - {#each tzOptions() as tz (tz)}{/each} - - - - {#if draft && activeTab !== 'account'} -
- - -
- {/if} - - diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 4179646..ec84b42 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -12,6 +12,7 @@ deleteFolder, deleteHeap, duplicateHeap, + getIndexSubpath, heapDownloadUrl, listFolders, listHeaps, @@ -45,8 +46,11 @@ } from '$lib/stores/filters.svelte'; import { isAuthenticated, + prefs, session, + setIndexSubpathState, userBasePath, + userLibraryBase, toOriginalsPath, toUserPath } from '$lib/stores/session.svelte'; @@ -84,25 +88,48 @@ enabled: isAuthenticated() })); + // Keyed on the effective library base (BasePath + chosen index sub-path) + // so re-rooting refetches, and so the post-bootstrap identity change forces + // a fresh fetch instead of leaving the query wedged in pending/idle (the + // old `gcTime: 0` + `enabled` toggle could strand it there on first paint). const foldersQuery = createQuery(() => ({ - queryKey: ['folders'], + queryKey: ['folders', userLibraryBase()], queryFn: listFolders, enabled: isAuthenticated(), - gcTime: 0 + staleTime: 30_000, + retry: 2, + refetchOnMount: 'always' })); + // Hydrate the per-user index sub-path into the session store on load so the + // Library tree re-roots to it without waiting for the settings dialog to be + // opened. Shares the ['prefs'] key with SettingsDialog's setter. + const prefsQuery = createQuery(() => ({ + queryKey: ['prefs'], + queryFn: getIndexSubpath, + enabled: isAuthenticated(), + staleTime: 5 * 60_000 + })); + $effect(() => { + if (prefsQuery.data !== undefined) setIndexSubpathState(prefsQuery.data); + }); + // Stacks + cross-folder duplicate caches are warmed here so the // /duplicates view (and its review tab strip) hits a warm cache. The // sidebar only observes these — cross-folder is an O(disk) scan, so it // stays enabled:false and the duplicates page populates it on first visit. const stacksQuery = createQuery(() => ({ - queryKey: ['duplicates', userBasePath()], - queryFn: () => listDuplicateGroups(userBasePath()), + queryKey: ['duplicates', userLibraryBase()], + queryFn: () => listDuplicateGroups(userLibraryBase()), enabled: isAuthenticated(), staleTime: 60_000 })); + // The cross-folder scan is server-scoped to the caller's effective + // library root (sidecar reads BasePath + the stored index sub-path + // itself), but the query is still keyed on userLibraryBase() so changing + // the index folder invalidates the stale, differently-scoped result. const crossFolderQuery = createQuery(() => ({ - queryKey: ['duplicates-cross-folder'], + queryKey: ['duplicates-cross-folder', userLibraryBase()], queryFn: scanCrossFolderDuplicates, enabled: false, staleTime: 5 * 60_000 @@ -244,6 +271,17 @@ session.user?.DisplayName?.trim() || session.user?.Name || '/' ); + // When the user has narrowed their library to an index sub-folder, the + // root row stands for that sub-folder — surface its leaf name so it's + // obvious the tree is re-rooted rather than showing the whole account. + const rootSubLabel = $derived( + prefs.indexSubpath === '' ? '' : (prefs.indexSubpath.split('/').pop() ?? '') + ); + const rootTitle = $derived.by(() => { + const base = userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`; + return prefs.indexSubpath === '' ? base : `${base} → ${prefs.indexSubpath}`; + }); + async function onSignOut() { await logout(); await goto('/login', { replaceState: true }); @@ -320,7 +358,9 @@ if (indexer.active) return; const tid = toast.loading('Starting reindex…'); try { - await startIndex({ path: '/', rescan: false, cleanup: false }); + // Scope the one-click reindex to the effective library root + // (BasePath + chosen index sub-path) rather than the whole library. + await startIndex({ path: '/' + toOriginalsPath('/'), rescan: false, cleanup: false }); toast.success('Reindex started — new files will appear as they’re found', { id: tid }); } catch (err) { toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid }); @@ -550,11 +590,16 @@ {/if} - + + +
+

+ Index folder +

+

+ Pick the sub-folder PhotoPrism should treat as your library + root. The folder tree re-roots here and the reindex only scans + this subtree. Leave on “Whole folder” to use everything. +

+
+
+ {#if subpathFoldersQuery.isPending} + + {:else if subpathFoldersQuery.isError} + + {:else} + + + {#if (subpathFoldersQuery.data ?? []).length > 0} + (pickedSubpath = p)} + selectedPath={pickedSubpath} + readonly + /> + {/if} + {/if} +
+
+
+ + Current: {prefs.indexSubpath === '' ? 'Whole folder' : prefs.indexSubpath} + + +
+
+ +
+ {#if settingsQuery.isPending}

Loading settings…

{:else if settingsQuery.isError} @@ -284,25 +391,6 @@

-
-

- Importer defaults -

- - -
-

Stacks @@ -411,34 +499,6 @@

{/if} - - {#if draft.features && Object.keys(draft.features).length > 0} -
-

- Features -

-

- Toggling a feature off hides it from PhotoPrism's own - UI and disables the underlying API surface. -

-
- {#each Object.keys(draft.features).sort() as key (key)} - {#if typeof draft.features![key] === 'boolean'} - - {/if} - {/each} -
-
- {/if} -
@@ -511,58 +571,6 @@
- - -

- Pulls files from the import folder into the library. With "move" - enabled, files are deleted from the import folder after a - successful import. -

- - - -
- - -
-
- {#if configQuery.isPending} diff --git a/web/src/lib/services/photoprism.ts b/web/src/lib/services/photoprism.ts index d8eb382..f61a52a 100644 --- a/web/src/lib/services/photoprism.ts +++ b/web/src/lib/services/photoprism.ts @@ -7,7 +7,8 @@ import { session, toOriginalsPath, toUserPath, - userBasePath + userBasePath, + userLibraryBase } from '$lib/stores/session.svelte'; import { primaryFile } from '$lib/types/photoprism'; import type { @@ -503,23 +504,45 @@ export interface PpFolder { * 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 { +async function fetchFolders(): Promise { const { data } = await sidecar.get<{ folders?: PpFolder[] }>( '/api/sidecar/folders', { params: { recursive: true, uncached: true, files: false } } ); - const bp = userBasePath(); - // Sidecar already filters by BasePath; the frontend still applies the - // filter + path rewrite as a safety net for admin (bp="") and for any - // folders that might have slipped through. - const folders = data.folders ?? []; - if (bp === '') return folders; + return data.folders ?? []; +} + +/** + * Filter a flat folder list to those at/under `base` (server-absolute, + * originals-relative) and rewrite each `Path` to be `base`-relative, dropping + * the `base` row itself. `base === ''` (whole library) is a no-op. Sidecar + * already filters by BasePath; this is the frontend's safety net + the + * narrowing to the chosen index sub-path. + */ +function scopeFolders(folders: PpFolder[], base: string): PpFolder[] { + if (base === '') return folders; return folders - .filter((f) => f.Path === bp || f.Path.startsWith(bp + '/')) - .map((f) => ({ ...f, Path: toUserPath(f.Path) })) + .filter((f) => f.Path === base || f.Path.startsWith(base + '/')) + .map((f) => ({ ...f, Path: f.Path === base ? '' : f.Path.slice(base.length + 1) })) .filter((f) => f.Path !== ''); } +export async function listFolders(): Promise { + // Scoped to the *effective* library root (BasePath + chosen index + // sub-path) so the sidebar tree re-roots to whatever the user picked. + return scopeFolders(await fetchFolders(), userLibraryBase()); +} + +/** + * Like `listFolders` but scoped to the user's *whole* BasePath, ignoring the + * chosen index sub-path. The index-folder picker uses this so the user can + * choose any sub-folder of their library as a new root — including ones + * outside the current sub-path. + */ +export async function listFoldersUnderBase(): Promise { + return scopeFolders(await fetchFolders(), userBasePath()); +} + /** * Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders` * endpoint reports `FileCount: 0` even when populated, so the count has @@ -1077,26 +1100,17 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise { + const data = (await callSidecar('GET', '/prefs')) as { indexPath?: string }; + return (data.indexPath ?? '').replace(/^\/+|\/+$/g, ''); +} + +export async function setIndexSubpath(indexPath: string): Promise { + const data = (await callSidecar('PUT', '/prefs', { + indexPath: indexPath.replace(/^\/+|\/+$/g, '') + })) as { indexPath?: string }; + return (data.indexPath ?? '').replace(/^\/+|\/+$/g, ''); +} + export async function getSettings(): Promise { const { data } = await http.get('/settings'); return data; @@ -1183,26 +1181,6 @@ export async function cancelIndex(): Promise { await http.delete('/index'); } -export interface ImportBody { - path?: string; - move?: boolean; - dest?: string; -} - -export async function startImport(body: ImportBody = {}): Promise<{ message: string }> { - const { data } = await http.post<{ message: string }>('/import', { - path: '/', - move: false, - dest: '', - ...body - }); - return data; -} - -export async function cancelImport(): Promise { - await http.delete('/import'); -} - export interface PpLogEntry { Time: string; Level: string; diff --git a/web/src/lib/stores/session.svelte.ts b/web/src/lib/stores/session.svelte.ts index 5ae3b12..c7386b0 100644 --- a/web/src/lib/stores/session.svelte.ts +++ b/web/src/lib/stores/session.svelte.ts @@ -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" → "/2024/01" + * "" or "/" → libraryBase (user's working root) + * "2024/01" → "/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; } diff --git a/web/src/routes/review/+page.svelte b/web/src/routes/review/+page.svelte index 0f7c755..828168e 100644 --- a/web/src/routes/review/+page.svelte +++ b/web/src/routes/review/+page.svelte @@ -30,7 +30,7 @@ scanCrossFolderDuplicates, type CrossFolderScanResult } from '$lib/services/photoprism'; - import { isAuthenticated, userBasePath } from '$lib/stores/session.svelte'; + import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte'; import { clearSelection, selection } from '$lib/stores/selection.svelte'; import { filters, setSection, type Section } from '$lib/stores/filters.svelte'; import { @@ -85,13 +85,16 @@ // observes its cache (enabled:false) and DuplicatesView is what // triggers the actual scan when its tab is active. const stacksQuery = createQuery(() => ({ - queryKey: ['duplicates', userBasePath()], - queryFn: () => listDuplicateGroups(userBasePath()), + queryKey: ['duplicates', userLibraryBase()], + queryFn: () => listDuplicateGroups(userLibraryBase()), enabled: isAuthenticated(), staleTime: 30_000 })); + // Scope is enforced server-side (sidecar reads the caller's BasePath + + // stored index sub-path), but key on userLibraryBase() so switching the + // index folder doesn't show a stale, differently-scoped cached result. const crossFolderQuery = createQuery(() => ({ - queryKey: ['duplicates-cross-folder'], + queryKey: ['duplicates-cross-folder', userLibraryBase()], queryFn: scanCrossFolderDuplicates, enabled: false, staleTime: 5 * 60_000