The move resolved photos via the /photos search, whose merged Files array is
trimmed (often omitting a photo's video file) and which applies PhotoPrism's
quality/review/archive filters — so a video's .mov was never listed to move
and nothing happened. Resolve each UID via GET /photos/:uid instead (full file
list, no filters), shared by photos-move and heap-convert via resolvePhotosFull.
Unresolved UIDs are reported as skipped rather than aborting the batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Videos, Live Photos, and RAW+JPG pairs keep several files under Root "/". The
old movePhotoFiles moved only the primary (often the poster JPG), orphaning
the .mov: PhotoPrism then saw the photo as moved (dropped from the grid) while
the video stayed behind and broke. Move the whole originals group under one
shared stem (new uniqueStem helper) so siblings re-stack after reindex; fail
the photo and report it if any sibling can't move.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Client-side paging of listPhotosWithNotes stopped early for BasePath users:
the sidecar post-filters each page by BasePath, so a full upstream page can
arrive short, tripping the `length < PAGE` end condition before the library
is exhausted — hiding notes past the first slice.
Add GET /api/sidecar/notes: the sidecar pages /api/v1/photos to completion
(keying the loop off the raw upstream page length), filters to non-empty
Caption under the caller's BasePath, dedupes by UID, and returns the set.
listPhotosWithNotes now calls this single endpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Issue 1 — colors/labels not applying in bulk:
- sidecar validColors only accepted 4 of the 8 UI swatches, so teal/blue/
purple/pink returned "invalid color" and rolled back the whole bulk txn.
Add teal, blue, purple, pink to validColors.
- Add invalidateFacets() and call it on the success path of bulk marks,
patchTargets, and single-photo edits so the Colors/Ratings/Notes facet
sections refresh immediately instead of waiting out staleTime.
Issue 2 — archived photos linger in the grid:
- Add a UI-only removedIds set to the bulkAction store; archive/delete/
restore/keep call markRemoved() on success so tiles vanish instantly,
cleared once the server-reconcile refetch lands (no cache eviction).
Issue 3 — per-photo progress state:
- Wire startBulk/doneBulk/failBulk into all metadata applies, bulk
(BulkMetadataSidebar) and single (RightSidebar), so colors/ratings/
notes/dates/keywords show the spinner -> check -> X overlay.
Issue 4 — remove Left-sidebar count badges:
- Drop count badges from root folder, Archive, heaps, Notes, and the
folder tree, plus the now-dead count queries and unused imports. Facet
drill-panel counts are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Added 401-handling response interceptor to sidecar axios instance
(matches existing http instance) so expired/invalid tokens redirect
to login instead of showing raw 404/401 errors
- Added GET /api/sidecar/folders — proxies PhotoPrism's
/api/v1/folders/originals with BasePath post-filter
- Updated listFolders() frontend to call sidecar proxy
- Updated plan with remaining fixes
- New GET /api/sidecar/timeline — proxies PP's /api/v1/photos and
post-filters by FileName prefix matching the user's BasePath
- Also works for review/archive views (q=review:true, q=archived:true)
- Frontend route uses /timeline to avoid Gin route conflict with
existing /photos/:uid/marks pattern
- New GET /api/sidecar/labels — proxies PP's labels, recalculates
PhotoCount per user's BasePath via DB query
- New GET /api/sidecar/counts — returns user-scoped sidebar badges
(all, review, archived, private, photos, videos, favorites)
- Fixed auth middleware to expose userUID and basePath on context
- Fixed ppClient.resolveSession — uses correct endpoint
(GET /api/v1/session, not /api/v1/session/{token}) and correct
JSON field names (UID, Name instead of UserUID, UserName)
- Frontend: listLabels now calls /api/sidecar/labels instead of /api/v1/labels
Marks (ratings/color labels) were stored without a user column — every
user saw every other user's marks. Labels and subjects from PhotoPrism's
global endpoints leaked across users because those endpoints ignore
BasePath ACL.
Sidecar:
- Add UserName as composite primary key on Mark (photo_uid, user_name)
- Replace validateSession with resolveSession that fetches the user
identity from PhotoPrism's session endpoint
- Filter all mark queries by user_name
Frontend:
- Filter listLabels/listSubjects through a BasePath-aware existence
check — each label/subject is kept only if the user has at least one
matching photo (single count=1 probe per item, batched at concurrency 8)
- Skip filtering for admin users with empty BasePath (single-user compat)
Also documents USER_BASEPATHS in .env.example — the env var that drives
per-user library isolation via PhotoPrism's auth_users.base_path.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two distinct bugs were causing left-sidebar badges to under-report:
1. sidecar/folders/counts hard-capped each PP /photos call at count=1000
and deduped UIDs from that single page. Any folder with >1000 file
rows under it (typical for a multi-year root scan with HEIC sidecars)
silently lost everything past row 1000. On this library the root
badge reported 912 while the year subfolders summed to 1175. Loop
offsets instead, breaking when PP returns a short page.
2. The Labels-badge query passed all:true label:* to PP, which 400s with
"Unable to do that" - none of the other bucket queries prefix
all:true. Drop it; the scoped() helper already injects the user's
path clause when applicable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Delete the old Python+React mule-image stack (backend/, frontend/,
docker-compose.yml, mulita.yml, .env*) plus the one-shot migration and
sample dirs (migrate/, photos-sample/, photovault-app-prompt.md). Only
the PhotoPrism + Go sidecar + SvelteKit web stack remains, so drop the
".photoprism." qualifier from the compose+env filenames.
Bind PhotoPrism's port to 127.0.0.1 so the user-facing surface is just
the SvelteKit web/ app; admin reaches PP's UI via SSH tunnel. Flatten
PHOTOPRISM_INDEX_WORKERS' nested default (podman-compose's interpolator
doesn't expand ${A:-${B:-…}}). Rewrite README for the current stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PhotoPrism's OSS edition has no way to map OIDC claims to BasePath, so
every freshly-registered OIDC user lands with BasePath="" and either
sees the whole library (admin) or nothing (guest) — never their own
subfolder.
Introduces a sidecar-driven reconciler with a single env knob the
admin sets in docker-compose / .env.photoprism:
USER_BASEPATHS="test:test, alice:family/alice, bob:bob"
(`user:originals-relative-path` pairs, comma-separated.) On boot and
every 60s thereafter the sidecar:
- mkdir -p's the target subdirectory under ORIGINALS_ROOT so
PhotoPrism's path: ACL filter has somewhere real to point;
- UPDATEs photoprism.auth_users.base_path for the matching row
where it differs (idempotent, missing users skipped — they
materialise on first OIDC login and the next pass catches them).
The reconciler uses a separate gorm connection scoped to the
`photoprism` schema with PhotoPrism's own DB user, since the existing
`sidecar` user only has grants on `mule_sidecar.*`. Connection stays
dormant when PP_DB_PASSWORD is empty — the feature is opt-in via env.
Compose changes: thread PP_DB_* + USER_BASEPATHS through to the
sidecar service. New users.go file isolates the reconciler logic;
main.go calls startUserBasepathReconciler() during boot.
Two paired fixes for the folder tree on the timeline:
* +page.svelte: drop the `Path === ''` post-filter for the root entry.
PhotoPrism's indexer always nests photos under YYYY/MM, so "photos
whose Path is empty" is always the empty set in practice — the root
entry looked broken instead of "whole library". Treat `/` as the
unscoped view and rely on subfolder selections (now wildcarded via
filters.svelte.ts) for narrowing.
* sidecar/handlers_folders.go: the per-folder count fan-out used
`q=path:<x>`, the same exact-match operator that just got fixed in
the web filter. Result: every year-level folder reported count=0
in the sidebar. Switch to `q=path:"<x>*"` so the count reflects
the whole subtree (dedupe by UID still in place).
Six-item frontend performance pass on the SvelteKit app.
P1 — Move per-folder photo counts to a new sidecar endpoint and defer
the fetch to requestIdleCallback. The old client-side path fired one
/photos?count=1000 per folder from the browser (≈1 MB JSON × N folders)
on every cold sidebar mount; the new POST /api/sidecar/folders/counts
fans out over loopback with bounded concurrency and returns a single
{path: count} payload of a few KB.
P2 — Bound the visibleRange scroll-scan around the previous visible
band instead of sweeping every shell from index 0 on each scroll-rAF.
Falls back to a full sweep on cache miss (filter reset, programmatic
jump) so behaviour is unchanged at the edges.
P3 — Adaptive thumbnail size + srcset. PhotoTile now picks the smallest
PhotoPrism tile_* variant (100/224/500) that covers the user's grid
preset at the current DPR. Adds decoding="async".
P4 — Lift the selection check above the {#each} loop. Mostly readability
— SvelteSet.has() is already per-key reactive — but keeps the hot loop
body terse.
P5 — Split dedupedAll / photos derivations so filter-store mutations
(search-as-you-type, section toggles) don't re-walk every loaded page;
only the cheap folder-scope filter re-runs.
P6 — Dynamic-import PreviewOverlay on first preview.uid !== null and
cache the loaded module; closing the overlay leaves the component
mounted with its internal {#if} collapsing the DOM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sidebar
- New `/` root-folder entry at the top of the Folders group. Active
when the timeline is scoped to root; the photo grid post-filters to
`Path === ''` because PhotoPrism's `path:` operator can't express an
exact-root match. Collapsible chevron, persisted to its own
localStorage key, and a kebab carrying just "New subfolder".
- Per-folder count badges. `/api/v1/photos?q=path:X&count=1000` per
folder in parallel via `listFolderCounts`; root count derived from
`config.count.all − Σ subfolder counts`.
- Folder tree starts at depth=1 under the root so nested rows indent
visually relative to `/`.
- Footer matches the Toolbar / action-bar h-9 height.
Timeline interaction
- Single click on a tile selects only that tile (clears others); the
preview now lives on dblclick. Modifier clicks still go through
`gridKeyNav`'s document handler (shift = range, cmd/ctrl = toggle).
- `x` (archive) now actually archives — PhotoPrism's photo PUT
silently drops the Archived field, so we route through
/batch/photos/{archive,restore} the same way the BulkActionBar
already did. Mirror for `u`.
- Preview close restores the timeline focus + scrolls the last-shown
photo into view via `forcedExpand`+`scrollTileIntoView` so it
actually mounts (selection ring would otherwise stay invisible when
the user navigated far in preview).
- `applyFolderScope` only narrows the timeline to root when the active
view is a folder view (no heap / search / non-default section), so
label clicks / heap views / favorites no longer drop subfolder
photos.
Action bar
- Inline `h-9` row at the bottom of the main column (not `fixed`),
matching the Toolbar's visual language. Right sidebar stays full
height — the bar only spans the timeline width.
- Approve action wired for the review pile.
Colors / Tags / Ratings drill-ins
- New shared `PhotoGrid` component owning tile rendering, selection
styling, single-click-selects + dblclick-previews, and `setOrder`
for arrow-key nav.
- Each route's drill-in `<main>` carries `use:gridKeyNav` and a
trailing `<BulkActionBar />` so shift/cmd/ctrl click, arrow keys,
and the keyboard shortcuts work the same as the timeline.
- Tags switches from `goto('/?q=label:…')` to an in-place drill-in
with a back button, mirroring `/colors`'s flow.
- Category cards + drill-in photo cards honour the global
`view.thumbnailSize` (XS–XL) so the timeline's size selector now
reaches into all four grids.
Settings
- General-settings dialog merges Appearance into UI and switches free
text inputs to selects for the PhotoPrism theme / language / start
page / map style (the value-from-server prepends if it's outside
the curated list so we never silently rewrite a custom value). Time
zone uses `<datalist>` with `Intl.supportedValuesOf('timeZone')`.
Sidecar
- Heap convert runs reindex synchronously per source path so the
client's invalidate-and-refetch sees the moved files.
Inbox
- New /inbox route stub for the upcoming import workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New sidecar/Dockerfile: multi-stage golang:1.25-alpine → distroless/
static, ~12 MB final image, static CGO-free binary.
- Wire pp-sidecar into docker-compose.photoprism.yml so the whole
stack (mariadb + photoprism + sidecar) starts with one
`podman-compose up`. Container reaches mariadb + photoprism on the
internal network; the host gets 127.0.0.1:8000 for Vite's proxy.
- New SIDECAR_LISTEN_ADDR env var (default 127.0.0.1 for the host-mode
dev loop) so the container can bind 0.0.0.0:8000 and let the port
mapping reach it. Without this the loopback bind was invisible to
the host.
- Delete sidecar/legacy/server.mjs — the Node prototype's archival
window is over; git history is its home now.
- Update sidecar/README with compose-first bringup; keep the host
`go build` flow as the fast-iteration loop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the Node prototype (server.mjs) with the stack the merge plan
calls for: Go 1.25, Gin for routing, GORM + MariaDB for persistence.
Same wire contract on /api/sidecar/* so the SvelteKit client doesn't
change.
- Marks move from a JSON file on disk to mule_sidecar.marks (auto-
migrated by GORM on first boot). The Node prototype's marks.json
was dev-only; not migrated.
- Folder/rename/heap-convert/duplicates handlers reproduce the
prototype's behaviour, including the path-traversal defence
(resolveUnderRoot + EvalSymlinks), the size-bucket prefilter for
the duplicate hasher, and the background reindex fire-and-forget
pattern.
- Auth model unchanged: requireSession middleware proxies the
caller's X-Auth-Token to PhotoPrism's /api/v1/photos?count=1
before any destructive op.
- Expose pp-mariadb on 127.0.0.1:3306 in docker-compose so the
host Go process can reach mule_sidecar.* without joining the
container network.
- Archive the Node prototype under sidecar/legacy/server.mjs for
one cycle as reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the legacy mule-image backend with PhotoPrism plus a thin
SvelteKit client and a Node sidecar for endpoints PhotoPrism doesn't
expose (file rename), and add a two-phase migrator (metadata via PUT,
heaps → albums) for the existing Postgres library.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>