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

@@ -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<PpFolder[]> {
async function fetchFolders(): Promise<PpFolder[]> {
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<PpFolder[]> {
// 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<PpFolder[]> {
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<R
// ── Settings / Admin ─────────────────────────────────────────────────────────
//
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
// (Library / Index / Import / Logs). Shapes are deliberately partial — newer
// (Library / Index / Logs). Shapes are deliberately partial — newer
// PhotoPrism versions ship extra fields we don't render, and the POST endpoint
// merges server-side, so it's safe to round-trip an incomplete object.
// PhotoPrism's /settings payload. muleimage only drives the indexer/stack/
// download knobs from its own UI — the `ui`/`search`/`maps`/`import`/`features`
// blocks PhotoPrism also returns only steer PhotoPrism's own SPA (which our
// users never see), so they're intentionally omitted here and never surfaced.
// The `[k: string]` index signature means an unknown round-tripped block is
// preserved on save without us having to model it.
export interface PpSettings {
ui?: {
theme?: string;
language?: string;
timeZone?: string;
startPage?: string;
scrollbar?: boolean;
zoom?: boolean;
};
search?: {
batchSize?: number;
listView?: boolean;
showTitles?: boolean;
showCaptions?: boolean;
};
maps?: { animate?: number; style?: string };
index?: {
path?: string;
convert?: boolean;
@@ -1106,7 +1120,6 @@ export interface PpSettings {
skipRaw?: boolean;
skipHidden?: boolean;
};
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
download?: {
name?: string;
@@ -1117,42 +1130,27 @@ export interface PpSettings {
crc32?: boolean;
sha1?: boolean;
};
/**
* PhotoPrism's feature-flag bag. Each key gates a UI surface (and the
* matching API endpoints) inside PP's own SPA — disabling `share` for
* example hides every share button. Optional because older PP versions
* don't return the block; the Library tab only renders toggles for
* keys it actually sees in the response.
*/
features?: {
archive?: boolean;
private?: boolean;
review?: boolean;
files?: boolean;
folders?: boolean;
moments?: boolean;
calendar?: boolean;
places?: boolean;
edit?: boolean;
share?: boolean;
library?: boolean;
import?: boolean;
logs?: boolean;
search?: boolean;
account?: boolean;
settings?: boolean;
services?: boolean;
people?: boolean;
labels?: boolean;
download?: boolean;
upload?: boolean;
delete?: boolean;
ratings?: boolean;
[k: string]: boolean | undefined;
};
[k: string]: unknown;
}
// ── Per-user prefs (sidecar) ──────────────────────────────────────────────────
//
// The index sub-path: an originals-relative folder under the user's BasePath
// that re-roots the Library tree and scopes the reindex. Stored server-side by
// the sidecar, keyed by username. Empty string = "whole folder".
export async function getIndexSubpath(): Promise<string> {
const data = (await callSidecar('GET', '/prefs')) as { indexPath?: string };
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
}
export async function setIndexSubpath(indexPath: string): Promise<string> {
const data = (await callSidecar('PUT', '/prefs', {
indexPath: indexPath.replace(/^\/+|\/+$/g, '')
})) as { indexPath?: string };
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
}
export async function getSettings(): Promise<PpSettings> {
const { data } = await http.get<PpSettings>('/settings');
return data;
@@ -1183,26 +1181,6 @@ export async function cancelIndex(): Promise<void> {
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<void> {
await http.delete('/import');
}
export interface PpLogEntry {
Time: string;
Level: string;