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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user