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}
-
-
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.
+
- 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