Extends the heap-only "move to folder" action to grid single/bulk selections, sidebar folders, and an `m` keyboard shortcut — all through one shared dialog driven by a moveDialog store. Backend (sidecar): - Extract the heap move/copy + reindex loop into a reusable movePhotoFiles helper plus resolveMoveTarget - POST /photos/move: move/copy an arbitrary UID list into a folder - POST /folders/:rel/move: reparent a folder dir (whole subtree) under a new parent, guarding against moving into itself/a descendant Frontend: - moveDialog store + generalized MoveToFolderDialog (heap | photos | folder subjects); mounted once in +layout.svelte. Replaces HeapConvertDialog - movePhotosToFolder / moveFolder service fns - Entry points: BulkActionBar button, gridKeyNav `m`, FolderTree kebab, heap kebab — all call openMove() Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1287 lines
44 KiB
TypeScript
1287 lines
44 KiB
TypeScript
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
|
import { browser } from '$app/environment';
|
|
import { goto } from '$app/navigation';
|
|
import {
|
|
adoptSession,
|
|
clearSession,
|
|
session,
|
|
toOriginalsPath,
|
|
toUserPath,
|
|
userBasePath
|
|
} from '$lib/stores/session.svelte';
|
|
import { primaryFile } from '$lib/types/photoprism';
|
|
import type {
|
|
PpClientConfig,
|
|
PpPhoto,
|
|
PpRole,
|
|
PpSessionResponse,
|
|
PpUser
|
|
} from '$lib/types/photoprism';
|
|
|
|
/**
|
|
* Axios client pre-configured for PhotoPrism's /api/v1. Same-origin in dev
|
|
* (vite proxies /api → photoprism:2342), same-origin in prod (Caddy fronts
|
|
* both the SPA and PhotoPrism on one hostname).
|
|
*/
|
|
const http: AxiosInstance = axios.create({
|
|
baseURL: '/api/v1',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
/** Axios instance for sidecar endpoints — no baseURL prefix so paths
|
|
* like `/api/sidecar/timeline` resolve directly through Caddy's
|
|
* `/api/sidecar/*` rule instead of becoming `/api/v1/api/sidecar/*`. */
|
|
const sidecar: AxiosInstance = axios.create({
|
|
baseURL: '',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
http.interceptors.request.use((config) => {
|
|
if (session.accessToken) {
|
|
config.headers = config.headers ?? {};
|
|
(config.headers as Record<string, string>)['X-Auth-Token'] = session.accessToken;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
sidecar.interceptors.request.use((config) => {
|
|
if (session.accessToken) {
|
|
config.headers = config.headers ?? {};
|
|
(config.headers as Record<string, string>)['X-Auth-Token'] = session.accessToken;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
http.interceptors.response.use(
|
|
(r) => r,
|
|
(err: AxiosError) => {
|
|
if (err.response?.status === 401 && browser) {
|
|
clearSession();
|
|
// Avoid redirect loops if the request was a login probe.
|
|
const url = err.config?.url ?? '';
|
|
if (!url.endsWith('/session')) {
|
|
void goto('/login', { replaceState: true });
|
|
}
|
|
}
|
|
return Promise.reject(err);
|
|
}
|
|
);
|
|
|
|
sidecar.interceptors.response.use(
|
|
(r) => r,
|
|
(err: AxiosError) => {
|
|
if (err.response?.status === 401 && browser) {
|
|
clearSession();
|
|
const url = err.config?.url ?? '';
|
|
if (!url.endsWith('/session')) {
|
|
void goto('/login', { replaceState: true });
|
|
}
|
|
}
|
|
return Promise.reject(err);
|
|
}
|
|
);
|
|
|
|
// ── Auth ─────────────────────────────────────────────────────────────────────
|
|
|
|
export async function login(username: string, password: string): Promise<PpSessionResponse> {
|
|
const { data } = await http.post<PpSessionResponse>('/session', { username, password });
|
|
adoptSession(data);
|
|
return data;
|
|
}
|
|
|
|
export async function logout(): Promise<void> {
|
|
if (session.id) {
|
|
try {
|
|
await http.delete(`/session/${session.id}`);
|
|
} catch {
|
|
// Best-effort: even if PhotoPrism rejects, drop the client state.
|
|
}
|
|
}
|
|
clearSession();
|
|
}
|
|
|
|
export async function fetchSession(id: string): Promise<PpSessionResponse> {
|
|
const { data } = await http.get<PpSessionResponse>(`/session/${id}`);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* After OIDC completes, PhotoPrism returns an HTML page that writes the
|
|
* issued session into `localStorage` under the namespaced keys
|
|
* `pp:<storageNamespace>:session.{id,token,user,provider}` and then runs
|
|
* `window.location.href = "/library/login"`. With our Caddy bouncing
|
|
* `/library/*` back to `/`, the browser lands on the SvelteKit root with
|
|
* those entries already in localStorage but with no PhotoPrism cookies set
|
|
* — so we read them back to adopt the OIDC-issued session.
|
|
*
|
|
* Returns the adopted session, or null when nothing is waiting in storage
|
|
* (caller treats null as "stay on /login").
|
|
*/
|
|
export async function bootstrapSessionFromPhotoPrism(): Promise<PpSessionResponse | null> {
|
|
if (!browser) return null;
|
|
// PhotoPrism's storageNamespace is per-instance (build-time hash); fetch
|
|
// it via /api/v1/config so we resolve the right key prefix.
|
|
let namespace: string | undefined;
|
|
try {
|
|
const cfg = await getConfig();
|
|
namespace = cfg.storageNamespace;
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!namespace) return null;
|
|
const prefix = `pp:${namespace}:`;
|
|
const sid = localStorage.getItem(prefix + 'session.id');
|
|
const token = localStorage.getItem(prefix + 'session.token');
|
|
if (!sid || !token) return null;
|
|
// Prime the http client so the X-Auth-Token interceptor fires for the
|
|
// session lookup below.
|
|
session.accessToken = token;
|
|
try {
|
|
const resp = await fetchSession(sid);
|
|
adoptSession(resp);
|
|
// adoptSession persists into our own storage key (`pp_session`);
|
|
// PhotoPrism's `pp:<ns>:session.*` entries are one-shot delivery,
|
|
// so clear them now to avoid stale state on logout.
|
|
for (const k of [
|
|
'session.id',
|
|
'session.token',
|
|
'session.user',
|
|
'session.provider',
|
|
'session.error'
|
|
]) {
|
|
localStorage.removeItem(prefix + k);
|
|
}
|
|
return resp;
|
|
} catch {
|
|
session.accessToken = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function getConfig(): Promise<PpClientConfig> {
|
|
const { data } = await http.get<PpClientConfig>('/config');
|
|
return data;
|
|
}
|
|
|
|
// ── Photos ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface ListPhotosParams {
|
|
q?: string;
|
|
count?: number;
|
|
offset?: number;
|
|
order?: 'newest' | 'oldest' | 'added' | 'edited' | 'name';
|
|
merged?: boolean;
|
|
}
|
|
|
|
export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto[]> {
|
|
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
|
params: {
|
|
count: 60,
|
|
offset: 0,
|
|
order: 'newest',
|
|
merged: true,
|
|
...params
|
|
}
|
|
});
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Resolve photos for an explicit set of UIDs. Used by the Colors / Ratings
|
|
* facets, whose member set comes from the mule-sidecar marks store and is NOT
|
|
* bounded to the newest N photos — a marked photo anywhere in the library must
|
|
* resolve. Fetches per-UID (concurrency-bounded) via the same `/photos/:uid`
|
|
* endpoint the metadata panel uses, so it can't drift from PhotoPrism's search
|
|
* DSL. Missing UIDs (deleted since marked) are skipped.
|
|
*/
|
|
export async function listPhotosByUids(uids: string[]): Promise<PpPhoto[]> {
|
|
if (uids.length === 0) return [];
|
|
const out: PpPhoto[] = [];
|
|
const concurrency = 8;
|
|
for (let i = 0; i < uids.length; i += concurrency) {
|
|
const slice = uids.slice(i, i + concurrency);
|
|
const fetched = await Promise.all(slice.map((uid) => getPhoto(uid).catch(() => null)));
|
|
for (const p of fetched) if (p) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Fetch a page of photos *anchored at* a specific TakenAt — `before`
|
|
* older photos preceded by `after` newer ones, merged newest-first.
|
|
* Uses PhotoPrism's `before:`/`after:` DSL clauses so the anchor's
|
|
* neighbours can be loaded without paging through the whole filter.
|
|
*
|
|
* Used by the timeline's deep-link focus mode: an in-app navigation
|
|
* stashes `{uid, takenAt}`, the timeline calls this with the anchor's
|
|
* date for page 0, and the target photo lands ~`afterCount` tiles
|
|
* down with `~beforeCount` older neighbours below it.
|
|
*
|
|
* Subsequent infinite-scroll pages use plain `listPhotos` with the
|
|
* standard offset cursor — the anchor mode only matters for page 0.
|
|
*/
|
|
export interface AroundParams {
|
|
/** Base DSL filter (e.g. `path:"2024/02*"`). Anchor clauses are appended. */
|
|
q?: string;
|
|
/** Anchor's TakenAt as ISO string (e.g. `'2026-01-31T18:26:40Z'`). */
|
|
takenAt: string;
|
|
/** How many photos newer than the anchor to fetch. */
|
|
afterCount?: number;
|
|
/** How many photos at-or-older-than the anchor to fetch (includes the anchor itself). */
|
|
beforeCount?: number;
|
|
merged?: boolean;
|
|
}
|
|
|
|
export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
|
|
const afterCount = p.afterCount ?? 30;
|
|
const beforeCount = p.beforeCount ?? 90;
|
|
const baseQ = p.q?.trim() ?? '';
|
|
// PhotoPrism's `before:`/`after:` operators take ISO timestamps.
|
|
// `+1s` / `-1s` makes the bounds inclusive of the anchor itself in
|
|
// the `before:` half (so the target tile is in the merged result).
|
|
const anchorDate = new Date(p.takenAt);
|
|
if (Number.isNaN(anchorDate.getTime())) {
|
|
// Date parse failed — fall back to a plain newest-first page.
|
|
return listPhotos({ q: baseQ, count: afterCount + beforeCount, order: 'newest', merged: p.merged });
|
|
}
|
|
// PhotoPrism's DSL accepts date-only bounds (`YYYY-MM-DD`). Round
|
|
// up/down by a day so the anchor's own day is included in the
|
|
// `before:` half — the bounds are inclusive day boundaries, so a
|
|
// timestamp-precision anchor lands inside the `[beforeBound,
|
|
// afterBound]` window.
|
|
function ymd(d: Date): string {
|
|
const y = d.getUTCFullYear();
|
|
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
const dd = String(d.getUTCDate()).padStart(2, '0');
|
|
return `${y}-${m}-${dd}`;
|
|
}
|
|
const dayMs = 86_400_000;
|
|
const beforeBound = ymd(new Date(anchorDate.getTime() + dayMs));
|
|
const afterBound = ymd(new Date(anchorDate.getTime() - dayMs));
|
|
const newerQ = `${baseQ} after:${afterBound}`.trim();
|
|
const olderQ = `${baseQ} before:${beforeBound}`.trim();
|
|
|
|
const [newerOldestFirst, older] = await Promise.all([
|
|
listPhotos({
|
|
q: newerQ,
|
|
count: afterCount,
|
|
order: 'oldest',
|
|
merged: p.merged ?? true
|
|
}),
|
|
listPhotos({
|
|
q: olderQ,
|
|
count: beforeCount,
|
|
order: 'newest',
|
|
merged: p.merged ?? true
|
|
})
|
|
]);
|
|
// `newerOldestFirst` is oldest→newest; reverse so it reads newest-first
|
|
// to match the standard timeline order, then concat the older window.
|
|
// Dedupe by UID in case the anchor itself shows up in both halves.
|
|
const merged: PpPhoto[] = [];
|
|
const seen = new Set<string>();
|
|
for (const p of newerOldestFirst.slice().reverse()) {
|
|
if (!seen.has(p.UID)) {
|
|
merged.push(p);
|
|
seen.add(p.UID);
|
|
}
|
|
}
|
|
for (const p of older) {
|
|
if (!seen.has(p.UID)) {
|
|
merged.push(p);
|
|
seen.add(p.UID);
|
|
}
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Count photos matching a DSL query, scoped to whatever the caller's
|
|
* session ACL allows. PhotoPrism doesn't expose a dedicated "count
|
|
* only" endpoint, but the `X-Count` header on `/photos` returns the
|
|
* page size — which equals the total when `count` is set above the
|
|
* library size. 10000 is generously above the realistic per-user
|
|
* library and well under PhotoPrism's server-side ceiling, so a single
|
|
* round-trip yields the true total without paginating.
|
|
*
|
|
* Used by the LeftSidebar to render bucket badges that reflect what
|
|
* the signed-in user actually sees, not the global library aggregate
|
|
* exposed by `/config.count`.
|
|
*/
|
|
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
|
|
const merged = opts.merged ?? false;
|
|
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
|
params: { count: 10000, offset: 0, merged, q }
|
|
});
|
|
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
|
|
// `Files[]` entry), regardless of `merged`. With `merged: true` the
|
|
// response body is one entry per logical photo — so the body length
|
|
// is the canonical photo count when callers need to match what the
|
|
// timeline displays (e.g. the LeftSidebar root badge vs `Cmd+A`).
|
|
if (merged) return Array.isArray(resp.data) ? resp.data.length : 0;
|
|
const header = resp.headers['x-count'];
|
|
const n = typeof header === 'string' ? parseInt(header, 10) : NaN;
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
export async function getPhoto(uid: string): Promise<PpPhoto> {
|
|
const { data } = await http.get<PpPhoto>(`/photos/${uid}`);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Patch payload accepted by `updatePhoto`. Top-level scalars merge in
|
|
* place; `Details` is shallow-merged onto the existing Details object so
|
|
* callers can patch a single Details field (Keywords, Subject, …) without
|
|
* clobbering siblings.
|
|
*
|
|
* **PhotoPrism quirk**: nested `Details.*` only persists when the PUT
|
|
* carries the FULL photo body — partial PUTs silently no-op for those
|
|
* fields. `updatePhoto` handles the fetch/merge/PUT round-trip so callers
|
|
* can stick to a partial shape.
|
|
*/
|
|
export interface UpdatePhotoBody {
|
|
OriginalName?: string;
|
|
Caption?: string;
|
|
CaptionSrc?: 'manual' | '';
|
|
Archived?: boolean;
|
|
/** TakenAt + TakenAtLocal + Year/Month/Day must move in lockstep —
|
|
* use `buildTakenAtPatch` to assemble all five fields from one ISO. */
|
|
TakenAt?: string;
|
|
TakenAtLocal?: string;
|
|
TakenSrc?: 'manual' | '';
|
|
Year?: number;
|
|
Month?: number;
|
|
Day?: number;
|
|
TimeZone?: string;
|
|
Lat?: number;
|
|
Lng?: number;
|
|
Altitude?: number;
|
|
Country?: string;
|
|
CountrySrc?: 'manual' | '';
|
|
Details?: Partial<import('$lib/types/photoprism').PpDetails>;
|
|
}
|
|
|
|
/** True when `s` is a real calendar date in strict `YYYY-MM-DD` form. Rejects
|
|
* shape mismatches AND out-of-range parts that `Date` would silently roll
|
|
* over (e.g. `2026-02-30` → Mar 2). */
|
|
export function isValidISODate(s: string): boolean {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
|
|
const d = new Date(`${s}T00:00:00Z`);
|
|
if (Number.isNaN(d.getTime())) return false;
|
|
return d.toISOString().slice(0, 10) === s;
|
|
}
|
|
|
|
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return {};
|
|
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
|
|
return {
|
|
TakenAt: utc,
|
|
TakenAtLocal: utc,
|
|
TakenSrc: 'manual',
|
|
Year: d.getUTCFullYear(),
|
|
Month: d.getUTCMonth() + 1,
|
|
Day: d.getUTCDate()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merge a partial `UpdatePhotoBody` onto a full photo and PUT the result.
|
|
* Required for `Details.*` because PhotoPrism rejects partial bodies for
|
|
* nested fields. Top-level fields would work with a thinner body but we
|
|
* unify on full-body PUT to keep the call sites simple.
|
|
*/
|
|
export async function updatePhoto(photo: PpPhoto, patch: UpdatePhotoBody): Promise<PpPhoto> {
|
|
const merged: Record<string, unknown> = { ...photo, ...patch };
|
|
if (patch.Details) {
|
|
merged.Details = { ...(photo.Details ?? {}), ...patch.Details };
|
|
}
|
|
const { data } = await http.put<PpPhoto>(`/photos/${photo.UID}`, merged);
|
|
return data;
|
|
}
|
|
|
|
// ── Batch ────────────────────────────────────────────────────────────────────
|
|
|
|
interface BatchPhotosBody {
|
|
photos: string[];
|
|
}
|
|
|
|
function toBatchBody(uids: string[]): BatchPhotosBody {
|
|
// PhotoPrism's batch endpoints expect a flat array of UIDs, not
|
|
// `{UID: ...}` objects (verified against the bundled Vue client).
|
|
return { photos: uids };
|
|
}
|
|
|
|
export async function batchArchive(uids: string[]): Promise<void> {
|
|
await http.post('/batch/photos/archive', toBatchBody(uids));
|
|
}
|
|
|
|
export async function batchRestore(uids: string[]): Promise<void> {
|
|
await http.post('/batch/photos/restore', toBatchBody(uids));
|
|
}
|
|
|
|
/**
|
|
* Permanently delete photos. PhotoPrism only accepts UIDs that are already
|
|
* archived — calling on a live photo returns 4xx. Irreversible; no undo
|
|
* counterpart.
|
|
*/
|
|
export async function batchDelete(uids: string[]): Promise<void> {
|
|
await http.post('/batch/photos/delete', toBatchBody(uids));
|
|
}
|
|
|
|
/**
|
|
* Approve a photo in the review pile. PhotoPrism's indexer leaves photos
|
|
* with low quality scores in `review:true` purgatory; approving bumps the
|
|
* score above the review threshold (Quality goes to 3+) so the photo
|
|
* lands in the main timeline. No corresponding "unapprove" endpoint — the
|
|
* review pile is one-way out.
|
|
*/
|
|
export async function approvePhoto(uid: string): Promise<void> {
|
|
await http.post(`/photos/${uid}/approve`);
|
|
}
|
|
|
|
// ── Stack file operations ───────────────────────────────────────────────────
|
|
// PhotoPrism's stacks pack multiple file variants (RAW + JPG + Live + …) into
|
|
// a single Photo entity. The duplicate-resolution flow needs two ops, both
|
|
// nested under the photo UID:
|
|
// - setPrimary: pick which file is the canonical/cover for the stack.
|
|
// - unstackFile: pull a file out of the stack so it becomes its own Photo
|
|
// record (which can then be archived via batchArchive). PhotoPrism returns
|
|
// the freshly-promoted parent photo body on success.
|
|
//
|
|
// PhotoPrism refuses to unstack auto-generated sidecar files (e.g. `.jpg`
|
|
// companions next to a RAW) and live-photo pairs — both return 4xx/5xx. The
|
|
// callers above must surface the failure rather than retry, hence the
|
|
// passthrough error from the axios layer.
|
|
|
|
export async function setPrimary(photoUid: string, fileUid: string): Promise<void> {
|
|
await http.post(`/photos/${photoUid}/files/${fileUid}/primary`);
|
|
}
|
|
|
|
export async function unstackFile(photoUid: string, fileUid: string): Promise<PpPhoto> {
|
|
const { data } = await http.post<PpPhoto>(`/photos/${photoUid}/files/${fileUid}/unstack`);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Remove a non-primary file from a stack. PhotoPrism cascades the delete
|
|
* through related variants in the same logical group (e.g. deleting one
|
|
* file of a Live Photo HEIC+MOV pair removes the whole pair). The file is
|
|
* NOT erased from disk; PhotoPrism renames the on-disk file with a hash
|
|
* suffix to take it out of the indexer's path. The response is the
|
|
* updated parent photo body.
|
|
*
|
|
* Used by the duplicate-resolution flow as the practical "discard rest"
|
|
* primitive because `/unstack` returns 5xx for live-photo and sidecar
|
|
* files (`only originals can be unstacked` / `Changes could not be saved`).
|
|
*/
|
|
export async function deleteFile(photoUid: string, fileUid: string): Promise<PpPhoto> {
|
|
const { data } = await http.delete<PpPhoto>(`/photos/${photoUid}/files/${fileUid}`);
|
|
return data;
|
|
}
|
|
|
|
// ── Folders ──────────────────────────────────────────────────────────────────
|
|
|
|
export interface PpFolder {
|
|
UID: string;
|
|
Path: string;
|
|
Root: string;
|
|
Title: string;
|
|
FileCount?: number;
|
|
}
|
|
|
|
/**
|
|
* Recursive list of subfolders under originals/. `uncached=true` because
|
|
* PhotoPrism's folder cache lags new folders by a noticeable interval and
|
|
* mule-image's folder tree expects to surface mutations immediately.
|
|
*
|
|
* Scoped to the signed-in user's `BasePath` on the way out: server-absolute
|
|
* `Path` values get rewritten to user-relative (e.g. `users/alice/2024/01`
|
|
* → `2024/01`) so every downstream consumer (FolderTree, sidebar, heap
|
|
* convert picker) sees folders relative to the user's root. The BasePath
|
|
* 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[]> {
|
|
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 folders
|
|
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
|
.map((f) => ({ ...f, Path: toUserPath(f.Path) }))
|
|
.filter((f) => f.Path !== '');
|
|
}
|
|
|
|
/**
|
|
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
|
* endpoint reports `FileCount: 0` even when populated, so the count has
|
|
* to be derived from a `/photos?q=path:X` lookup per folder.
|
|
*
|
|
* We hand this off to the sidecar (`POST /api/sidecar/folders/counts`)
|
|
* which fans out to PhotoPrism over loopback, dedupes by UID, and
|
|
* returns a single `{path: count}` payload of <5 KB. The previous
|
|
* client-side implementation issued one `/photos?count=1000` per folder
|
|
* from the browser — on a library with 30 folders that's ≈30 MB of JSON
|
|
* pulled across the wire on every cold sidebar mount.
|
|
*
|
|
* `path:X` is non-recursive in PhotoPrism's q-DSL: it matches direct
|
|
* children only, so summing the per-path counts (no double-counting
|
|
* from nested folders) is the right way to derive the root-folder
|
|
* photo count. Capped at PhotoPrism's 1000-row ceiling; folders larger
|
|
* than that under-report (pre-existing limitation, unchanged here).
|
|
*
|
|
* Returns a plain object keyed by the input paths to keep it JSON-
|
|
* friendly for TanStack's structural sharing.
|
|
*/
|
|
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
|
|
if (paths.length === 0) return {};
|
|
// The sidecar walks the real filesystem and queries PhotoPrism with
|
|
// server-absolute paths, but callers hand us user-relative paths
|
|
// (because that's what `listFolders` returns post-scoping). Translate
|
|
// on the way out, then re-key the response back to user-relative on
|
|
// the way in so callers' map keys line up with their input array.
|
|
const serverPaths = paths.map((p) => toOriginalsPath(p));
|
|
const data = (await callSidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
|
|
string,
|
|
number
|
|
>;
|
|
const out: Record<string, number> = {};
|
|
for (let i = 0; i < paths.length; i += 1) {
|
|
out[paths[i]] = data[serverPaths[i]] ?? 0;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ── Geo ──────────────────────────────────────────────────────────────────────
|
|
|
|
export interface PpGeoFeature {
|
|
type: 'Feature';
|
|
id: string;
|
|
geometry: { type: 'Point'; coordinates: [number, number] };
|
|
properties: {
|
|
UID: string;
|
|
Hash: string;
|
|
Title?: string;
|
|
TakenAt?: string;
|
|
FavId?: number;
|
|
};
|
|
}
|
|
|
|
export interface PpGeoCollection {
|
|
type: 'FeatureCollection';
|
|
features: PpGeoFeature[];
|
|
bbox?: number[];
|
|
}
|
|
|
|
export async function listGeo(q = ''): Promise<PpGeoCollection> {
|
|
// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
|
|
// matching geocoded photo. MapLibre's native clustering handles 50k+
|
|
// points without breaking a sweat (PhotoPrism upstream documents
|
|
// 500k); we ask for a generous cap that covers realistic libraries.
|
|
const { data } = await http.get<PpGeoCollection>('/geo', {
|
|
params: { count: 50000, q: q || undefined }
|
|
});
|
|
return data;
|
|
}
|
|
|
|
// ── Labels ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface PpLabel {
|
|
UID: string;
|
|
Slug: string;
|
|
CustomSlug?: string;
|
|
Name: string;
|
|
Priority?: number;
|
|
Description?: string;
|
|
PhotoCount?: number;
|
|
Thumb?: string;
|
|
}
|
|
|
|
/**
|
|
* User-set keyword aggregation. PhotoPrism's `/labels` endpoint only
|
|
* surfaces classifier-derived labels; user-typed keywords live in
|
|
* `Details.Keywords` (a flat comma-separated string) which is *not*
|
|
* included in the `/photos` list response. The only path to it is the
|
|
* single-photo endpoint, so we fetch the library top-end (capped at
|
|
* PhotoPrism's 1000-row ceiling), fan out `getPhoto` calls in batches,
|
|
* and aggregate.
|
|
*
|
|
* Slow but cached upstream via TanStack — keys under the `['photos', …]`
|
|
* prefix so the existing photo-mutation invalidations cascade to it.
|
|
*/
|
|
export interface AggregatedKeyword {
|
|
keyword: string;
|
|
count: number;
|
|
/** UID of an arbitrary photo carrying this keyword — used as the
|
|
* thumbnail source so the tile is visually consistent with
|
|
* classifier-label tiles. */
|
|
sampleUid: string;
|
|
sampleHash: string;
|
|
}
|
|
|
|
/**
|
|
* Photos carrying a non-empty user note. mule-image's "Note" is
|
|
* PhotoPrism's `Caption` field (see RightSidebar's Note textarea), which
|
|
* is a top-level scalar — present on the list response, so a single
|
|
* round-trip is enough.
|
|
*/
|
|
export interface PhotoWithNote {
|
|
photo: PpPhoto;
|
|
note: string;
|
|
}
|
|
|
|
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
|
|
// The sidecar pages PhotoPrism to completion server-side and returns only
|
|
// captioned, BasePath-scoped photos — paging client-side would stop early
|
|
// because each page is BasePath-filtered before we see it (a full upstream
|
|
// page can arrive short), silently hiding notes past the first slice.
|
|
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/notes');
|
|
const out: PhotoWithNote[] = [];
|
|
for (const p of data) {
|
|
const note = p.Caption?.trim();
|
|
if (!note) continue;
|
|
out.push({ photo: p, note });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
|
const list = await listPhotos({ count: 1000, merged: true });
|
|
const buckets = new Map<string, AggregatedKeyword>();
|
|
const concurrency = 8;
|
|
for (let i = 0; i < list.length; i += concurrency) {
|
|
const slice = list.slice(i, i + concurrency);
|
|
const fulls = await Promise.all(
|
|
slice.map((p) => getPhoto(p.UID).catch(() => null))
|
|
);
|
|
for (let j = 0; j < slice.length; j++) {
|
|
const photo = slice[j];
|
|
const full = fulls[j];
|
|
if (!full) continue;
|
|
const raw = full.Details?.Keywords ?? '';
|
|
if (!raw) continue;
|
|
for (const kw of raw.split(',').map((k) => k.trim()).filter(Boolean)) {
|
|
const bucket = buckets.get(kw);
|
|
if (bucket) {
|
|
bucket.count++;
|
|
continue;
|
|
}
|
|
const hash = photo.Hash ?? primaryFile(photo).Hash;
|
|
buckets.set(kw, {
|
|
keyword: kw,
|
|
count: 1,
|
|
sampleUid: photo.UID,
|
|
sampleHash: hash
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
|
}
|
|
|
|
async function hasPhotosMatching(q: string): Promise<boolean> {
|
|
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
|
params: { count: 1, offset: 0, q }
|
|
});
|
|
return Array.isArray(resp.data) && resp.data.length > 0;
|
|
}
|
|
|
|
async function filterByUserPhotos<T>(
|
|
items: T[],
|
|
queryFor: (item: T) => string
|
|
): Promise<T[]> {
|
|
if (userBasePath() === '') return items;
|
|
const CONCURRENCY = 8;
|
|
const out: T[] = [];
|
|
for (let i = 0; i < items.length; i += CONCURRENCY) {
|
|
const batch = items.slice(i, i + CONCURRENCY);
|
|
const checks = await Promise.all(
|
|
batch.map(async (item) => ({
|
|
item,
|
|
has: await hasPhotosMatching(queryFor(item))
|
|
}))
|
|
);
|
|
for (const { item, has } of checks) {
|
|
if (has) out.push(item);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function listLabels(): Promise<PpLabel[]> {
|
|
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
|
// low-confidence classifier hits, manually-removed labels). They're
|
|
// still attached to photos in the DB and the `label:<slug>` query
|
|
// still resolves; without `all=true` the /labels endpoint filters
|
|
// them out and the tags page silently shows only ~40% of the user's
|
|
// real tag set. `count` bumped to 1000 so a moderately tagged library
|
|
// returns the full list in one round-trip.
|
|
//
|
|
// Uses the sidecar proxy (/api/sidecar/labels) instead of PhotoPrism's
|
|
// /api/v1/labels so PhotoCount reflects only photos under the user's
|
|
// BasePath. The sidecar proxies the request through to PP then
|
|
// post-filters each label's count.
|
|
const { data } = await sidecar.get<PpLabel[]>('/api/sidecar/labels', {
|
|
params: { count: 1000, order: 'count', all: true, perPage: 1000 }
|
|
});
|
|
// Sidecar already filters to the user's scope and sets correct counts +
|
|
// thumbs in one DB query — no need to probe each label individually.
|
|
return data;
|
|
}
|
|
|
|
// ── Subjects (people / face recognition) ────────────────────────────────────
|
|
//
|
|
// PhotoPrism's face indexer clusters detected faces into Subjects, each with a
|
|
// stable UID, a human-editable Name, and a slug. The DSL operator `person:<slug>`
|
|
// filters photos to those carrying a marker assigned to that subject.
|
|
|
|
export interface PpSubject {
|
|
UID: string;
|
|
Slug: string;
|
|
Name: string;
|
|
Favorite?: boolean;
|
|
Private?: boolean;
|
|
Excluded?: boolean;
|
|
PhotoCount?: number;
|
|
Thumb?: string;
|
|
}
|
|
|
|
export async function listSubjects(): Promise<PpSubject[]> {
|
|
const { data } = await http.get<PpSubject[]>('/subjects', {
|
|
params: { count: 1000, order: 'count' }
|
|
});
|
|
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
|
|
}
|
|
|
|
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
|
const { data } = await http.put<PpSubject>(`/subjects/${uid}`, patch);
|
|
return data;
|
|
}
|
|
|
|
export async function deleteSubject(uid: string): Promise<void> {
|
|
await http.delete(`/subjects/${uid}`);
|
|
}
|
|
|
|
// ── Albums = Heaps ───────────────────────────────────────────────────────────
|
|
|
|
export interface PpAlbum {
|
|
UID: string;
|
|
Slug?: string;
|
|
Type: string;
|
|
Title: string;
|
|
Description?: string;
|
|
PhotoCount?: number;
|
|
CreatedAt?: string;
|
|
UpdatedAt?: string;
|
|
Thumb?: string;
|
|
}
|
|
|
|
export async function listHeaps(): Promise<PpAlbum[]> {
|
|
const { data } = await http.get<PpAlbum[]>('/albums', {
|
|
params: { type: 'album', count: 500, order: 'newest' }
|
|
});
|
|
return data;
|
|
}
|
|
|
|
export async function getHeap(uid: string): Promise<PpAlbum> {
|
|
const { data } = await http.get<PpAlbum>(`/albums/${uid}`);
|
|
return data;
|
|
}
|
|
|
|
export async function createHeap(title: string): Promise<PpAlbum> {
|
|
const { data } = await http.post<PpAlbum>('/albums', {
|
|
Title: title,
|
|
Type: 'album'
|
|
});
|
|
return data;
|
|
}
|
|
|
|
export async function renameHeap(uid: string, title: string): Promise<PpAlbum> {
|
|
const { data } = await http.put<PpAlbum>(`/albums/${uid}`, { Title: title });
|
|
return data;
|
|
}
|
|
|
|
export async function deleteHeap(uid: string): Promise<void> {
|
|
await http.delete(`/albums/${uid}`);
|
|
}
|
|
|
|
/**
|
|
* PhotoPrism returns `{ code, message, album, photos: [uids in album], added: [delta] }`.
|
|
* Surface `added` so callers can detect "200-but-nothing-happened" — PhotoPrism
|
|
* silently skips UIDs that are missing from the index or already in the album,
|
|
* which used to look like a successful add to the user.
|
|
*/
|
|
export interface AddToHeapResult {
|
|
added: string[];
|
|
}
|
|
|
|
export async function addToHeap(uid: string, photos: string[]): Promise<AddToHeapResult> {
|
|
const { data } = await http.post<{ added?: string[] }>(`/albums/${uid}/photos`, { photos });
|
|
return { added: data.added ?? [] };
|
|
}
|
|
|
|
export async function removeFromHeap(uid: string, photos: string[]): Promise<void> {
|
|
await http.delete(`/albums/${uid}/photos`, { data: { photos } });
|
|
}
|
|
|
|
/**
|
|
* Clone a heap. PhotoPrism has no native duplicate endpoint, so we fan out
|
|
* three round-trips: read the source title, list its members via the q-DSL
|
|
* (the same `album:<UID>` filter the timeline uses for the heap view), create
|
|
* a new "X (copy)" album, then add every member to it. Matches mule-image's
|
|
* backend `POST /heaps/{id}/duplicate` behaviour.
|
|
*/
|
|
export async function duplicateHeap(uid: string): Promise<PpAlbum> {
|
|
const source = await getHeap(uid);
|
|
const members = await listPhotos({ q: `album:${uid}`, count: 1000 });
|
|
const copy = await createHeap(`${source.Title} (copy)`);
|
|
if (members.length > 0) {
|
|
await addToHeap(
|
|
copy.UID,
|
|
members.map((p) => p.UID)
|
|
);
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
/**
|
|
* URL for PhotoPrism's album-as-zip download. The download token comes from
|
|
* the session config and is what authenticates the GET — no auth header
|
|
* needed, which is why the URL can be opened in a fresh window/tab.
|
|
*/
|
|
export function heapDownloadUrl(uid: string): string {
|
|
const t = session.downloadToken ?? '';
|
|
return `/api/v1/albums/${uid}/dl?t=${encodeURIComponent(t)}`;
|
|
}
|
|
|
|
/**
|
|
* Trigger a browser download by injecting a transient <a> element and
|
|
* clicking it. Matches the pattern from mule-image's `downloads.trigger`.
|
|
* Uses target=_blank so PhotoPrism's zip response (which streams) doesn't
|
|
* navigate the current page away.
|
|
*/
|
|
export function triggerDownload(url: string): void {
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.rel = 'noopener';
|
|
a.target = '_blank';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
}
|
|
|
|
// ── mule-sidecar (Node prototype today, Go in M4) ────────────────────────────
|
|
|
|
/**
|
|
* Rename the primary file of a photo on disk. PhotoPrism's `OriginalName`
|
|
* field only updates the display name; this calls the mule-sidecar service
|
|
* to issue an actual `os.Rename` under `originals/` and then trigger a
|
|
* PhotoPrism reindex of the parent path.
|
|
*
|
|
* Network: same-origin via the dev proxy entry `/api/sidecar/*`.
|
|
*/
|
|
export interface RenameResult {
|
|
ok: boolean;
|
|
oldName: string;
|
|
newName: string;
|
|
oldRelPath: string;
|
|
newRelPath: string;
|
|
}
|
|
|
|
async function callSidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
|
|
const res = await fetch(`/api/sidecar${urlPath}`, {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Auth-Token': session.accessToken ?? ''
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
const err = (data as { error?: string }).error ?? `HTTP ${res.status}`;
|
|
throw new Error(err);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export async function createFolder(relPath: string): Promise<{ path: string }> {
|
|
return callSidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>;
|
|
}
|
|
|
|
export async function renameFolder(
|
|
relPath: string,
|
|
newName: string
|
|
): Promise<{ oldPath: string; newPath: string }> {
|
|
return callSidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, {
|
|
newName
|
|
}) as Promise<{ oldPath: string; newPath: string }>;
|
|
}
|
|
|
|
export async function deleteFolder(relPath: string): Promise<{ path: string }> {
|
|
return callSidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{
|
|
path: string;
|
|
}>;
|
|
}
|
|
|
|
// ── Cross-folder duplicate detection (sidecar-driven) ───────────────────────
|
|
// PhotoPrism silently drops byte-identical files at index time, so duplicates
|
|
// across folders never enter its DB. The sidecar walks the originals tree,
|
|
// pre-filters by size, sha1s the survivors, and returns the hash-collision
|
|
// groups. Resolution moves the unwanted copies into a `.duplicates/`
|
|
// quarantine folder PhotoPrism's indexer ignores.
|
|
|
|
export interface DupFileEntry {
|
|
path: string;
|
|
size: number;
|
|
}
|
|
|
|
export interface CrossFolderDuplicateGroup {
|
|
hash: string;
|
|
size: number;
|
|
/** Path of the file PhotoPrism currently has indexed for this hash,
|
|
* or null if none (the rare case of every copy being dropped). The
|
|
* UI uses this to default the "keep" pick. */
|
|
indexedPath: string | null;
|
|
files: DupFileEntry[];
|
|
}
|
|
|
|
export interface CrossFolderScanResult {
|
|
groups: CrossFolderDuplicateGroup[];
|
|
scannedMs: number;
|
|
}
|
|
|
|
export async function scanCrossFolderDuplicates(): Promise<CrossFolderScanResult> {
|
|
return callSidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
|
|
}
|
|
|
|
export interface ArchiveDuplicatesResult {
|
|
moved: { from: string; to: string }[];
|
|
errors: { path: string; error: string }[];
|
|
}
|
|
|
|
export async function archiveDuplicatePaths(
|
|
paths: string[]
|
|
): Promise<ArchiveDuplicatesResult> {
|
|
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
|
}
|
|
|
|
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
|
|
// Lives on the sidecar because moving the underlying files is a filesystem
|
|
// operation PhotoPrism's API doesn't expose. The sidecar lists album members
|
|
// via PhotoPrism's q-DSL, fs.rename / fs.copyFile each primary file into the
|
|
// target folder, then triggers a PhotoPrism reindex.
|
|
|
|
export interface HeapConvertBody {
|
|
/** Originals-relative target folder. Must exist. */
|
|
targetFolder: string;
|
|
mode: 'move' | 'copy';
|
|
/** Optional subfolder name to create under `targetFolder` and place
|
|
* files into. Lets the user keep a heap's worth of files grouped. */
|
|
subfolder?: string | null;
|
|
/** Delete the album after a successful move. Ignored when mode='copy'
|
|
* (a copy doesn't change membership). */
|
|
deleteHeap?: boolean;
|
|
}
|
|
|
|
export interface HeapConvertResult {
|
|
moved: number;
|
|
copied: number;
|
|
errors: { uid: string; reason: string }[];
|
|
heap_deleted: boolean;
|
|
}
|
|
|
|
export async function convertHeap(
|
|
uid: string,
|
|
body: HeapConvertBody
|
|
): Promise<HeapConvertResult> {
|
|
return callSidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
|
|
}
|
|
|
|
// ── Move arbitrary photos (by UID) to a folder ──────────────────────────────
|
|
// Same on-disk move/copy + reindex as convertHeap, but the sidecar resolves the
|
|
// photos from a UID list instead of an album. Backs the grid's move-to-folder.
|
|
|
|
export interface PhotosMoveBody {
|
|
uids: string[];
|
|
/** Originals-relative target folder. Empty string = originals root. */
|
|
targetFolder: string;
|
|
mode: 'move' | 'copy';
|
|
/** Optional subfolder to create under `targetFolder` and place files into. */
|
|
subfolder?: string | null;
|
|
}
|
|
|
|
export interface PhotosMoveResult {
|
|
moved: number;
|
|
copied: number;
|
|
errors: { uid: string; reason: string }[];
|
|
}
|
|
|
|
export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMoveResult> {
|
|
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
|
|
}
|
|
|
|
// ── Reparent a folder (move the directory under a different parent) ──────────
|
|
|
|
export interface FolderMoveResult {
|
|
ok: boolean;
|
|
oldPath: string;
|
|
newPath: string;
|
|
}
|
|
|
|
export async function moveFolder(rel: string, targetParent: string): Promise<FolderMoveResult> {
|
|
return callSidecar('POST', `/folders/${encodeURIComponent(rel)}/move`, {
|
|
targetParent
|
|
}) as Promise<FolderMoveResult>;
|
|
}
|
|
|
|
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
|
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
|
// internal fields). We store them in mule-sidecar instead.
|
|
|
|
export interface PhotoMark {
|
|
rating?: number;
|
|
color?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export type PhotoMarksMap = Record<string, PhotoMark>;
|
|
|
|
export async function getAllMarks(): Promise<PhotoMarksMap> {
|
|
const data = await callSidecar('GET', '/photos/marks');
|
|
return (data ?? {}) as PhotoMarksMap;
|
|
}
|
|
|
|
export async function setMark(photoUid: string, patch: PhotoMark): Promise<PhotoMark> {
|
|
return callSidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise<PhotoMark>;
|
|
}
|
|
|
|
export async function bulkSetMarks(
|
|
ids: string[],
|
|
patch: PhotoMark
|
|
): Promise<{ count: number; marks: PhotoMarksMap }> {
|
|
return callSidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{
|
|
count: number;
|
|
marks: PhotoMarksMap;
|
|
}>;
|
|
}
|
|
|
|
export async function renameOnDisk(photoUid: string, newName: string): Promise<RenameResult> {
|
|
// Bypass the axios client because the sidecar lives at /api/sidecar, not
|
|
// /api/v1 — http.baseURL would prepend the wrong prefix.
|
|
const res = await fetch(`/api/sidecar/files/${photoUid}/rename`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Auth-Token': session.accessToken ?? ''
|
|
},
|
|
body: JSON.stringify({ newName })
|
|
});
|
|
const data = (await res.json()) as Partial<RenameResult> & { error?: string };
|
|
if (!res.ok) throw new Error(data.error ?? `Rename failed (${res.status})`);
|
|
return data as RenameResult;
|
|
}
|
|
|
|
// ── Settings / Admin ─────────────────────────────────────────────────────────
|
|
//
|
|
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
|
|
// (Library / Index / Import / 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.
|
|
|
|
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;
|
|
rescan?: boolean;
|
|
skipArchived?: boolean;
|
|
skipMeta?: boolean;
|
|
skipRaw?: boolean;
|
|
skipHidden?: boolean;
|
|
};
|
|
import?: { path?: string; move?: boolean; dest?: string };
|
|
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
|
|
download?: {
|
|
name?: string;
|
|
disabled?: boolean;
|
|
originals?: boolean;
|
|
mediaRaw?: boolean;
|
|
mediaSidecar?: boolean;
|
|
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;
|
|
}
|
|
|
|
export async function getSettings(): Promise<PpSettings> {
|
|
const { data } = await http.get<PpSettings>('/settings');
|
|
return data;
|
|
}
|
|
|
|
export async function saveSettings(patch: Partial<PpSettings>): Promise<PpSettings> {
|
|
const { data } = await http.post<PpSettings>('/settings', patch);
|
|
return data;
|
|
}
|
|
|
|
export interface IndexBody {
|
|
path?: string;
|
|
rescan?: boolean;
|
|
cleanup?: boolean;
|
|
}
|
|
|
|
export async function startIndex(body: IndexBody = {}): Promise<{ message: string }> {
|
|
const { data } = await http.post<{ message: string }>('/index', {
|
|
path: '/',
|
|
rescan: false,
|
|
cleanup: false,
|
|
...body
|
|
});
|
|
return data;
|
|
}
|
|
|
|
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;
|
|
Message: string;
|
|
}
|
|
|
|
export async function getErrors(opts: { limit?: number } = {}): Promise<PpLogEntry[]> {
|
|
const { data } = await http.get<PpLogEntry[]>('/errors', {
|
|
params: { limit: opts.limit ?? 200 }
|
|
});
|
|
return data ?? [];
|
|
}
|
|
|
|
// ── Users ────────────────────────────────────────────────────────────────────
|
|
//
|
|
// PhotoPrism's admin user endpoints. List/create/update/delete require an
|
|
// admin session; the password endpoint accepts the user's own UID with their
|
|
// current password as `old`.
|
|
|
|
export interface CreateUserBody {
|
|
Name: string;
|
|
DisplayName?: string;
|
|
Email?: string;
|
|
Role: PpRole;
|
|
BasePath?: string;
|
|
UploadPath?: string;
|
|
WebDAV?: boolean;
|
|
Password?: string;
|
|
}
|
|
|
|
export type UpdateUserBody = Partial<CreateUserBody>;
|
|
|
|
export async function listUsers(): Promise<PpUser[]> {
|
|
const { data } = await http.get<PpUser[] | { users?: PpUser[] }>('/users', {
|
|
params: { count: 1000, order: 'name' }
|
|
});
|
|
if (Array.isArray(data)) return data;
|
|
return data.users ?? [];
|
|
}
|
|
|
|
export async function createUser(body: CreateUserBody): Promise<PpUser> {
|
|
const { data } = await http.post<PpUser>('/users', body);
|
|
return data;
|
|
}
|
|
|
|
export async function updateUser(uid: string, patch: UpdateUserBody): Promise<PpUser> {
|
|
const { data } = await http.put<PpUser>(`/users/${uid}`, patch);
|
|
return data;
|
|
}
|
|
|
|
export async function deleteUser(uid: string): Promise<void> {
|
|
await http.delete(`/users/${uid}`);
|
|
}
|
|
|
|
export async function setUserPassword(
|
|
uid: string,
|
|
oldPassword: string,
|
|
newPassword: string
|
|
): Promise<void> {
|
|
await http.put(`/users/${uid}/password`, { old: oldPassword, new: newPassword });
|
|
}
|
|
|
|
// ── Re-exports ───────────────────────────────────────────────────────────────
|
|
|
|
export type { PpClientConfig, PpPhoto, PpSessionResponse, PpUser };
|