4 Commits

Author SHA1 Message Date
d5e4f23c0f feat(web): sidebar root folder + folder counts, drill-in colors/tags/ratings, click semantics rework
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>
2026-05-17 21:01:41 +02:00
5153aeebec fix(sidecar): podman keep-id mapping so mutations land as the host user
Without keep-id the container's UID:GID maps into the rootless podman
subuid range (524288+), so the sidecar couldn't create
`/photoprism/originals/.duplicates/` — the archive endpoint failed
with "mkdir: permission denied", and rename / folder ops would have
hit the same wall.

The PhotoPrism container already has this override for the same
reason; mirror it for the sidecar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:23:37 +02:00
4362e475a7 build(sidecar): containerize + delete the Node prototype
- 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>
2026-05-17 17:23:36 +02:00
032dce6c85 feat(sidecar): port Node prototype to Go + Gin + GORM + MariaDB
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>
2026-05-17 17:15:47 +02:00
37 changed files with 3516 additions and 1196 deletions

3
.gitignore vendored
View File

@@ -72,6 +72,9 @@ docker-compose.override.yml
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
/sidecar/data/
# Sidecar Go build output.
/sidecar/mule-sidecar
# Photos (for development)
/photos/

View File

@@ -26,3 +26,9 @@ services:
# host UID that owns the bind-mounted originals/.
photoprism:
userns_mode: keep-id
# Sidecar mutates the originals tree (rename / folder ops / heap
# convert / .duplicates archive) — same keep-id mapping so its writes
# land as the host user, not as a podman-subuid the host doesn't own.
sidecar:
userns_mode: keep-id

View File

@@ -33,6 +33,12 @@ services:
MARIADB_USER: ${PP_DB_USER:-photoprism}
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.photoprism}
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env.photoprism}
# Loopback-only host port so the mule-sidecar (running as a host process
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
# 127.0.0.1; the photoprism container still resolves mariadb by service
# name on the photoprism-network bridge.
ports:
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
volumes:
- pp_mariadb_data:/var/lib/mysql
# The init script creates the mule_sidecar database + user that the Go
@@ -119,6 +125,49 @@ services:
- "./pp/import:/photoprism/import:Z"
networks: [photoprism-network]
# mule-sidecar — Go + Gin + GORM service for endpoints PhotoPrism's API
# does not expose (file rename, folder mutations, heap convert, duplicate
# scan, per-photo marks). Same wire contract as the M3 Node prototype;
# the SvelteKit dev server proxies /api/sidecar/* here.
sidecar:
build:
context: ./sidecar
container_name: pp-sidecar
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
photoprism:
condition: service_started
# Match PhotoPrism's UID/GID so renames/folder mutations preserve the
# ownership the indexer expects on the bind-mounted originals.
user: "${PP_UID:-1000}:${PP_GID:-1000}"
ports:
# Loopback only — Vite (host) proxies /api/sidecar/* to this port.
# Behind a reverse proxy in production; never published beyond the
# host.
- "127.0.0.1:${SIDECAR_PORT:-8000}:8000"
environment:
ORIGINALS_ROOT: /photoprism/originals
PHOTOPRISM_BASE_URL: http://photoprism:2342
# Bind on all interfaces inside the container so the host-side
# 127.0.0.1:8000 port mapping can reach the listener. The Go
# binary defaults to 127.0.0.1 for the host-mode dev loop.
SIDECAR_LISTEN_ADDR: 0.0.0.0
SIDECAR_PORT: "8000"
SIDECAR_DB_HOST: mariadb
SIDECAR_DB_PORT: "3306"
SIDECAR_DB_USER: sidecar
# Rotate before any non-local deployment. Provisioned by
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
SIDECAR_DB_NAME: mule_sidecar
volumes:
# Sidecar mutates originals (rename, folder mutations, heap
# convert) — always rw regardless of PhotoPrism's mount mode.
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env.photoprism}:/photoprism/originals:rw,Z"
networks: [photoprism-network]
networks:
photoprism-network:
driver: bridge

13
sidecar/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
# Files that have no business shipping into the build context.
# Anything not listed here gets COPY'd into /src so keep this tight.
# Local host-mode build output — re-built inside the image.
mule-sidecar
# Runtime state from the M3 Node prototype.
data/
# Docs + git noise.
README.md
.git/
.gitignore

28
sidecar/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1.6
#
# mule-sidecar — Go service for endpoints PhotoPrism does not expose.
# Multi-stage build: a Go toolchain image compiles a static binary,
# then we copy it onto a distroless base so the runtime image is ~12 MB
# with no shell, package manager, or libc.
FROM docker.io/library/golang:1.25-alpine AS build
WORKDIR /src
# Cache deps separately from source so a one-line code change doesn't
# re-download the whole module graph.
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
# CGO disabled → fully static binary that runs on the distroless base
# (no libc resolution at startup). -trimpath strips local paths from
# debug info; -s -w drop the symbol table to keep the binary small.
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags='-s -w' \
-o /out/mule-sidecar .
FROM gcr.io/distroless/static-debian12:latest
COPY --from=build /out/mule-sidecar /mule-sidecar
EXPOSE 8000
ENTRYPOINT ["/mule-sidecar"]

View File

@@ -1,43 +1,116 @@
# mule-sidecar
Auxiliary service that handles operations PhotoPrism's REST API does not expose.
Go + Gin + GORM service for the endpoints PhotoPrism's REST API does not
expose. Same wire contract as the M3 Node prototype it replaces; the
SvelteKit client at [web/](../web/) talks to it transparently through
Vite's `/api/sidecar/*` proxy.
## Why this exists
## What it owns
Per the merge plan at `/home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md`,
a Go + Gin + GORM service (matching PhotoPrism's stack) will eventually own:
| Method | Path | Purpose |
| ------ | --------------------------------------- | --------------------------------------------- |
| GET | `/api/sidecar/healthz` | Unauthenticated liveness probe. |
| GET | `/api/sidecar/photos/marks` | Every per-photo `{rating, color}` mark. |
| GET | `/api/sidecar/photos/:uid/marks` | One photo's mark (or `{}` if none). |
| PUT | `/api/sidecar/photos/:uid/marks` | Patch one photo's mark. |
| POST | `/api/sidecar/photos/marks/bulk` | Stamp the same mark onto many photos. |
| POST | `/api/sidecar/files/:uid/rename` | Rename the primary file on disk + reindex. |
| POST | `/api/sidecar/folders` | Create a folder under `${ORIGINALS_ROOT}`. |
| POST | `/api/sidecar/folders/:rel/rename` | Rename a folder (rel path URL-encoded). |
| DELETE | `/api/sidecar/folders/:rel` | Delete an **empty** folder. |
| POST | `/api/sidecar/albums/:uid/convert` | Move/copy every photo in a heap into folder X. |
| GET | `/api/sidecar/duplicates/scan` | Walk originals, return same-hash groups. |
| POST | `/api/sidecar/duplicates/archive` | Move duplicate paths into `.duplicates/<ts>/`. |
- Per-user heap sharing with pending invitations
- Folder mutations under `originals/` (create / rename / delete / move)
- **File rename** on disk (PhotoPrism's `OriginalName` is a display-only rename)
Auth: every endpoint except `healthz` requires the caller's
`X-Auth-Token` header. The sidecar holds no service credentials — it
proxies the token straight back to PhotoPrism's `/api/v1/photos?count=1`
to confirm the session is live before doing anything destructive.
The plan picks Go for stack consistency and the option to upstream features.
## What ships today
A **Node.js prototype** (`server.mjs`) covering only the **file rename** endpoint.
The decision to ship Node first is pragmatic — Go isn't installed on this dev
box and `sudo dnf install golang` needs a password. Node is already on PATH for
the SvelteKit dev server, so a single-file Node service unblocks the feature
without changing the host setup.
The endpoint contract is stable: when M4 lands the proper Go service, the
SvelteKit client keeps calling the same paths.
## Endpoints
- `POST /api/sidecar/files/:photoUid/rename` `{ "newName": "newfile.png" }`
Renames the primary file of the photo on disk under `${ORIGINALS_ROOT}`,
then triggers a PhotoPrism reindex of the parent path.
Marks persist to **MariaDB** (`mule_sidecar.marks`); everything else
operates on the filesystem under `${ORIGINALS_ROOT}` and triggers a
PhotoPrism reindex of the affected parent in the background.
## Run
```
ORIGINALS_ROOT=/home/dtoro/projects/mule-image/photos-sample \
PHOTOPRISM_BASE_URL=http://localhost:2342 \
SIDECAR_PORT=8000 \
node server.mjs
The sidecar is a `sidecar` service in the PhotoPrism compose stack.
Bringing the whole stack up brings it up too:
```sh
podman-compose --env-file .env.photoprism \
-f docker-compose.photoprism.yml \
-f docker-compose.photoprism.podman.yml \
up -d
```
The SvelteKit dev server proxies `/api/sidecar/*` to `http://localhost:8000`.
This builds [Dockerfile](Dockerfile) (multi-stage `golang:1.25-alpine`
`gcr.io/distroless/static`, ~12 MB final image), starts the container,
and binds `127.0.0.1:8000` to the service. The SvelteKit dev server
proxies `/api/sidecar/*` to that port transparently.
### Dev-iteration loop (host build)
For tight iteration without rebuilding the image on every change you
can run it as a host process — Go is already on the dev machine:
```sh
cd sidecar
go build -o mule-sidecar .
ORIGINALS_ROOT=/path/to/photoprism/originals \
PHOTOPRISM_BASE_URL=http://localhost:2342 \
SIDECAR_PORT=8000 \
./mule-sidecar
```
The host build connects to `mariadb` via the loopback port the compose
file publishes; stop `pp-sidecar` first so they don't fight for 8000.
## Env
| Var | Default | Notes |
| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
| `ORIGINALS_ROOT` | `/photoprism/originals` | Absolute path; must match PhotoPrism's mount. |
| `PHOTOPRISM_BASE_URL` | `http://localhost:2342` | Where to reach PhotoPrism for session validation + reindex calls. |
| `SIDECAR_PORT` | `8000` | Loopback-only; reverse-proxy fronts it in production. |
| `SIDECAR_DSN` | _(built from the vars below)_ | Set this to override the assembled MySQL DSN entirely. |
| `SIDECAR_DB_HOST` | `127.0.0.1` | Host of the MariaDB the compose stack publishes on `127.0.0.1:3306`. |
| `SIDECAR_DB_PORT` | `3306` | |
| `SIDECAR_DB_USER` | `sidecar` | Provisioned by [`mariadb/init/01-sidecar.sql`](../mariadb/init/01-sidecar.sql) on first boot. |
| `SIDECAR_DB_PASSWORD` | `replace-at-m4-bringup` | Literal placeholder — **rotate before any non-local deployment**. |
| `SIDECAR_DB_NAME` | `mule_sidecar` | |
## Schema
GORM `AutoMigrate` creates the only table the service owns:
```sql
CREATE TABLE marks (
photo_uid VARCHAR(64) PRIMARY KEY,
rating BIGINT NULL,
color VARCHAR(16) NULL,
updated_at DATETIME(3)
);
```
The M3 Node prototype kept the same data in `sidecar/data/marks.json`.
There is no migration path — the prototype's marks file was dev-only
state. Heap-sharing tables (M4) will land in subsequent migrations.
## Layout
```text
sidecar/
├── Dockerfile multi-stage golang:1.25 → distroless/static
├── main.go entrypoint, route wiring, graceful shutdown
├── config.go env-driven Config
├── db.go GORM open + Mark model + AutoMigrate
├── auth.go requireSession middleware + ctxToken
├── fs.go path safety, walk, sha1
├── pp.go PhotoPrism HTTP client (validateSession, reindex)
├── handlers_rename.go
├── handlers_folders.go
├── handlers_marks.go
├── handlers_heap.go
└── handlers_dups.go
```

44
sidecar/auth.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
// requireSession is the standard auth shim every mutating handler wears.
// We don't store a shared service credential — the caller's X-Auth-Token
// is the only authority, and we probe PhotoPrism with it before doing any
// destructive work. The handler reads the validated token off the context
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
// operation.
func requireSession(pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("X-Auth-Token")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
return
}
if !pp.validateSession(c.Request.Context(), token) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
return
}
c.Set("token", token)
c.Next()
}
}
// ctxToken returns the validated X-Auth-Token a previous requireSession
// middleware stored on the request. Handlers MUST run behind that
// middleware; otherwise this returns the empty string.
func ctxToken(c *gin.Context) string {
v, ok := c.Get("token")
if !ok {
return ""
}
s, ok := v.(string)
if !ok {
return ""
}
return s
}

62
sidecar/config.go Normal file
View File

@@ -0,0 +1,62 @@
package main
import (
"os"
"path/filepath"
"strconv"
)
// Config aggregates every runtime knob the sidecar reads from the
// environment. Held in one struct so the rest of the package can take a
// pointer instead of poking os.Getenv at use-sites.
type Config struct {
OriginalsRoot string // absolute path to PhotoPrism's originals dir
PhotoprismBaseURL string // e.g. http://localhost:2342
ListenAddr string // bind interface — 127.0.0.1 for host mode, 0.0.0.0 in containers
Port int // HTTP listen port
DSN string // GORM/MySQL connection string for mule_sidecar
}
func loadConfig() (*Config, error) {
root := envOr("ORIGINALS_ROOT", "/photoprism/originals")
abs, err := filepath.Abs(root)
if err != nil {
return nil, err
}
portStr := envOr("SIDECAR_PORT", "8000")
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, err
}
dsn := os.Getenv("SIDECAR_DSN")
if dsn == "" {
// Default matches the user that mariadb/init/01-sidecar.sql provisions
// on first boot. The literal placeholder password is intentional: the
// SQL ships with it and an env-templating step is left for whoever
// runs this in a non-local context.
user := envOr("SIDECAR_DB_USER", "sidecar")
pass := envOr("SIDECAR_DB_PASSWORD", "replace-at-m4-bringup")
host := envOr("SIDECAR_DB_HOST", "127.0.0.1")
dbPort := envOr("SIDECAR_DB_PORT", "3306")
name := envOr("SIDECAR_DB_NAME", "mule_sidecar")
dsn = user + ":" + pass + "@tcp(" + host + ":" + dbPort + ")/" + name +
"?charset=utf8mb4&parseTime=true&loc=Local"
}
return &Config{
OriginalsRoot: abs,
PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"),
ListenAddr: envOr("SIDECAR_LISTEN_ADDR", "127.0.0.1"),
Port: port,
DSN: dsn,
}, nil
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

63
sidecar/db.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// Mark mirrors the per-photo extras the web client stores via the marks
// endpoints — rating + four-colour label. PhotoUID is the row key; both
// payload fields are nullable so the sparse "no rating / no colour" state
// round-trips cleanly. The Node prototype kept this in a JSON file; we
// migrate to MariaDB here so the M4 sharing work has a real table to
// extend.
type Mark struct {
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}
// TableName pins the GORM-pluralised default to a name that matches the
// other tables the M4 plan calls out (`marks`, `heap_shares`, …) so
// nothing surprising lands in the schema.
func (Mark) TableName() string { return "marks" }
// 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".
func (m *Mark) asJSON() map[string]any {
out := map[string]any{}
if m == nil {
return out
}
if m.Rating != nil {
out["rating"] = *m.Rating
}
if m.Color != nil && *m.Color != "" {
out["color"] = *m.Color
}
if !m.UpdatedAt.IsZero() {
// ISO-8601 with millisecond precision, UTC — matches the Node
// prototype's `new Date().toISOString()` so clients written against
// the old endpoint stay happy.
out["updatedAt"] = m.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z")
}
return out
}
func openDB(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, err
}
if err := db.AutoMigrate(&Mark{}); err != nil {
return nil, err
}
return db, nil
}

213
sidecar/fs.go Normal file
View File

@@ -0,0 +1,213 @@
package main
import (
"crypto/sha1"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strings"
)
// sanitizeFilename trims a user-supplied filename and rejects anything
// dangerous: path separators, leading dots, NUL bytes, the `.`/`..`
// pseudo-names, anything absurdly long. PhotoPrism's indexer is happy
// with most filename shapes; we lock down the ones a malicious or
// careless caller might use to escape the folder.
func sanitizeFilename(name string) (string, bool) {
s := strings.TrimSpace(name)
if s == "" || len(s) > 240 {
return "", false
}
if strings.HasPrefix(s, ".") {
return "", false
}
if s == "." || s == ".." {
return "", false
}
if strings.ContainsAny(s, "/\\\x00") {
return "", false
}
return s, true
}
// resolveUnderRoot takes a user-supplied relative path and returns its
// absolute form, but only when the resolved location lives under the
// configured originals root. Symlink escapes are caught by resolving the
// parent through filepath.EvalSymlinks first.
//
// `mustExist=false` is for the *target* of a rename/create where the
// terminal path isn't on disk yet; the parent still has to exist and
// still has to be inside the root.
func resolveUnderRoot(root, rel string, mustExist bool) (string, error) {
if rel == "" {
return "", errors.New("empty path")
}
clean := strings.TrimLeft(rel, "/")
if clean == "" || clean == "." {
return "", errors.New("empty path")
}
for _, seg := range strings.Split(clean, "/") {
if seg == "" || seg == ".." {
return "", errors.New("path traversal")
}
}
abs := filepath.Join(root, clean)
parent := filepath.Dir(abs)
parentReal, err := filepath.EvalSymlinks(parent)
if err != nil {
return "", err
}
if !sameOrUnder(parentReal, root) {
return "", errors.New("parent escapes originals root")
}
if mustExist {
if _, err := os.Stat(abs); err != nil {
return "", err
}
}
return abs, nil
}
// ensureWithinOriginals checks that an absolute path's parent resolves to
// somewhere inside the root after symlink evaluation. Used for the
// already-resolved-on-disk paths returned by PhotoPrism's Files[].
func ensureWithinOriginals(root, absPath string) bool {
real, err := filepath.EvalSymlinks(filepath.Dir(absPath))
if err != nil {
return false
}
return sameOrUnder(real, root)
}
func sameOrUnder(p, root string) bool {
if p == root {
return true
}
return strings.HasPrefix(p, root+string(os.PathSeparator))
}
// uniqueName resolves "destDir/basename" against collisions by appending
// `-1`, `-2`, … to the stem. Caps at 1000 attempts so a runaway loop
// can't pin the goroutine forever.
func uniqueName(destDir, basename string) (abs, name string, ok bool) {
ext := filepath.Ext(basename)
stem := strings.TrimSuffix(basename, ext)
for i := 0; i < 1000; i++ {
candidate := basename
if i > 0 {
candidate = stem + "-" + itoa(i) + ext
}
p := filepath.Join(destDir, candidate)
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
return p, candidate, true
}
}
return "", "", false
}
// itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// fileEntry is the per-file row walkFiles emits. relPath stays root-
// relative so it can land in API responses unchanged.
type fileEntry struct {
RelPath string
AbsPath string
Size int64
}
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
// itself walks the same set; we keep the list in lock-step so callers
// don't see "duplicate" warnings about files PhotoPrism would ignore.
var supportedExts = map[string]struct{}{
".jpg": {}, ".jpeg": {}, ".png": {}, ".heic": {}, ".heif": {},
".tiff": {}, ".tif": {}, ".gif": {}, ".bmp": {}, ".webp": {}, ".avif": {},
".mov": {}, ".mp4": {}, ".m4v": {}, ".avi": {}, ".mkv": {}, ".webm": {},
".dng": {}, ".cr2": {}, ".cr3": {}, ".nef": {}, ".arw": {},
".orf": {}, ".rw2": {}, ".raw": {},
}
// walkFiles enumerates every supported media file under root, skipping
// dotfiles/dotdirs (matches PhotoPrism's indexer and our own quarantine
// folder). Errors on individual entries are swallowed so a single
// permission-denied dir doesn't abort the whole scan.
func walkFiles(root string) ([]fileEntry, error) {
var out []fileEntry
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
// Permission errors etc. — skip the offending subtree but
// keep walking. The dup-scan endpoint is best-effort.
if d != nil && d.IsDir() {
return filepath.SkipDir
}
return nil
}
name := d.Name()
if p != root && strings.HasPrefix(name, ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if _, ok := supportedExts[ext]; !ok {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return nil
}
out = append(out, fileEntry{
RelPath: rel,
AbsPath: p,
Size: info.Size(),
})
return nil
})
return out, err
}
// sha1File streams the file through a SHA1 hasher so a 4GB ProRes clip
// doesn't blow the process's RAM. Returns the hex digest.
func sha1File(absPath string) (string, error) {
f, err := os.Open(absPath)
if err != nil {
return "", err
}
defer f.Close()
h := sha1.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}

45
sidecar/go.mod Normal file
View File

@@ -0,0 +1,45 @@
module mule-sidecar
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

101
sidecar/go.sum Normal file
View File

@@ -0,0 +1,101 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

230
sidecar/handlers_dups.go Normal file
View File

@@ -0,0 +1,230 @@
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/gin-gonic/gin"
)
const quarantineDir = ".duplicates"
type dupFileLite struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
type dupGroup struct {
Hash string `json:"hash"`
Size int64 `json:"size"`
IndexedPath *string `json:"indexedPath"`
Files []dupFileLite `json:"files"`
}
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
// looking up "which file path has this hash already indexed", used to
// hint the UI which copy to keep.
type dupListPhoto struct {
Files []ppFile `json:"Files"`
}
func handleDupScan(cfg *Config, pp *ppClient) 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)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Group by size first: byte-identical files necessarily share size,
// so size-collision is a cheap O(N) prefilter that lets us skip
// hashing >95% of a typical library.
bySize := map[int64][]fileEntry{}
for _, f := range all {
bySize[f.Size] = append(bySize[f.Size], f)
}
// Hash size-collision buckets concurrently. Cap fan-out to GOMAXPROCS
// so we don't drown the disk with parallel reads on a spinning HDD.
type hashOut struct {
hash string
f fileEntry
}
var (
wg sync.WaitGroup
sem = make(chan struct{}, 4)
outMu sync.Mutex
byHash = map[string][]fileEntry{}
hashSize = map[string]int64{}
)
for size, group := range bySize {
if len(group) < 2 {
continue
}
for _, f := range group {
wg.Add(1)
sem <- struct{}{}
go func(f fileEntry, sz int64) {
defer wg.Done()
defer func() { <-sem }()
h, err := sha1File(f.AbsPath)
if err != nil {
return
}
outMu.Lock()
byHash[h] = append(byHash[h], f)
hashSize[h] = sz
outMu.Unlock()
}(f, size)
}
}
wg.Wait()
// Drop singletons (size collision but different hashes), then ask
// PhotoPrism which of the duplicates it has indexed so the UI can
// default the "keep" selection to that one.
groups := make([]dupGroup, 0)
for h, files := range byHash {
if len(files) < 2 {
continue
}
g := dupGroup{Hash: h, Size: hashSize[h]}
for _, f := range files {
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
}
// Best-effort lookup; swallow errors. The hash query is cheap on
// PhotoPrism's side (indexed column).
resp, err := pp.call(c.Request.Context(), http.MethodGet,
"/api/v1/photos?q=hash:"+h+"&count=1&merged=true", token, nil)
if err == nil && resp.OK {
var photos []dupListPhoto
if err := json.Unmarshal(resp.Body, &photos); err == nil && len(photos) > 0 {
if pf, ok := primaryFileOf(&ppPhoto{Files: photos[0].Files}); ok && pf.Name != "" {
p := pf.Name
g.IndexedPath = &p
}
}
}
groups = append(groups, g)
}
// Sort by reclaimable bytes descending (size × duplicate-count) so
// the biggest wins float to the top of the UI.
sort.Slice(groups, func(i, j int) bool {
return groups[i].Size*int64(len(groups[i].Files)-1) >
groups[j].Size*int64(len(groups[j].Files)-1)
})
ms := time.Since(start).Milliseconds()
slog.Info("dup.scan done", "groups", len(groups), "ms", ms)
c.JSON(http.StatusOK, gin.H{
"groups": groups,
"scannedMs": ms,
})
}
}
type dupArchiveBody struct {
Paths []string `json:"paths"`
}
type dupMoved struct {
From string `json:"from"`
To string `json:"to"`
}
type dupArchiveErr struct {
Path string `json:"path"`
Error string `json:"error"`
}
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body dupArchiveBody
if err := c.ShouldBindJSON(&body); err != nil || len(body.Paths) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "paths[] required"})
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).
stamp := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
targetDir := filepath.Join(cfg.OriginalsRoot, quarantineDir, stamp)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// archiveOne moves a single file into the quarantine batch dir
// and returns the new relative path. Disambiguates same-basename
// collisions within the batch so two `IMG_0001.jpg` from
// different folders don't clobber each other.
archiveOne := func(rel string) (string, error) {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
return "", errors.New("invalid path")
}
base := filepath.Base(abs)
dest := filepath.Join(targetDir, base)
for i := 1; ; i++ {
if _, err := os.Stat(dest); errors.Is(err, os.ErrNotExist) {
break
} else if err != nil {
return "", err
}
stem := base[:len(base)-len(filepath.Ext(base))]
dest = filepath.Join(targetDir, stem+"__"+itoa(i)+filepath.Ext(base))
}
if err := os.Rename(abs, dest); err != nil {
// EXDEV fallback — copy+remove for libraries that span
// filesystems (e.g. originals on a different mount).
if err2 := copyFile(abs, dest); err2 != nil {
return "", err
}
if err2 := os.Remove(abs); err2 != nil {
return "", errors.New("moved but source remove failed: " + err2.Error())
}
}
relDest, _ := filepath.Rel(cfg.OriginalsRoot, dest)
return relDest, nil
}
moved := []dupMoved{}
errs := []dupArchiveErr{}
for _, rel := range body.Paths {
relDest, err := archiveOne(rel)
if err != nil {
errs = append(errs, dupArchiveErr{Path: rel, Error: err.Error()})
continue
}
moved = append(moved, dupMoved{From: rel, To: relDest})
slog.Info("dup.archive", "from", rel, "to", relDest)
}
// Reindex the entire library so PhotoPrism drops rows for the
// archived files. cleanup:true is critical — the files still
// exist on disk, just under .duplicates/ which the indexer
// ignores.
if len(moved) > 0 {
go func() {
if err := pp.reindex(context.Background(), token, "/"); err != nil {
slog.Warn("dup.archive reindex failed", "err", err)
}
}()
}
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
}
}

178
sidecar/handlers_folders.go Normal file
View File

@@ -0,0 +1,178 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
)
// pathParam pulls the URL-encoded :rel out of the Gin context and
// unescapes it. UseRawPath is on at the router level (see main.go) so the
// raw value still carries `%2F` for nested paths; we decode here.
func pathParam(c *gin.Context, key string) (string, bool) {
raw := c.Param(key)
if raw == "" {
return "", false
}
dec, err := url.PathUnescape(raw)
if err != nil {
return "", false
}
return dec, true
}
type folderCreateBody struct {
Path string `json:"path"`
}
func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body folderCreateBody
if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
return
}
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
if _, err := os.Stat(abs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := os.Mkdir(abs, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
rel, _ := filepath.Rel(cfg.OriginalsRoot, abs)
slog.Info("folder.create", "path", rel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
}
}
type folderRenameBody struct {
NewName string `json:"newName"`
}
func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
var body folderRenameBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
newName, ok := sanitizeFilename(body.NewName)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain dirname"})
return
}
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
return
}
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
slog.Info("folder.rename", "from", oldRel, "to", newRel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldPath": oldRel,
"newPath": newRel,
})
}
}
func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
rel, ok := pathParam(c, "rel")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return
}
if abs == cfg.OriginalsRoot {
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
return
}
st, err := os.Stat(abs)
if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
entries, err := os.ReadDir(abs)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(entries) > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"})
return
}
if err := os.Remove(abs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slog.Info("folder.delete", "path", rel)
go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel))
c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel})
}
}
// fireReindex wraps pp.reindex with logging and a detached context so
// it can run in a goroutine after the response has gone out. The Node
// prototype kicks reindex with `void reindex(...)` and never awaits;
// matching that here keeps the apparent latency of mutating endpoints
// low (PhotoPrism's index can take seconds on a big folder).
func fireReindex(_ *Config, pp *ppClient, token, parentRel string) {
// pp.call's client already enforces a 60s timeout, so the parent
// context can be detached from the request — the handler has long
// since written its response.
if err := pp.reindex(context.Background(), token, parentRel); err != nil {
slog.Warn("reindex failed", "path", parentRel, "err", err)
}
}

262
sidecar/handlers_heap.go Normal file
View File

@@ -0,0 +1,262 @@
package main
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
type heapConvertBody struct {
TargetFolder string `json:"targetFolder"`
Mode string `json:"mode"` // "move" or "copy"
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
DeleteHeap bool `json:"deleteHeap"`
}
type heapPhoto struct {
UID string `json:"UID"`
Files []ppFile `json:"Files"`
}
type heapErr struct {
UID string `json:"uid"`
Reason string `json:"reason"`
}
// copyFile is the os.Rename fallback for cross-device moves and the
// primary path for "copy" mode. Streams so a 4GB video doesn't pin
// memory; preserves mode bits, sets the modification time to now (we're
// creating a new inode either way).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, st.Mode())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst)
return err
}
return out.Close()
}
func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
albumUID := c.Param("uid")
var body heapConvertBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
mode := body.Mode
if mode != "copy" {
mode = "move"
}
deleteHeap := mode == "move" && body.DeleteHeap
var subfolder string
if body.Subfolder != "" {
s, ok := sanitizeFilename(body.Subfolder)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
return
}
subfolder = s
}
// Resolve destination. resolveUnderRoot ensures the target lives
// inside ORIGINALS_ROOT and that its parent is a real directory.
// Empty / "/" / "." are valid here — they mean "drop these into
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
// rejects those for safety, so handle the root case explicitly.
var targetAbs string
trimmed := strings.Trim(body.TargetFolder, "/")
if trimmed == "" || trimmed == "." {
targetAbs = cfg.OriginalsRoot
} else {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return
}
targetAbs = abs
}
destAbs := targetAbs
if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder)
if err := os.MkdirAll(destAbs, 0o755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
// Pull the heap's photos via the q=album:UID query. count=1000 covers
// every realistic heap; merged=true expands stacked variants so we
// move the JPG/HEIC sibling alongside the primary.
q := url.QueryEscape("album:" + albumUID)
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !resp.OK {
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
return
}
var photos []heapPhoto
if err := json.Unmarshal(resp.Body, &photos); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
return
}
sourceParents := map[string]struct{}{}
errs := []heapErr{}
moved, copied := 0, 0
for _, photo := range photos {
// Pick the file to physically move. PhotoPrism's "primary" file
// for a HEIC photo is the generated `.HEIC.jpg` preview that
// lives in storage/sidecar (Root=="sidecar"), not in originals
// — moving that path would fail "file missing on disk" every
// time. Prefer the primary that lives in originals (Root=="/")
// and fall back to the first originals-rooted file. PhotoPrism
// regenerates sidecars on reindex, so they don't need to follow.
var file ppFile
found := false
for _, f := range photo.Files {
if f.Root == "/" && f.Primary {
file, found = f, true
break
}
}
if !found {
for _, f := range photo.Files {
if f.Root == "/" {
file, found = f, true
break
}
}
}
if !found {
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
continue
}
srcRel := file.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
continue
}
st, err := os.Stat(srcAbs)
if err != nil || !st.Mode().IsRegular() {
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
continue
}
if filepath.Dir(srcAbs) == destAbs {
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
continue
}
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
if !ok {
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
continue
}
dstAbs := filepath.Join(destAbs, name)
if mode == "move" {
if err := os.Rename(srcAbs, dstAbs); err != nil {
// Cross-device renames fail with EXDEV — fall back to
// copy+remove so a library that spans filesystems still
// works.
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
continue
}
if err2 := os.Remove(srcAbs); err2 != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
continue
}
}
moved++
} else {
if err := copyFile(srcAbs, dstAbs); err != nil {
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
continue
}
copied++
}
sourceParents[filepath.Dir(srcRel)] = struct{}{}
}
// Reindex the destination + every source parent so PhotoPrism's
// DB catches up. We block on these so the response only goes out
// after the index reflects the move — callers (the frontend's
// invalidateQueries refetch in particular) need the next /photos
// fetch to return the moved files, otherwise the folder view
// looks unchanged. PhotoPrism's index endpoint serialises calls
// internally; running them sequentially matches that contract
// without surprising the server.
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
paths := map[string]struct{}{destRel: {}}
for p := range sourceParents {
paths[p] = struct{}{}
}
if subfolder != "" {
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
paths[parent] = struct{}{}
}
for p := range paths {
reindex := "/"
if p != "" && p != "." {
reindex = "/" + p
}
fireReindex(cfg, pp, token, reindex)
}
heapDeleted := false
if deleteHeap {
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
if err == nil && r.OK {
heapDeleted = true
} else if err != nil {
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
} else {
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
}
}
slog.Info("heap.convert",
"album", albumUID,
"mode", mode,
"moved", moved,
"copied", copied,
"errors", len(errs),
"heap_deleted", heapDeleted,
)
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"errors": errs,
"heap_deleted": heapDeleted,
})
}
}

206
sidecar/handlers_marks.go Normal file
View File

@@ -0,0 +1,206 @@
package main
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// validColors is the four-color palette mule-image always shipped. The
// empty string is the explicit "clear color" sentinel.
var validColors = map[string]struct{}{
"red": {},
"orange": {},
"yellow": {},
"green": {},
}
// markPatch is the request body for all three mutating mark endpoints.
// Pointers distinguish "field omitted" from "field set to zero" — a PUT
// with `{"rating": 0}` clears the rating, but a PUT with `{"color": "red"}`
// alone must NOT wipe an existing rating.
type markPatch struct {
Rating *int `json:"rating,omitempty"`
Color *string `json:"color,omitempty"`
}
func (p *markPatch) sanitize() error {
if p.Rating != nil {
r := *p.Rating
if r < 0 || r > 5 {
return errors.New("rating out of range")
}
}
if p.Color != nil {
c := strings.ToLower(strings.TrimSpace(*p.Color))
if c != "" {
if _, ok := validColors[c]; !ok {
return errors.New("invalid color")
}
}
*p.Color = c
}
return nil
}
// apply merges the patch onto an existing row (or a fresh zero-value
// Mark for an upsert). Returns true if anything in the row still has a
// non-empty value — false signals "delete the row" to the caller.
func (p *markPatch) apply(m *Mark) bool {
if p.Rating != nil {
if *p.Rating > 0 {
r := *p.Rating
m.Rating = &r
} else {
m.Rating = nil
}
}
if p.Color != nil {
if *p.Color != "" {
c := *p.Color
m.Color = &c
} else {
m.Color = nil
}
}
return m.Rating != nil || (m.Color != nil && *m.Color != "")
}
// allMarksJSON renders the entire `marks` table as the wire shape
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
// GET /photos/marks which the web client calls once on session start.
func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
var rows []Mark
if err := db.Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]map[string]any, len(rows))
for i := range rows {
out[rows[i].PhotoUID] = rows[i].asJSON()
}
return out, nil
}
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
marks, err := allMarksJSON(db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, marks)
}
}
func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var m Mark
err := db.Where("photo_uid = ?", uid).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m.asJSON())
}
}
// upsert applies the patch and writes back. Returns the resulting JSON
// shape (empty map if the row was deleted).
func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) {
var m Mark
err := db.Where("photo_uid = ?", uid).First(&m).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
m.PhotoUID = uid
keep := patch.apply(&m)
m.UpdatedAt = time.Now().UTC()
if !keep {
// Drop the row entirely so a re-fetch returns {}.
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
return nil, err
}
return map[string]any{}, nil
}
if err := db.Save(&m).Error; err != nil {
return nil, err
}
return m.asJSON(), nil
}
func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("uid")
var patch markPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch"})
return
}
if err := patch.sanitize(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := upsert(db, uid, &patch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
type bulkBody struct {
IDs []string `json:"ids"`
Patch markPatch `json:"patch"`
}
func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
var body bulkBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
if len(body.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ids[] required"})
return
}
if err := body.Patch.sanitize(); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
applied := make(map[string]map[string]any, len(body.IDs))
// Single transaction so a partial failure rolls back. The client
// expects atomic semantics for a bulk star/colour stamp.
err := db.Transaction(func(tx *gorm.DB) error {
for _, uid := range body.IDs {
if uid == "" {
continue
}
out, err := upsert(tx, uid, &body.Patch)
if err != nil {
return err
}
applied[uid] = out
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"count": len(applied),
"marks": applied,
})
}
}

143
sidecar/handlers_rename.go Normal file
View File

@@ -0,0 +1,143 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// renameBody mirrors the Node prototype's wire contract — a single
// `newName` field carrying the bare basename (no slashes).
type renameBody struct {
NewName string `json:"newName"`
}
// ppPhoto is the partial PhotoPrism photo shape we need to find the
// primary file's on-disk location. Anything we don't read stays
// unspecified so version drift across PhotoPrism builds doesn't break
// JSON unmarshalling.
type ppPhoto struct {
Files []ppFile `json:"Files"`
}
type ppFile struct {
Name string `json:"Name"`
Root string `json:"Root"`
Primary bool `json:"Primary"`
}
func primaryFileOf(p *ppPhoto) (ppFile, bool) {
if p == nil {
return ppFile{}, false
}
for _, f := range p.Files {
if f.Primary {
return f, true
}
}
if len(p.Files) > 0 {
return p.Files[0], true
}
return ppFile{}, false
}
func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
photoUID := c.Param("uid")
var body renameBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
newName, ok := sanitizeFilename(body.NewName)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain filename"})
return
}
// Fetch the photo so we can resolve Files[0].Root + Name into a
// concrete on-disk path. PhotoPrism has no "file by UID" endpoint
// in this build, so the single-photo lookup is the cheapest path.
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos/"+photoUID, token, nil)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if !resp.OK {
c.JSON(resp.Status, gin.H{"error": "photo not found"})
return
}
var photo ppPhoto
if err := json.Unmarshal(resp.Body, &photo); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo"})
return
}
file, ok := primaryFileOf(&photo)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "no files on this photo"})
return
}
root := strings.TrimPrefix(file.Root, "/")
if root == "" || root == "/" {
root = ""
}
relPath := filepath.Join(root, file.Name)
oldAbs := filepath.Join(cfg.OriginalsRoot, relPath)
if !ensureWithinOriginals(cfg.OriginalsRoot, oldAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
return
}
st, err := os.Stat(oldAbs)
if err != nil || !st.Mode().IsRegular() {
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
return
}
newAbs := filepath.Join(filepath.Dir(oldAbs), newName)
if !ensureWithinOriginals(cfg.OriginalsRoot, newAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "new path escapes originals root"})
return
}
if _, err := os.Stat(newAbs); err == nil {
c.JSON(http.StatusConflict, gin.H{"error": "target filename already exists"})
return
} else if !errors.Is(err, os.ErrNotExist) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
newRel := filepath.Join(root, newName)
slog.Info("rename", "from", relPath, "to", newRel)
if err := os.Rename(oldAbs, newAbs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Trigger reindex on the parent so PhotoPrism picks up the new
// filename and drops the orphan row for the old name. Best-effort.
reindexPath := "/"
if root != "" {
reindexPath = "/" + root
}
if err := pp.reindex(c.Request.Context(), token, reindexPath); err != nil {
slog.Warn("rename reindex failed", "err", err)
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"oldName": file.Name,
"newName": newName,
"oldRelPath": relPath,
"newRelPath": newRel,
})
}
}

113
sidecar/main.go Normal file
View File

@@ -0,0 +1,113 @@
// mule-sidecar — Go service for endpoints PhotoPrism does not expose.
//
// Ports the Node prototype (server.mjs) to the stack the merge plan calls
// out: Go + Gin + GORM + MariaDB. Same wire contract as the prototype so
// the SvelteKit web client doesn't need to change.
//
// Auth model is unchanged: the caller's X-Auth-Token is the only authority.
// requireSession validates it against PhotoPrism's /api/v1/photos before
// any destructive op runs.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
cfg, err := loadConfig()
if err != nil {
slog.Error("config", "err", err)
os.Exit(1)
}
db, err := openDB(cfg.DSN)
if err != nil {
slog.Error("db open", "err", err)
os.Exit(1)
}
pp := newPPClient(cfg.PhotoprismBaseURL)
gin.SetMode(gin.ReleaseMode)
r := gin.New()
// Keep `%2F` literal in path params so callers can pass URL-encoded
// nested folder paths (e.g. `foo%2Fbar`) without the router splitting
// them into separate segments. Handlers decode via url.PathUnescape.
r.UseRawPath = true
r.UnescapePathValues = false
r.Use(gin.Recovery())
// Health probe — unauthenticated so a process supervisor can call it
// without needing PhotoPrism to be reachable.
r.GET("/api/sidecar/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"ok": true,
"originalsRoot": cfg.OriginalsRoot,
})
})
// Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
}
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
}
// Graceful shutdown so an in-flight duplicate scan or heap convert
// gets a chance to finish (or at least flush logs) on SIGTERM.
idleClosed := make(chan struct{})
go func() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
slog.Info("shutdown signal received")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
close(idleClosed)
}()
slog.Info("mule-sidecar listening",
"addr", "http://"+addr,
"originals", cfg.OriginalsRoot,
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("listen", "err", err)
os.Exit(1)
}
<-idleClosed
}

115
sidecar/pp.go Normal file
View File

@@ -0,0 +1,115 @@
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"time"
)
// ppClient is the thin HTTP wrapper around PhotoPrism's /api/v1. It is
// deliberately *not* shared across requests with caching: each handler
// forwards the caller's X-Auth-Token, so a single shared http.Client (we
// reuse the stdlib default) plus per-call header injection is all we need.
type ppClient struct {
base string
h *http.Client
}
func newPPClient(base string) *ppClient {
return &ppClient{
base: base,
h: &http.Client{Timeout: 60 * time.Second},
}
}
// ppResp is the trimmed projection of an HTTP response that callers
// actually consume. Status + raw body are exposed so handlers can mirror
// PhotoPrism's status code or parse the body themselves.
type ppResp struct {
OK bool
Status int
Body []byte
}
// call issues an authenticated request against PhotoPrism. body is
// optional; pass nil for GET/DELETE. We don't JSON-decode here — callers
// know the shape they want and decode lazily.
func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body any) (*ppResp, error) {
u, err := url.Parse(c.base)
if err != nil {
return nil, err
}
rel, err := url.Parse(urlPath)
if err != nil {
return nil, err
}
full := u.ResolveReference(rel).String()
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(buf)
}
req, err := http.NewRequestWithContext(ctx, method, full, reader)
if err != nil {
return nil, err
}
req.Header.Set("X-Auth-Token", token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.h.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
buf, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return &ppResp{
OK: resp.StatusCode >= 200 && resp.StatusCode < 300,
Status: resp.StatusCode,
Body: buf,
}, nil
}
// validateSession is the cheapest probe that the supplied token is live:
// list one photo. 401 → bad/expired token. We never read the payload.
func (c *ppClient) validateSession(ctx context.Context, token string) bool {
if token == "" {
return false
}
r, err := c.call(ctx, http.MethodGet, "/api/v1/photos?count=1", token, nil)
if err != nil {
return false
}
return r.OK
}
// reindex tells PhotoPrism to re-walk a single subpath of originals and
// reconcile its DB with the on-disk state. Callers fire this after any
// rename/create/delete so the timeline catches up. `cleanup: true` drops
// orphan rows (e.g. the row for the file's old name after a rename).
//
// Best-effort: errors are surfaced to the caller, who logs but does not
// abort — the file mutation has already happened on disk by the time
// reindex runs.
func (c *ppClient) reindex(ctx context.Context, token, parentRel string) error {
if parentRel == "" {
parentRel = "/"
}
_, err := c.call(ctx, http.MethodPost, "/api/v1/index", token, map[string]any{
"path": parentRel,
"rescan": false,
"cleanup": true,
})
return err
}

View File

@@ -1,845 +0,0 @@
// mule-sidecar — Node.js prototype.
//
// Endpoints PhotoPrism does not expose. Today: file rename on disk.
// Future Go rewrite (per plan) will keep the same wire contract.
//
// Auth: forwards the caller's `X-Auth-Token` to PhotoPrism's session check
// before doing anything destructive. The token belongs to the end user; the
// sidecar does not hold its own credentials in this prototype.
import http from 'node:http';
import { URL, fileURLToPath } from 'node:url';
import { promises as fs } from 'node:fs';
import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs';
import path from 'node:path';
const ORIGINALS_ROOT = path.resolve(
process.env.ORIGINALS_ROOT ?? '/photoprism/originals'
);
const PHOTOPRISM_BASE_URL =
process.env.PHOTOPRISM_BASE_URL ?? 'http://localhost:2342';
const PORT = Number(process.env.SIDECAR_PORT ?? 8000);
// JSON-backed marks store. Holds the mule-image extras PhotoPrism doesn't:
// per-photo rating (0..5) and color label (red/orange/yellow/green/'').
// Lives next to server.mjs so the Go rewrite can migrate it into MariaDB
// without touching ORIGINALS_ROOT.
const SIDECAR_DIR = path.dirname(fileURLToPath(import.meta.url));
const MARKS_FILE = path.join(SIDECAR_DIR, 'data', 'marks.json');
// ── helpers ──────────────────────────────────────────────────────────────────
function json(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
async function readJson(req) {
const chunks = [];
for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks).toString('utf8');
return raw ? JSON.parse(raw) : {};
}
async function pp(method, urlPath, token, body) {
const url = new URL(urlPath, PHOTOPRISM_BASE_URL);
const init = {
method,
headers: {
'X-Auth-Token': token,
'Content-Type': 'application/json'
}
};
if (body !== undefined) init.body = JSON.stringify(body);
const r = await fetch(url, init);
const text = await r.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return { ok: r.ok, status: r.status, data };
}
async function validateSession(token) {
if (!token) return false;
// Cheapest auth probe: list 1 photo. 401 if the token is bad.
const r = await pp('GET', '/api/v1/photos?count=1', token);
return r.ok;
}
/**
* Enforce: NewName is a bare filename (no path separators, no leading dot,
* no surprising chars). PhotoPrism's index works fine with most filename
* shapes, but we lock down the obvious dangerous ones.
*/
function sanitizeFilename(name) {
if (typeof name !== 'string') return null;
const trimmed = name.trim();
if (!trimmed) return null;
if (trimmed.length > 240) return null;
if (trimmed.startsWith('.')) return null;
if (/[\\/\x00]/.test(trimmed)) return null;
if (trimmed === '..' || trimmed === '.') return null;
return trimmed;
}
// ── Marks store (rating + color label) ───────────────────────────────────────
// In-memory cache backed by an atomic JSON file write. Single-threaded Node
// means we don't need an external lock — sequential awaits serialize writes.
/** @type {Record<string, { rating?: number; color?: string; updatedAt: string }>} */
let MARKS_CACHE = null;
let marksLoadPromise = null;
async function loadMarks() {
if (MARKS_CACHE !== null) return MARKS_CACHE;
if (marksLoadPromise) return marksLoadPromise;
marksLoadPromise = (async () => {
await fs.mkdir(path.dirname(MARKS_FILE), { recursive: true });
try {
const raw = await fs.readFile(MARKS_FILE, 'utf8');
MARKS_CACHE = JSON.parse(raw);
} catch (err) {
if (err.code === 'ENOENT') MARKS_CACHE = {};
else throw err;
}
return MARKS_CACHE;
})();
return marksLoadPromise;
}
async function persistMarks() {
const tmp = MARKS_FILE + '.tmp';
await fs.writeFile(tmp, JSON.stringify(MARKS_CACHE ?? {}, null, 2));
await fs.rename(tmp, MARKS_FILE);
}
/** Normalize partial input. Strips unknown fields, clamps rating to 0..5,
* whitelists colors to mule-image's four-color palette. */
function sanitizeMarkPatch(patch) {
if (!patch || typeof patch !== 'object') return null;
const out = {};
if ('rating' in patch) {
const r = Math.round(Number(patch.rating));
if (!Number.isFinite(r) || r < 0 || r > 5) return null;
out.rating = r;
}
if ('color' in patch) {
const c = typeof patch.color === 'string' ? patch.color.toLowerCase() : '';
if (c !== '' && !['red', 'orange', 'yellow', 'green'].includes(c)) return null;
out.color = c;
}
return out;
}
/** Merge a patch onto an existing mark, dropping zero/empty so the JSON
* stays sparse — never write `rating: 0` or `color: ''` to disk, just
* delete the field. */
function mergeMark(prev, patch) {
const merged = { ...(prev ?? {}) };
if ('rating' in patch) {
if (patch.rating > 0) merged.rating = patch.rating;
else delete merged.rating;
}
if ('color' in patch) {
if (patch.color) merged.color = patch.color;
else delete merged.color;
}
const hasAny = 'rating' in merged || 'color' in merged;
if (!hasAny) return null;
merged.updatedAt = new Date().toISOString();
return merged;
}
async function ensureWithinOriginals(absPath) {
const real = await fs.realpath(path.dirname(absPath)).catch(() => null);
if (!real) return false;
return real === ORIGINALS_ROOT || real.startsWith(ORIGINALS_ROOT + path.sep);
}
/**
* Resolve a user-supplied relative path under ORIGINALS_ROOT.
* Returns the absolute resolved path on success, or null if it escapes
* the root, contains traversal sequences, or its parent is missing.
*
* `mustExist=false` is used for the *target* of a rename/create where
* the path itself doesn't yet exist; we still ensure the parent does.
*/
async function resolveUnderRoot(rel, { mustExist = true } = {}) {
if (typeof rel !== 'string') return null;
const clean = rel.replace(/^\/+/, '');
if (!clean || clean === '.' || clean.split('/').some((seg) => seg === '..' || seg === '')) {
return null;
}
const abs = path.resolve(ORIGINALS_ROOT, clean);
const parent = path.dirname(abs);
// Confirm both the abs and its parent resolve back under ORIGINALS_ROOT
// (defends against symlinks pointing out of the library).
const parentReal = await fs.realpath(parent).catch(() => null);
if (!parentReal) return null;
if (
parentReal !== ORIGINALS_ROOT &&
!parentReal.startsWith(ORIGINALS_ROOT + path.sep)
) {
return null;
}
if (mustExist) {
const stat = await fs.stat(abs).catch(() => null);
if (!stat) return null;
}
return abs;
}
// ── handlers ─────────────────────────────────────────────────────────────────
async function handleRename(req, res, photoUid) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
const body = await readJson(req).catch(() => null);
if (!body) return json(res, 400, { error: 'invalid json' });
const newName = sanitizeFilename(body.newName);
if (!newName) return json(res, 400, { error: 'newName must be a plain filename' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
// Fetch the photo to discover the file's path on disk. The single-photo
// endpoint nests Files[0] with Root + Name; the file lookup by UID
// (/api/v1/files/:uid) doesn't exist in this build.
const photoResp = await pp('GET', `/api/v1/photos/${photoUid}`, token);
if (!photoResp.ok) return json(res, photoResp.status, { error: 'photo not found' });
const photo = photoResp.data;
const file =
(photo.Files || []).find((f) => f.Primary) || (photo.Files || [])[0];
if (!file) return json(res, 404, { error: 'no files on this photo' });
// Build the absolute current path.
const root = (file.Root && file.Root !== '/') ? file.Root : '';
const relPath = path.posix.join(root, file.Name);
const oldAbs = path.resolve(path.join(ORIGINALS_ROOT, relPath));
if (!(await ensureWithinOriginals(oldAbs))) {
return json(res, 400, { error: 'path escapes originals root' });
}
const stat = await fs.stat(oldAbs).catch(() => null);
if (!stat || !stat.isFile()) return json(res, 404, { error: 'file missing on disk' });
const newAbs = path.resolve(path.join(path.dirname(oldAbs), newName));
if (!(await ensureWithinOriginals(newAbs))) {
return json(res, 400, { error: 'new path escapes originals root' });
}
// Refuse to clobber an existing file.
if (await fs.stat(newAbs).then(() => true, () => false)) {
return json(res, 409, { error: 'target filename already exists' });
}
const oldName = file.Name;
console.log(`[rename] ${relPath} -> ${path.posix.join(root, newName)}`);
await fs.rename(oldAbs, newAbs);
// Tell PhotoPrism to re-index the parent so the DB picks up the new path.
// `cleanup: true` removes orphan rows for the old filename.
const indexResp = await pp(
'POST',
'/api/v1/index',
token,
{ path: root || '/', rescan: false, cleanup: true }
);
if (!indexResp.ok) {
// Best-effort: file is renamed, index will catch up eventually.
console.warn('[rename] reindex returned', indexResp.status);
}
json(res, 200, {
ok: true,
oldName,
newName,
oldRelPath: relPath,
newRelPath: path.posix.join(root, newName)
});
}
function handleHealth(_req, res) {
json(res, 200, { ok: true, originalsRoot: ORIGINALS_ROOT });
}
// ── Marks handlers (rating + color) ──────────────────────────────────────────
async function handleMarksListAll(req, res) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const all = await loadMarks();
json(res, 200, all);
}
async function handleMarkGet(req, res, uid) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const all = await loadMarks();
json(res, 200, all[uid] ?? {});
}
async function handleMarkPut(req, res, uid) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
const body = await readJson(req).catch(() => null);
const patch = sanitizeMarkPatch(body);
if (patch === null) return json(res, 400, { error: 'invalid patch' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const all = await loadMarks();
const next = mergeMark(all[uid], patch);
if (next === null) delete all[uid];
else all[uid] = next;
await persistMarks();
json(res, 200, all[uid] ?? {});
}
async function handleMarkBulk(req, res) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
const body = await readJson(req).catch(() => null);
if (!body || !Array.isArray(body.ids)) {
return json(res, 400, { error: 'ids[] required' });
}
const patch = sanitizeMarkPatch(body.patch);
if (patch === null) return json(res, 400, { error: 'invalid patch' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const all = await loadMarks();
const applied = {};
for (const uid of body.ids) {
if (typeof uid !== 'string' || !uid) continue;
const next = mergeMark(all[uid], patch);
if (next === null) delete all[uid];
else all[uid] = next;
applied[uid] = all[uid] ?? {};
}
await persistMarks();
json(res, 200, { count: Object.keys(applied).length, marks: applied });
}
// ── Folder mutations ────────────────────────────────────────────────────────
//
// Each endpoint operates on a relative path under ORIGINALS_ROOT, then
// triggers PhotoPrism's re-index of the parent so the DB picks up the
// change. PhotoPrism's "folder" concept is just a directory on disk —
// there's no DB-side folder entity to mutate.
async function reindex(parentRel, token) {
const r = await pp(
'POST',
'/api/v1/index',
token,
{ path: parentRel || '/', rescan: false, cleanup: true }
);
if (!r.ok) console.warn('[folder] reindex returned', r.status);
}
async function handleFolderCreate(req, res) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
const body = await readJson(req).catch(() => null);
if (!body) return json(res, 400, { error: 'invalid json' });
if (typeof body.path !== 'string') return json(res, 400, { error: 'path required' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const abs = await resolveUnderRoot(body.path, { mustExist: false });
if (!abs) return json(res, 400, { error: 'invalid path' });
if (await fs.stat(abs).then(() => true, () => false)) {
return json(res, 409, { error: 'already exists' });
}
await fs.mkdir(abs, { recursive: false });
const rel = path.relative(ORIGINALS_ROOT, abs);
console.log('[folder.create]', rel);
void reindex(path.posix.dirname('/' + rel), token);
json(res, 200, { ok: true, path: rel });
}
async function handleFolderRename(req, res, rel) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
const body = await readJson(req).catch(() => null);
if (!body) return json(res, 400, { error: 'invalid json' });
const newName = sanitizeFilename(body.newName);
if (!newName) return json(res, 400, { error: 'newName must be a plain dirname' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const oldAbs = await resolveUnderRoot(rel);
if (!oldAbs) return json(res, 400, { error: 'invalid path' });
const stat = await fs.stat(oldAbs);
if (!stat.isDirectory()) return json(res, 400, { error: 'not a directory' });
const newAbs = path.join(path.dirname(oldAbs), newName);
if (await fs.stat(newAbs).then(() => true, () => false)) {
return json(res, 409, { error: 'target already exists' });
}
if (
!newAbs.startsWith(ORIGINALS_ROOT + path.sep) &&
newAbs !== ORIGINALS_ROOT
) {
return json(res, 400, { error: 'target escapes root' });
}
await fs.rename(oldAbs, newAbs);
const oldRel = path.relative(ORIGINALS_ROOT, oldAbs);
const newRel = path.relative(ORIGINALS_ROOT, newAbs);
console.log('[folder.rename]', oldRel, '→', newRel);
void reindex(path.posix.dirname('/' + oldRel), token);
json(res, 200, { ok: true, oldPath: oldRel, newPath: newRel });
}
// ── Heap convert (move/copy heap photos into a folder) ─────────────────────
// PhotoPrism has no native "move all album photos into folder X" operation
// — it can't because the file-on-disk layout is its source of truth. The
// flow: list members via q=album:<UID>, fs.rename / fs.copyFile each primary
// file into the destination, optionally delete the album, then reindex
// both source and destination so PhotoPrism's DB catches up.
/** Find a non-clobbering destination for `basename` inside `destDir`. If
* `foo.jpg` exists, try `foo-1.jpg`, `foo-2.jpg`, … up to a sane cap. */
async function uniqueName(destDir, basename) {
const ext = path.extname(basename);
const stem = basename.slice(0, basename.length - ext.length);
for (let i = 0; i < 1000; i++) {
const candidate = i === 0 ? basename : `${stem}-${i}${ext}`;
const abs = path.join(destDir, candidate);
const exists = await fs.stat(abs).then(() => true, () => false);
if (!exists) return { abs, name: candidate };
}
return null;
}
async function handleHeapConvert(req, res, albumUid) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
const body = await readJson(req).catch(() => null);
if (!body) return json(res, 400, { error: 'invalid json' });
const mode = body.mode === 'copy' ? 'copy' : 'move';
const deleteHeap = mode === 'move' && body.deleteHeap === true;
const subfolderRaw = typeof body.subfolder === 'string' ? body.subfolder.trim() : '';
const subfolder = subfolderRaw ? sanitizeFilename(subfolderRaw) : null;
if (subfolderRaw && !subfolder) {
return json(res, 400, { error: 'invalid subfolder name' });
}
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
// Resolve target folder. Picker passes a relative path under ORIGINALS_ROOT.
const targetAbs = await resolveUnderRoot(body.targetFolder);
if (!targetAbs) return json(res, 400, { error: 'invalid targetFolder' });
// Create the optional subfolder (idempotent).
let destAbs = targetAbs;
if (subfolder) {
destAbs = path.join(targetAbs, subfolder);
await fs.mkdir(destAbs, { recursive: true });
}
// Fetch the album's photos. PhotoPrism's q-DSL lets us filter by album
// UID; merged=true expands to one row per file (we need every variant
// in a stack to follow the primary). 1000 covers any realistic heap.
const listResp = await pp(
'GET',
`/api/v1/photos?q=${encodeURIComponent(`album:${albumUid}`)}&count=1000&merged=true`,
token
);
if (!listResp.ok) return json(res, listResp.status, { error: 'list photos failed' });
const photos = Array.isArray(listResp.data) ? listResp.data : [];
// Track source parents so we know which paths to reindex once we're done.
const sourceParents = new Set();
const errors = [];
let moved = 0;
let copied = 0;
for (const photo of photos) {
const file =
(photo.Files || []).find((f) => f.Primary) || (photo.Files || [])[0];
if (!file || typeof file.Name !== 'string') {
errors.push({ uid: photo.UID, reason: 'no primary file' });
continue;
}
// PhotoPrism Files[].Name is already originals-relative.
const srcRel = file.Name;
const srcAbs = path.resolve(path.join(ORIGINALS_ROOT, srcRel));
if (!srcAbs.startsWith(ORIGINALS_ROOT + path.sep) && srcAbs !== ORIGINALS_ROOT) {
errors.push({ uid: photo.UID, reason: 'path escapes originals' });
continue;
}
const stat = await fs.stat(srcAbs).catch(() => null);
if (!stat || !stat.isFile()) {
errors.push({ uid: photo.UID, reason: 'file missing on disk' });
continue;
}
// Avoid no-op moves (file already lives in dest).
if (path.dirname(srcAbs) === destAbs) {
errors.push({ uid: photo.UID, reason: 'already in target' });
continue;
}
const basename = path.basename(srcAbs);
const target = await uniqueName(destAbs, basename);
if (!target) {
errors.push({ uid: photo.UID, reason: 'too many collisions' });
continue;
}
try {
if (mode === 'move') {
await fs.rename(srcAbs, target.abs);
moved += 1;
} else {
await fs.copyFile(srcAbs, target.abs);
copied += 1;
}
sourceParents.add(path.dirname(srcRel));
} catch (err) {
errors.push({
uid: photo.UID,
reason: err instanceof Error ? err.message : String(err)
});
}
}
// Reindex destination + every distinct source parent so PhotoPrism's
// DB catches up to the new on-disk layout.
const destRel = path.relative(ORIGINALS_ROOT, destAbs);
const reindexPaths = new Set(sourceParents);
reindexPaths.add(destRel);
if (subfolder) reindexPaths.add(path.relative(ORIGINALS_ROOT, targetAbs));
for (const p of reindexPaths) {
void reindex(p ? '/' + p : '/', token);
}
// Optionally delete the album after a successful move. We don't gate on
// `errors.length === 0` — partial successes still warrant heap cleanup
// if the user explicitly opted in.
let heap_deleted = false;
if (deleteHeap) {
const delResp = await pp('DELETE', `/api/v1/albums/${albumUid}`, token);
heap_deleted = delResp.ok;
if (!delResp.ok) {
errors.push({ uid: albumUid, reason: `album delete: HTTP ${delResp.status}` });
}
}
console.log(
`[heap.convert] album=${albumUid} mode=${mode} moved=${moved} copied=${copied} errors=${errors.length} heap_deleted=${heap_deleted}`
);
json(res, 200, { moved, copied, errors, heap_deleted });
}
async function handleFolderDelete(req, res, rel) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const abs = await resolveUnderRoot(rel);
if (!abs) return json(res, 400, { error: 'invalid path' });
if (abs === ORIGINALS_ROOT) return json(res, 400, { error: 'refuse to delete root' });
const stat = await fs.stat(abs);
if (!stat.isDirectory()) return json(res, 400, { error: 'not a directory' });
// Safety: only remove if empty. Recursive rm is left to the user via shell
// until M5 lands proper file moves.
const entries = await fs.readdir(abs);
if (entries.length > 0) {
return json(res, 409, { error: 'directory not empty' });
}
await fs.rmdir(abs);
console.log('[folder.delete]', rel);
void reindex(path.posix.dirname('/' + rel), token);
json(res, 200, { ok: true, path: rel });
}
// ── Cross-folder duplicate scan ─────────────────────────────────────────────
//
// PhotoPrism silently drops byte-identical files at index time, so duplicates
// across folders never enter its DB. This sidecar scans ORIGINALS_ROOT
// directly: walk every file, pre-filter by size (files with unique sizes
// can't be hash-duplicates so we skip hashing them), sha1 the rest, and
// return groups of ≥2 files that share a hash.
//
// Resolution archives the unwanted copies into a `.duplicates/` quarantine
// folder under ORIGINALS_ROOT — PhotoPrism's indexer ignores dotfile dirs,
// so the moved files stop appearing in the index. Recoverable by moving
// them back to a regular subfolder + reindex.
const QUARANTINE_DIR = '.duplicates';
const SUPPORTED_EXTS = new Set([
'.jpg', '.jpeg', '.png', '.heic', '.heif', '.tiff', '.tif',
'.gif', '.bmp', '.webp', '.avif',
'.mov', '.mp4', '.m4v', '.avi', '.mkv', '.webm',
'.dng', '.cr2', '.cr3', '.nef', '.arw', '.orf', '.rw2', '.raw'
]);
async function walkFiles(rootAbs) {
/** @type {{ relPath: string, absPath: string, size: number }[]} */
const out = [];
async function recurse(dir) {
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
// Skip dotfile/dotdir (matches PhotoPrism's indexer behaviour
// + our own quarantine dir).
if (ent.name.startsWith('.')) continue;
const abs = path.join(dir, ent.name);
if (ent.isDirectory()) {
await recurse(abs);
continue;
}
if (!ent.isFile()) continue;
const ext = path.extname(ent.name).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) continue;
let st;
try {
st = await fs.stat(abs);
} catch {
continue;
}
out.push({
relPath: path.relative(rootAbs, abs),
absPath: abs,
size: st.size
});
}
}
await recurse(rootAbs);
return out;
}
async function sha1File(absPath) {
return new Promise((resolve, reject) => {
const hash = createHash('sha1');
createReadStream(absPath)
.on('data', (chunk) => hash.update(chunk))
.on('end', () => resolve(hash.digest('hex')))
.on('error', reject);
});
}
async function scanDuplicates(token) {
const all = await walkFiles(ORIGINALS_ROOT);
// Group by size first; hashes only fire for size collisions. For a
// typical library this skips ~95% of the IO+CPU.
/** @type {Map<number, typeof all>} */
const bySize = new Map();
for (const f of all) {
const arr = bySize.get(f.size);
if (arr) arr.push(f);
else bySize.set(f.size, [f]);
}
/** @type {Map<string, { hash: string, size: number, files: typeof all }>} */
const groups = new Map();
for (const [size, group] of bySize) {
if (group.length < 2) continue;
// Concurrently hash each file in this size bucket.
const hashes = await Promise.all(group.map((f) => sha1File(f.absPath)));
for (let i = 0; i < group.length; i++) {
const h = hashes[i];
const g = groups.get(h);
if (g) g.files.push(group[i]);
else groups.set(h, { hash: h, size, files: [group[i]] });
}
}
// Filter to groups with ≥2 files (size collisions where hashes differed
// produce singleton entries we drop here). For each surviving group,
// ask PhotoPrism which path it has indexed — that becomes the default
// "keep" candidate so the user doesn't accidentally archive the only
// indexed copy.
const out = [];
for (const g of groups.values()) {
if (g.files.length < 2) continue;
let indexedPath = null;
try {
const r = await pp(
'GET',
`/api/v1/photos?q=hash:${g.hash}&count=1&merged=true`,
token
);
if (r.ok && Array.isArray(r.data) && r.data[0]) {
const photo = r.data[0];
const primary =
(photo.Files || []).find((f) => f.Primary) ||
(photo.Files || [])[0];
if (primary?.Name) indexedPath = primary.Name;
}
} catch {
/* swallow — best-effort hint */
}
out.push({
hash: g.hash,
size: g.size,
indexedPath,
files: g.files.map((f) => ({ path: f.relPath, size: f.size }))
});
}
// Sort groups by size descending so the biggest disk wins float to top.
out.sort((a, b) => b.size * (b.files.length - 1) - a.size * (a.files.length - 1));
return out;
}
async function handleDupScan(req, res) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const started = Date.now();
console.log('[dup.scan] starting walk under', ORIGINALS_ROOT);
const groups = await scanDuplicates(token);
const ms = Date.now() - started;
console.log(`[dup.scan] ${groups.length} groups in ${ms} ms`);
json(res, 200, { groups, scannedMs: ms });
}
async function handleDupArchive(req, res) {
const token = req.headers['x-auth-token'];
if (!token || Array.isArray(token)) return json(res, 401, { error: 'no token' });
const body = await readJson(req).catch(() => null);
if (!body || !Array.isArray(body.paths)) {
return json(res, 400, { error: 'paths[] required' });
}
if (!(await validateSession(token))) return json(res, 401, { error: 'invalid session' });
const moved = [];
const errors = [];
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const targetDir = path.join(ORIGINALS_ROOT, QUARANTINE_DIR, stamp);
await fs.mkdir(targetDir, { recursive: true });
for (const rel of body.paths) {
try {
const abs = await resolveUnderRoot(rel);
if (!abs) {
errors.push({ path: rel, error: 'invalid path' });
continue;
}
// Use the file's basename as the quarantine name; prepend a
// short slice of its parent dir if a collision would happen so
// two `IMG_0001.jpg` from different folders don't overwrite
// each other in the same quarantine batch.
let dest = path.join(targetDir, path.basename(abs));
let i = 1;
while (await fs.stat(dest).then(() => true, () => false)) {
const parsed = path.parse(path.basename(abs));
dest = path.join(targetDir, `${parsed.name}__${i}${parsed.ext}`);
i++;
}
await fs.rename(abs, dest);
moved.push({
from: rel,
to: path.relative(ORIGINALS_ROOT, dest)
});
console.log('[dup.archive]', rel, '→', path.relative(ORIGINALS_ROOT, dest));
} catch (err) {
errors.push({
path: rel,
error: err instanceof Error ? err.message : String(err)
});
}
}
// Trigger a cleanup reindex so PhotoPrism drops any photo entries whose
// underlying file is now in the quarantine dir (out of its scan path).
if (moved.length > 0) {
void pp('POST', '/api/v1/index', token, {
path: '/',
rescan: false,
cleanup: true
});
}
json(res, 200, { moved, errors });
}
// ── router ───────────────────────────────────────────────────────────────────
const RENAME_RE = /^\/api\/sidecar\/files\/([^/]+)\/rename$/;
const FOLDER_CREATE_RE = /^\/api\/sidecar\/folders$/;
const FOLDER_RENAME_RE = /^\/api\/sidecar\/folders\/(.+)\/rename$/;
const FOLDER_DELETE_RE = /^\/api\/sidecar\/folders\/(.+)$/;
const MARKS_BULK_RE = /^\/api\/sidecar\/photos\/marks\/bulk$/;
const MARKS_ALL_RE = /^\/api\/sidecar\/photos\/marks$/;
const MARKS_ONE_RE = /^\/api\/sidecar\/photos\/([^/]+)\/marks$/;
const HEAP_CONVERT_RE = /^\/api\/sidecar\/albums\/([^/]+)\/convert$/;
const DUP_SCAN_RE = /^\/api\/sidecar\/duplicates\/scan$/;
const DUP_ARCHIVE_RE = /^\/api\/sidecar\/duplicates\/archive$/;
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://localhost');
const p = url.pathname;
if (p === '/api/sidecar/healthz' && req.method === 'GET') {
return handleHealth(req, res);
}
// Cross-folder duplicate detection.
if (DUP_SCAN_RE.test(p) && req.method === 'GET') {
return handleDupScan(req, res);
}
if (DUP_ARCHIVE_RE.test(p) && req.method === 'POST') {
return handleDupArchive(req, res);
}
// Marks routes — bulk match first so "marks/bulk" doesn't get
// captured by the "{uid}/marks" pattern.
if (MARKS_BULK_RE.test(p) && req.method === 'POST') {
return handleMarkBulk(req, res);
}
if (MARKS_ALL_RE.test(p) && req.method === 'GET') {
return handleMarksListAll(req, res);
}
const markM = MARKS_ONE_RE.exec(p);
if (markM) {
if (req.method === 'GET') return handleMarkGet(req, res, markM[1]);
if (req.method === 'PUT') return handleMarkPut(req, res, markM[1]);
}
const fileM = RENAME_RE.exec(p);
if (fileM && req.method === 'POST') {
return handleRename(req, res, fileM[1]);
}
if (FOLDER_CREATE_RE.test(p) && req.method === 'POST') {
return handleFolderCreate(req, res);
}
const renameFolderM = FOLDER_RENAME_RE.exec(p);
if (renameFolderM && req.method === 'POST') {
return handleFolderRename(req, res, decodeURIComponent(renameFolderM[1]));
}
const deleteFolderM = FOLDER_DELETE_RE.exec(p);
if (deleteFolderM && req.method === 'DELETE') {
// Avoid matching the rename URL which has a trailing /rename.
if (!p.endsWith('/rename')) {
return handleFolderDelete(req, res, decodeURIComponent(deleteFolderM[1]));
}
}
const heapConvertM = HEAP_CONVERT_RE.exec(p);
if (heapConvertM && req.method === 'POST') {
return handleHeapConvert(req, res, heapConvertM[1]);
}
json(res, 404, { error: 'no route' });
} catch (err) {
console.error(err);
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(
`mule-sidecar listening on http://127.0.0.1:${PORT} (originals=${ORIGINALS_ROOT})`
);
});

View File

@@ -1,8 +1,12 @@
import { toast } from 'svelte-sonner';
import { batchEdit } from '$lib/services/batch';
import { patchTargets } from '$lib/services/bulk';
import { invalidatePhotos } from '$lib/services/bulk';
import {
addToHeap,
approvePhoto,
batchArchive,
batchDelete,
batchRestore,
likePhoto,
removeFromHeap,
unlikePhoto,
@@ -203,12 +207,77 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
target = !(first?.Archived ?? false);
}
await patchTargets(
ids,
{ Archived: target },
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
(p) => ({ Archived: p.Archived ?? false })
);
// PhotoPrism's photo PUT silently drops the Archived field — the
// only working path is /api/v1/batch/photos/{archive,restore}. The
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
// back, so the toast fired but nothing moved.
try {
if (target) await batchArchive(ids);
else await batchRestore(ids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Archive failed');
return;
}
invalidatePhotos(ids);
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
toast.success(label);
pushUndo(label, async () => {
if (target) await batchRestore(ids);
else await batchArchive(ids);
invalidatePhotos(ids);
});
}
/** Permanently delete cull targets — only callable from the archive
* section (X is rerouted away from archive-toggle there). PhotoPrism
* rejects deletion of un-archived photos with a 4xx, so the section
* gate doubles as a safety guard against accidental deletes from the
* main timeline. Confirm dialog is mandatory — no undo path exists. */
async function deleteCullTargets() {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to delete', {
description: 'Click a photo or select some first'
});
return;
}
const msg =
ids.length === 1
? 'Permanently delete this photo? This cannot be undone.'
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
if (!confirm(msg)) return;
try {
await batchDelete(ids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
return;
}
invalidatePhotos(ids);
toast.success(`Deleted ${ids.length}`);
}
/** Approve cull targets — clears them out of the review pile by
* bumping each photo's quality score above PhotoPrism's review
* threshold. The op is one-way (no /unapprove route), so we don't
* push an undo entry: a re-keyed S would just be a no-op on
* already-approved photos. */
async function approveCullTargets() {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to keep', {
description: 'Click a photo or select some first'
});
return;
}
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
invalidatePhotos(ids);
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
description: errors[0].message
});
return;
}
toast.success(`Kept ${ids.length}`);
}
/** Flip the Favorite (heart) flag on cull targets. Reads the first
@@ -427,6 +496,13 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
case 'X':
if (meta || shift) return;
e.preventDefault();
// Archive section: X becomes permanent delete (Keep/Delete
// is the binary flow there, mirroring Review's Keep/Archive).
// Everywhere else X toggles archive on the cull targets.
if (filters.section === 'archive') {
void deleteCullTargets();
return;
}
void toggleArchive('toggle');
return;
case 'u':
@@ -444,9 +520,26 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
case 's':
case 'S':
if (meta || shift) return;
e.preventDefault();
// Review section repurposes S as the Keep affordance —
// matches the BulkActionBar button and keeps the binary
// Keep/Archive flow on home-row keys (S/X). The heap chord
// is meaningless here anyway (review photos can't sensibly
// be filed before they're approved).
if (filters.section === 'review') {
void approveCullTargets();
return;
}
// Archive section: S = Keep = restore back to the timeline
// (inverse of Delete on X). Same rationale as review —
// heap-filing an archived photo isn't a flow that fits the
// section's intent.
if (filters.section === 'archive') {
void toggleArchive('restore');
return;
}
// Arm the chord. A digit 19 within S_CHORD_MS picks heap N;
// otherwise we fall back to the currently-viewed heap.
e.preventDefault();
clearSChord();
sChordTimer = window.setTimeout(() => {
sChordTimer = null;
@@ -461,6 +554,9 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
if (!tile) return;
const uid = tile.dataset.uid;
if (!uid) return;
// Modifier clicks are the only paths this document-level handler
// owns. Plain clicks bubble to the tile button's onclick, which
// reduces selection to just that tile.
if (e.shiftKey) {
e.preventDefault();
selectRange(uid);
@@ -469,13 +565,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
e.preventDefault();
toggle(uid);
setFocused(uid);
} else if (selection.ids.size > 0) {
// When a multi-selection is active, a plain click reduces it to
// just this tile (matches mule-image's "selection mode" behaviour).
e.preventDefault();
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
}
}

View File

@@ -27,19 +27,24 @@ export interface NearBottomParams {
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
let current: NearBottomParams = params;
let io: IntersectionObserver | null = null;
// IntersectionObserver only emits on state changes. With a 4-viewport
// preload zone, the sentinel typically stays continuously intersecting
// across a whole fetchNextPage cycle: enabled flips false (fetching),
// the IO callback runs but no-ops, enabled flips back true — and no new
// event is emitted because the intersection state never changed. We'd
// stall mid-pagination. Remember the last reported intersection so the
// next `enabled` rising edge can re-fire manually.
let lastIntersecting = false;
function buildObserver(p: NearBottomParams) {
io?.disconnect();
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
io = new IntersectionObserver(
(entries) => {
if (!current.enabled) return;
for (const e of entries) {
if (e.isIntersecting) {
current.onHit();
return;
}
lastIntersecting = e.isIntersecting;
}
if (lastIntersecting && current.enabled) current.onHit();
},
{
root: p.root ?? null,
@@ -57,11 +62,15 @@ export function nearBottom(node: HTMLElement, params: NearBottomParams) {
update(next: NearBottomParams) {
const rootChanged = next.root !== current.root;
const preloadChanged = next.preloadPx !== current.preloadPx;
const enabledRose = !current.enabled && !!next.enabled;
current = next;
// `enabled` and `onHit` are read live inside the callback,
// so they don't require rebuilding the observer. Root and
// preloadPx are baked in at construction.
if (rootChanged || preloadChanged) buildObserver(current);
if (rootChanged || preloadChanged) {
buildObserver(current);
return;
}
// `enabled` rising while the sentinel is still in the preload
// zone — no IO event coming, so fire manually.
if (enabledRose && lastIntersecting) current.onHit();
},
destroy() {
io?.disconnect();

View File

@@ -60,6 +60,11 @@
* `filters.folderPath` matches (the sidebar nav case); the picker
* passes its own selection so the dialog has independent state. */
selectedPath?: string | null;
/** Optional per-path photo count. When provided, each row renders a
* compact badge with the count — matching the heaps section's
* "{n} photos" affordance. Undefined keeps the badge off entirely
* (the picker dialog doesn't need it). */
counts?: Record<string, number>;
}
let {
nodes,
@@ -69,7 +74,8 @@
onDelete,
onCreateChild,
readonly = false,
selectedPath
selectedPath,
counts
}: Props = $props();
// Auto-expanded folders, persisted to localStorage so the tree state
@@ -114,7 +120,7 @@
with the px-2 of Views/Heaps rows; +12px per nested level.
-->
<div
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
@@ -137,7 +143,7 @@
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if}
<button
class="flex flex-1 items-center truncate text-left"
class="flex min-w-0 flex-1 items-center truncate text-left"
class:px-1={hasChildren || depth > 0}
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
@@ -145,11 +151,23 @@
>
<span class="truncate">{node.name}</span>
</button>
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{n >= 1000 ? '1000+' : n}
</span>
{/if}
{#if !readonly}
<!-- Hover-revealed kebab. Reserves zero width when idle so the
row stays compact; expands on hover and stays visible while
the menu is open. Suppressed in readonly mode (picker). -->
<div class="mr-1">
<!-- Hover-revealed kebab. `display: none` until row hover
(or while the menu is open via has-[[data-state=open]])
so the count holds the row's right edge by default
and the kebab pushes it left when it appears.
Suppressed in readonly mode (picker). -->
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Folder actions">
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
@@ -187,6 +205,7 @@
{onCreateChild}
{readonly}
{selectedPath}
{counts}
/>
{/if}
</li>

View File

@@ -1,12 +1,23 @@
<!--
General app preferences. Distinct from the PhotoPrism-library admin dialog:
this one owns settings that affect *this* SvelteKit shell (theme), not the
server. Opened from the bottom of the left sidebar.
General app preferences. The UI tab owns the SvelteKit shell's
light/dark/system theme (mode-watcher) plus the per-user UI knobs
PhotoPrism's /settings exposes. Search and Maps follow the same
pattern — server prefs round-trip via /api/v1/settings.
The Library admin dialog and this one share the ['settings'] cache,
so saves from either invalidate the other.
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { Dialog, Tabs } from 'bits-ui';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { mode, setMode } from 'mode-watcher';
import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
import {
getSettings,
saveSettings,
type PpSettings
} from '$lib/services/photoprism';
interface Props {
open: boolean;
@@ -14,11 +25,98 @@
}
let { open, onClose }: Props = $props();
const qc = useQueryClient();
let activeTab = $state<'ui' | 'search' | 'maps'>('ui');
const themeOptions = [
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
{ value: 'system', label: 'System', Icon: Monitor }
] as const;
// PhotoPrism palette names from its built-in themes. Any value
// outside this list is preserved verbatim (see `withCurrent`).
const ppThemes = [
'default',
'abyss',
'gemstone',
'grayscale',
'lavender',
'legacy',
'neon',
'onyx',
'raspberry',
'shadow',
'yellowstone'
];
// IETF subtags PhotoPrism ships translations for. Extend without
// fear — `withCurrent` keeps unknown values visible.
const ppLanguages = [
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
];
const ppStartPages = [
'default',
'browse',
'albums',
'favorites',
'calendar',
'moments',
'people',
'places',
'labels',
'states',
'library'
];
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
// Returns `opts` with `current` prepended if it's set and not
// already in the list — so e.g. an experimental theme name in the
// server response shows up selected and editable instead of
// silently being overwritten by the dropdown's default.
function withCurrent(opts: string[], current?: string): string[] {
if (!current) return opts;
return opts.includes(current) ? opts : [current, ...opts];
}
const settingsQuery = createQuery<PpSettings>(() => ({
queryKey: ['settings'],
queryFn: getSettings,
enabled: open
}));
let draft = $state<PpSettings | null>(null);
$effect(() => {
if (settingsQuery.data && draft === null) {
draft = structuredClone(settingsQuery.data);
}
});
$effect(() => {
if (!open) draft = null;
});
const saveMut = createMutation(() => ({
mutationFn: (patch: PpSettings) => saveSettings(patch),
onSuccess: (next) => {
qc.setQueryData(['settings'], next);
draft = structuredClone(next);
toast.success('Settings saved');
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Could not save settings')
}));
function resetDraft() {
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
}
const selectClass =
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
</script>
<Dialog.Root
@@ -32,7 +130,7 @@
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[440px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[560px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<div class="flex items-start gap-2">
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
@@ -41,8 +139,8 @@
General settings
</Dialog.Title>
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
Preferences for this app. Library-side settings live under
Folders → ⚙.
Preferences for this app and your PhotoPrism account.
Library admin lives under Folders → ⚙.
</Dialog.Description>
</div>
<Dialog.Close
@@ -53,31 +151,219 @@
</Dialog.Close>
</div>
<section class="space-y-2 text-[12px]">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Appearance
</h3>
<div
class="flex items-center overflow-hidden rounded-md border border-border"
role="group"
aria-label="Theme"
>
{#each themeOptions as opt (opt.value)}
{@const active = mode.current === opt.value}
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => setMode(opt.value)}
<Tabs.Root bind:value={activeTab}>
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
{#each ['ui', 'search', 'maps'] as const as t (t)}
<Tabs.Trigger
value={t}
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
>
<opt.Icon class="h-3.5 w-3.5" />
{opt.label}
</button>
{t}
</Tabs.Trigger>
{/each}
</Tabs.List>
<!-- UI — local app theme (mode-watcher) on top, then the
PhotoPrism per-user UI knobs that go to /settings. -->
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
<section class="space-y-2">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
App theme
</h3>
<div
class="flex items-center overflow-hidden rounded-md border border-border"
role="group"
aria-label="Theme"
>
{#each themeOptions as opt (opt.value)}
{@const active = mode.current === opt.value}
<button
type="button"
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => setMode(opt.value)}
>
<opt.Icon class="h-3.5 w-3.5" />
{opt.label}
</button>
{/each}
</div>
<p class="text-[11px] text-muted-foreground">
Light/dark for this app. Persists locally; no Save needed.
</p>
</section>
{#if settingsQuery.isPending}
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p>
{:else if settingsQuery.isError}
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p>
{:else if draft}
<section class="space-y-3">
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
PhotoPrism UI
</h3>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Theme</span>
<select bind:value={draft.ui!.theme} class={selectClass}>
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Language</span>
<select bind:value={draft.ui!.language} class={selectClass}>
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Time zone</span>
<!-- IANA tz list is ~400 entries, browser support varies; use
a datalist so we get autocomplete without spamming a
gigantic <select>. "Local" is PhotoPrism's special
"follow system" sentinel. -->
<input
type="text"
list="general-tz-list"
placeholder="Local"
bind:value={draft.ui!.timeZone}
class={selectClass}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Start page</span>
<select bind:value={draft.ui!.startPage} class={selectClass}>
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
Always show scrollbars
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.ui!.zoom} />
Allow image zoom
</label>
</section>
{/if}
</Tabs.Content>
{#if settingsQuery.isPending && activeTab !== 'ui'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
</Tabs.Content>
{:else if settingsQuery.isError && activeTab !== 'ui'}
<Tabs.Content value={activeTab} class="outline-none">
<p class="px-1 text-[12px] text-destructive">
Could not load settings.
</p>
</Tabs.Content>
{:else if draft}
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.listView} />
Default to list view
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showTitles} />
Show titles
</label>
<label class="flex items-center gap-2">
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
Show captions
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Batch size (-1 = server default)
</span>
<input
type="number"
bind:value={draft.search!.batchSize}
class={selectClass}
/>
</label>
</Tabs.Content>
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">Style</span>
<select bind:value={draft.maps!.style} class={selectClass}>
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
<option value={v}>{v}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1">
<span class="text-muted-foreground">
Animation duration (ms, 0 = off)
</span>
<input
type="number"
bind:value={draft.maps!.animate}
class={selectClass}
/>
</label>
</Tabs.Content>
{/if}
</Tabs.Root>
<!-- Datalist for time-zone autocomplete. Falls back to the
"Local" sentinel when the browser can't enumerate the
IANA list (older Safari, etc.). -->
<datalist id="general-tz-list">
<option value="Local"></option>
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
</datalist>
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
trip). The App theme group above persists itself, so we
only show the action row when there's something to save. -->
{#if draft}
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
<button
type="button"
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
onclick={resetDraft}
disabled={saveMut.isPending}
>
Revert
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={() => draft && saveMut.mutate(draft)}
disabled={saveMut.isPending}
>
{#if saveMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
Save
</button>
</div>
</section>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<script lang="ts" module>
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
// 15.3 and below) return undefined here. The component handles that
// by simply showing only the "Local" sentinel in the datalist.
export function tzOptions(): string[] {
const fn = (Intl as unknown as {
supportedValuesOf?: (k: string) => string[];
}).supportedValuesOf;
if (typeof fn !== 'function') return [];
try {
return fn('timeZone');
} catch {
return [];
}
}
</script>

View File

@@ -92,7 +92,9 @@
}));
function submit() {
if (!heap || !pickedPath) return;
// pickedPath === '' is the root selection; falsy check would
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
if (!heap || pickedPath === null) return;
convertMut.mutate({
uid: heap.UID,
body: {
@@ -154,6 +156,20 @@
No folders. Create one from the sidebar first.
</p>
{:else}
<!-- Root row: lets the user drop the heap directly into
originals/ without picking a subfolder. The empty
string is the sidecar's "root" sentinel — matches
resolveUnderRoot's special case in handlers_heap. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
@@ -213,7 +229,7 @@
type="button"
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
onclick={submit}
disabled={!pickedPath || convertMut.isPending}
disabled={pickedPath === null || convertMut.isPending}
>
{#if convertMut.isPending}
<Loader2 class="h-3 w-3 animate-spin" />

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -10,14 +11,21 @@
deleteFolder,
deleteHeap,
duplicateHeap,
getAllMarks,
getConfig,
getImportInfo,
heapDownloadUrl,
listFolderCounts,
listFolders,
listHeaps,
logout,
renameFolder,
renameHeap,
triggerDownload,
type ImportInfo,
type PhotoMarksMap,
type PpAlbum,
type PpClientConfig,
type PpFolder
} from '$lib/services/photoprism';
import {
@@ -36,6 +44,7 @@
Copy,
Download,
FolderInput,
FolderPlus,
LogOut,
Moon,
Pencil,
@@ -58,10 +67,90 @@
enabled: isAuthenticated()
}));
// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
// 60s — the inbox only changes when files are uploaded or the indexer
// runs, neither of which happens often enough to justify a tighter cadence.
const importQuery = createQuery<ImportInfo>(() => ({
queryKey: ['import'],
queryFn: getImportInfo,
enabled: isAuthenticated(),
staleTime: 60_000
}));
// View counts come from PhotoPrism's `/config` response, which carries a
// precomputed counter for every common bucket (all/favorites/archived/
// labels/places/…) updated incrementally on every mutation. Cheap to
// refetch, and gives us a stable total — `/photos` only returns
// per-page row counts via `X-Count`, never a total.
//
// The key sits under the `['photos', …]` prefix so it inherits the
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
// across mutations (favorite, archive, restore, delete, heap add) — the
// counter map refreshes whenever the photo list does. Marks-derived
// counts (ratings/colors) react through the shared `['marks']` cache.
const configQuery = createQuery<PpClientConfig>(() => ({
queryKey: ['photos', 'config'],
queryFn: getConfig,
enabled: isAuthenticated()
}));
const marksQuery = createQuery<PhotoMarksMap>(() => ({
queryKey: ['marks'],
queryFn: getAllMarks,
enabled: isAuthenticated(),
staleTime: 60_000
}));
const ratingsCount = $derived(countRatings(marksQuery.data));
const colorsCount = $derived(countColors(marksQuery.data));
function countRatings(marks: PhotoMarksMap | undefined): number {
if (!marks) return 0;
let n = 0;
for (const m of Object.values(marks)) {
if ((m.rating ?? 0) > 0) n++;
}
return n;
}
function countColors(marks: PhotoMarksMap | undefined): number {
if (!marks) return 0;
let n = 0;
for (const m of Object.values(marks)) {
if (m.color) n++;
}
return n;
}
const folderTree = $derived(
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
);
// Per-folder photo counts. PhotoPrism's /folders/originals reports
// FileCount: 0 for every folder, so we hit /photos?q=path:X per folder
// in parallel. Key the query off the folder-path list so it refetches
// when folders are added/renamed/deleted, and share the ['photos', …]
// prefix so it invalidates alongside the other photo caches whenever a
// mutation lands.
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
queryFn: () => listFolderCounts(folderPaths),
enabled: isAuthenticated() && folderPaths.length > 0,
staleTime: 60_000
}));
const folderCounts = $derived(folderCountsQuery.data ?? {});
// Root count = total photos minus the sum of every subfolder count.
// `config.count.all` is PhotoPrism's authoritative library total
// (kept in sync server-side); subtracting non-root photos gives an
// exact root-only count without a separate API trip.
const rootCount = $derived.by(() => {
const total = configQuery.data?.count?.all ?? 0;
const sub = Object.values(folderCounts).reduce((a, b) => a + b, 0);
return Math.max(0, total - sub);
});
const createMut = createMutation(() => ({
mutationFn: (title: string) => createHeap(title),
onSuccess: (h) => {
@@ -109,6 +198,24 @@
// admin dialog above — opened from the bottom-of-sidebar footer.
let generalSettingsOpen = $state(false);
// Root-folder collapse state. Persisted to its own localStorage key so
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults
// to open so first-time users see the full tree.
const ROOT_OPEN_KEY = 'mule_root_expanded';
let rootExpanded = $state(loadRootExpanded());
function loadRootExpanded(): boolean {
if (!browser) return true;
const raw = localStorage.getItem(ROOT_OPEN_KEY);
return raw === null ? true : raw === '1';
}
function toggleRoot() {
rootExpanded = !rootExpanded;
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
}
const rootActive = $derived(filters.folderPath === '/');
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
async function onSignOut() {
await logout();
await goto('/login', { replaceState: true });
@@ -221,24 +328,40 @@
return true;
}
// Single Views group — section-driven entries and route-driven entries
// mixed in display order. `kind` discriminates which click handler runs
// (sections go through `navigateTo` to seed filter state; routes are
// plain links). Archive intentionally sits at the bottom to keep it out
// of the way of the everyday-browse rows.
// Two groups: Views (everyday browse) and Manage (curation flows that
// decide a photo's fate — review, dedup, unhide, delete). `kind`
// discriminates which click handler runs (sections go through
// `navigateTo` to seed filter state; routes are plain links).
//
// `getCount` is a getter (not a snapshot) so the badge reads the latest
// derived value on every render — the arrays themselves are constant.
// `count.all` already excludes archived/review/hidden (PhotoPrism's
// "everything visible in the main timeline" tally), so it matches what
// the All photos view actually renders. `places` is the count of
// geocoded locations — semantically what the Map view groups by.
// Duplicates has no precomputed counter; we omit its badge.
type ViewItem =
| { kind: 'section'; id: Section; label: string }
| { kind: 'route'; href: string; label: string };
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
// "All photos" is not in this list: the root-folder row at the top
// of the sidebar is the canonical entry into the library, so a
// separate "everything regardless of folder" destination would just
// duplicate it for users whose photos live under the root.
const views: ViewItem[] = [
{ kind: 'section', id: 'all-photos', label: 'All photos' },
{ kind: 'section', id: 'favorites', label: 'Favorites' },
{ kind: 'route', href: '/duplicates', label: 'Duplicates' },
{ kind: 'route', href: '/map', label: 'Map' },
{ kind: 'route', href: '/ratings', label: 'Ratings' },
{ kind: 'route', href: '/colors', label: 'Colors' },
{ kind: 'route', href: '/tags', label: 'Tags' },
{ kind: 'section', id: 'archive', label: 'Archive' }
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
{ kind: 'route', href: '/ratings', label: 'Ratings', getCount: () => ratingsCount },
{ kind: 'route', href: '/colors', label: 'Colors', getCount: () => colorsCount },
{ kind: 'route', href: '/tags', label: 'Tags', getCount: () => configQuery.data?.count?.labels }
];
const manageViews: ViewItem[] = [
{ kind: 'section', id: 'review', label: 'Review', getCount: () => configQuery.data?.count?.review },
{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
];
function isRouteActive(href: string): boolean {
@@ -246,10 +369,176 @@
}
</script>
{#snippet viewRow(v: ViewItem)}
{@const active = v.kind === 'section' ? isActive(v.id) : isRouteActive(v.href)}
{@const count = v.getCount()}
{#if v.kind === 'section'}
<button
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
onclick={() => navigateTo(v.id)}
>
<span class="truncate">{v.label}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
{/if}
</button>
{:else}
<a
href={v.href}
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
>
<span class="truncate">{v.label}</span>
{#if count !== undefined}
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{count}
</span>
{/if}
</a>
{/if}
{/snippet}
<div class="flex h-full flex-col">
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
<!-- Views — section-driven entries + route-driven entries under a
single uppercase eyebrow. Compact rows, no icons. -->
<!-- Folders — top of the sidebar because the root folder is the
default landing view (see filters store init), making it the
primary navigation surface. Root-folder row + subfolder tree;
hover-revealed actions on the header for library settings and
new-top-level-folder. -->
<div>
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Library
</span>
<button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
title="Library settings"
aria-label="Library settings"
>
<Settings class="h-3 w-3" />
</button>
<button
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => onCreateFolder(null)}
title="New top-level folder"
aria-label="New top-level folder"
>
</button>
</div>
<!--
Root-folder entry. Mirrors a subfolder row's hover/active state
via the `/` sentinel; clicking the label filters the timeline to
photos whose Path is empty (handled by applyFolderScope in
+page.svelte). The chevron collapses/expands the subfolder tree
below — same affordance as nested folder rows.
-->
<div
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={rootActive}
class:text-primary-foreground={rootActive}
class:hover:bg-primary={rootActive}
style="padding-left: 8px;"
>
{#if hasSubfolders}
<button
type="button"
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
class:text-muted-foreground={!rootActive}
onclick={toggleRoot}
title={rootExpanded ? 'Collapse' : 'Expand'}
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
>
{rootExpanded ? '▾' : '▸'}
</button>
{/if}
<button
type="button"
class="flex min-w-0 flex-1 items-center truncate text-left"
class:px-1={hasSubfolders}
onclick={() => pickFolder('/')}
title="Photos directly under originals/"
>
<span class="truncate">/</span>
</button>
{#if configQuery.data}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span>
{/if}
<!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted
entirely rather than greyed out. Hidden until row hover (or
menu open) so the count holds the right edge by default. -->
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Root folder actions">
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
onSelect={() => onCreateFolder(null)}
>
<FolderPlus class="h-3.5 w-3.5 text-muted-foreground" />
New subfolder
</Item>
</KebabMenu>
</div>
</div>
{#if foldersQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
{:else if !hasSubfolders}
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p>
{:else if rootExpanded}
<!--
depth=1 visually nests the top-level subfolders one indent
step under the root row above. Labels at depth=1 line up
12px right of the root label, matching the same per-level
step used for deeper folders.
-->
<FolderTree
nodes={folderTree}
depth={1}
onPick={pickFolder}
onRename={onRenameFolder}
onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)}
counts={folderCounts}
/>
{/if}
{#if filters.folderPath && filters.folderPath !== '/'}
<button
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}}
title="Clear folder filter"
>
<span class="truncate">{filters.folderPath}</span>
</button>
{/if}
</div>
<!-- Views — everyday browse entries (section + route mixed) under
a single uppercase eyebrow. Compact rows, no icons. -->
<div>
<div class="px-3 pb-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
@@ -257,27 +546,21 @@
</span>
</div>
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{#if v.kind === 'section'}
<button
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={isActive(v.id)}
class:text-primary-foreground={isActive(v.id)}
class:hover:bg-primary={isActive(v.id)}
onclick={() => navigateTo(v.id)}
>
<span class="truncate">{v.label}</span>
</button>
{:else}
<a
href={v.href}
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={isRouteActive(v.href)}
class:text-primary-foreground={isRouteActive(v.href)}
class:hover:bg-primary={isRouteActive(v.href)}
>
<span class="truncate">{v.label}</span>
</a>
{/if}
{@render viewRow(v)}
{/each}
</div>
<!-- Manage — curation flows that decide a photo's fate. Same
row shape as Views; grouped separately so the binary-decision
destinations (Review/Archive) don't crowd the browse list. -->
<div>
<div class="px-3 pb-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Manage
</span>
</div>
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
{@render viewRow(v)}
{/each}
</div>
@@ -306,28 +589,36 @@
<ul>
{#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)}
<!--
Count + kebab share the right edge: count is the
resting state, kebab swaps in on hover (or while the
menu is open). Moving the count out of the inner
button is what lets it reach the row's right edge
the way Views rows do — and the inner button still
owns the navigate-on-click area.
-->
<li
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
class:bg-primary={active}
class:text-primary-foreground={active}
class:hover:bg-primary={active}
>
<button
class="flex flex-1 items-center gap-2 px-2 text-left"
class="flex min-w-0 flex-1 items-center gap-2 truncate px-2 text-left"
onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
>
<span class="truncate">{heap.Title}</span>
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
</button>
<div class="mr-1">
<span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions">
<Item
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
@@ -373,54 +664,6 @@
{/if}
</div>
<div>
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Folders
</span>
<button
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => (settingsOpen = true)}
title="Library settings"
aria-label="Library settings"
>
<Settings class="h-3 w-3" />
</button>
<button
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
onclick={() => onCreateFolder(null)}
title="New top-level folder"
aria-label="New top-level folder"
>
</button>
</div>
{#if foldersQuery.isPending}
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
{:else if (foldersQuery.data ?? []).length === 0}
<p class="px-2 text-[11px] text-muted-foreground">No subfolders.</p>
{:else}
<FolderTree
nodes={folderTree}
onPick={pickFolder}
onRename={onRenameFolder}
onDelete={onDeleteFolder}
onCreateChild={(parent) => onCreateFolder(parent)}
/>
{/if}
{#if filters.folderPath}
<button
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
onclick={() => {
setFolderPath(null);
void goto('/', { keepFocus: true, noScroll: true });
}}
title="Clear folder filter"
>
<span class="truncate">{filters.folderPath}</span>
</button>
{/if}
</div>
</nav>
<!--
@@ -429,7 +672,7 @@
sign-out) that used to live in the top toolbar.
-->
<footer
class="flex shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3 py-2"
class="flex h-9 shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3"
>
<span
class="min-w-0 flex-1 truncate text-[12px] text-foreground"

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { tick } from 'svelte';
import { createQuery } from '@tanstack/svelte-query';
import { getPhoto } from '$lib/services/photoprism';
import {
@@ -19,25 +18,14 @@
enabled: Boolean(preview.uid)
}));
// Track the last visible uid so we can return focus to the matching
// timeline tile when the overlay closes — lets the user keep moving
// with arrow keys without re-clicking.
let lastShown: string | null = null;
// Mirror the currently-shown photo into selection.focused. The
// timeline's tile uses `selection.focused === photo.UID` to draw the
// blue ring, so this keeps the selection in lockstep with whatever
// the user is paging through in preview. The host page (+page.svelte)
// owns the matching scroll-into-view on close so the tile actually
// mounts (it can be windowed out if the user navigated far).
$effect(() => {
if (preview.uid !== null) {
lastShown = preview.uid;
setFocused(preview.uid);
} else if (lastShown) {
const target = lastShown;
lastShown = null;
// Wait for the overlay to unmount before grabbing focus, otherwise
// the browser swallows it as the modal element is removed.
void tick().then(() => {
const tile = document.querySelector<HTMLElement>(`[data-uid="${target}"]`);
tile?.focus({ preventScroll: false });
tile?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
}
if (preview.uid !== null) setFocused(preview.uid);
});
// Keyboard handling lives at the document level so it works regardless

View File

@@ -3,6 +3,7 @@
import { toast } from 'svelte-sonner';
import {
addToHeap,
approvePhoto,
batchArchive,
batchDelete,
batchRestore,
@@ -43,6 +44,16 @@
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
);
const isBulk = $derived(selection.ids.size > 0);
// Review section uses a two-button decision flow (Keep / Archive) —
// every other action is hidden so the choice can't be confused with
// favoriting / heap-adding / restoring. The S keybinding is rerouted
// to approve from gridKeyNav for the same reason.
const isReview = $derived(filters.section === 'review');
// Archive section is the parallel two-button flow: Keep (restore back
// to the timeline) or Delete (permanent, no undo). X is repurposed
// from "archive" to "delete" since the photo is already archived;
// gridKeyNav mirrors the rerouting.
const isArchive = $derived(filters.section === 'archive');
function clearAll() {
clearSelection();
@@ -59,6 +70,24 @@
}
}
async function onApprove() {
const ids = snapshotIds();
if (ids.length === 0) return;
await withBusy(async () => {
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's
// no /unapprove route. We fan out per-photo because there's no
// batch endpoint either. Errors are tallied rather than aborting
// the loop so a single bad UID doesn't block the rest.
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
if (errors.length) {
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
} else {
toast.success(`Kept ${ids.length}`);
}
clearSelection();
});
}
async function onArchive() {
const ids = snapshotIds();
if (ids.length === 0) return;
@@ -160,28 +189,90 @@
</script>
{#if targetCount > 0}
<!--
Inline row at the bottom of the main content column (NOT fixed) so
the sidebars stay reachable. Matches the Toolbar's h-9 / px-3 /
bg-background/80 backdrop-blur visual so it reads as the timeline's
own footer.
-->
<div
class="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background/95 px-6 py-3 shadow-lg backdrop-blur"
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
>
<div class="mx-auto flex max-w-7xl items-center gap-3">
<span class="text-sm font-medium text-foreground">
{#if isBulk}
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
<span class="shrink-0 text-[11px] font-medium text-foreground">
{#if isBulk}
{targetCount} selected
{:else}
Focused photo
{/if}
</span>
<div class="ml-auto flex flex-wrap items-center gap-2">
<!--
`overflow-x-auto` would clip the heap-picker dropdown — CSS
forces `overflow-y: auto` whenever `overflow-x` is non-visible,
so the dropdown's `bottom-full` placement is clipped to zero
pixels above the 36px bar (it renders but is invisible). We
use `flex-wrap` instead so very narrow viewports get a second
row rather than a horizontal scroll, and the dropdown stays
free to escape upward.
-->
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
{#if isReview}
<!-- Review pile = binary decision. Keep approves (Quality →
3+, lands in the main timeline); Archive batches into
the archive section. Everything else (heap, favorite,
restore) is hidden so the choice reads as decisive. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
onclick={onApprove}
title="Keep — accept into timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
>
Archive
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{:else if isArchive}
<!-- Archive section = mirror of review: Keep restores back
to the timeline; Delete is permanent and can't be
undone. X is repurposed from archive→delete since the
photo is already archived; the destructive styling
reinforces the irreversibility. -->
<button
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Keep — restore to timeline"
>
✓ Keep
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
disabled={busy}
onclick={onDelete}
title="Permanently delete (no undo)"
>
Delete
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
{:else}
<div class="relative">
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={() => (heapPickerOpen = !heapPickerOpen)}
title="Add to heap (S then 19 picks a heap)"
>
Add to heap
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
>S&nbsp;N</kbd
>
</button>
@@ -220,70 +311,50 @@
{/if}
</div>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onFavorite}
title="Favorite"
>
♥ Favorite
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>F</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">F</kbd>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onArchive}
title="Archive"
>
Archive
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>X</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy}
onclick={onRestore}
title="Restore"
>
Restore
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>U</kbd
>
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">U</kbd>
</button>
{#if filters.section === 'archive'}
<button
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50"
disabled={busy}
onclick={onDelete}
title="Permanently delete (no undo)"
>
Delete
</button>
{/if}
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || undoStack.entries.length === 0}
onclick={onUndo}
title="Undo last action"
>
Undo
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>⌘Z</kbd
>
</button>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>
{isBulk ? 'Clear' : 'Dismiss'}
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Esc</kbd
>
</button>
</div>
{/if}
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
disabled={busy || undoStack.entries.length === 0}
onclick={onUndo}
title="Undo last action"
>
Undo
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">⌘Z</kbd>
</button>
<button
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
onclick={clearAll}
title={isBulk ? 'Clear selection' : 'Clear focus'}
>
{isBulk ? 'Clear' : 'Dismiss'}
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">Esc</kbd>
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,106 @@
<!--
Flat photo grid for views that don't need infinite-scroll windowing or
month headers — the drill-in screens in /colors, /tags, /ratings. Wears
the same tile look + click semantics as the timeline so the user gets
selection rings, single-click select, dblclick preview, and arrow-key
nav (via `gridKeyNav` on the scroll-root) without per-route plumbing.
The grid carries `data-photo-grid` so gridKeyNav can measure its
column count, and each tile carries `data-tile`+`data-uid` so the
action's document-level click handler can pick up shift/cmd/ctrl
modifiers and route them through the shared selection helpers.
-->
<script lang="ts">
import {
isSelected,
selection,
setAnchor,
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
interface Props {
photos: PpPhoto[];
/** Override the column template. Defaults to the global
* `view.thumbnailSize` so drill-in grids honour the same XSXL
* preset the timeline uses. */
columns?: string;
}
let { photos, columns }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
const order = $derived(photos.map((p) => p.UID));
$effect(() => {
setOrder(order);
});
function onClick(e: MouseEvent, uid: string) {
// Modifier clicks bubble to gridKeyNav's window handler (range +
// toggle paths). Plain clicks reduce the selection to this tile,
// matching the timeline's selectOnly semantics.
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
}
function onDblclick(e: MouseEvent, uid: string) {
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
openPreview(uid, order);
}
</script>
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
{#each photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={(e) => onClick(e, photo.UID)}
ondblclick={(e) => onDblclick(e, photo.UID)}
class:scale-90={sel}
class:ring-2={sel}
class:ring-blue-500={sel}
class:ring-offset-2={sel}
class:ring-offset-background={sel}
class:transition-[transform,box-shadow]={sel}
class:duration-300={sel}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
class:transition={!sel}
class:group-hover:scale-105={!sel}
/>
{#if sel}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/each}
</div>

View File

@@ -192,6 +192,17 @@ 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`);
}
/**
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
* routes that are atomic; preferred over PUT for this one field.
@@ -269,6 +280,65 @@ export async function listFolders(): Promise<PpFolder[]> {
return data.folders ?? [];
}
/**
* Inbox / import staging area. PhotoPrism keeps uploaded-but-not-yet-indexed
* files in a separate `/photoprism/import` root, exposed via
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
* `/folders/originals`, but the photo counts come from `X-Files` and
* `X-Folders` response headers since the body only lists subfolders.
*/
export interface ImportInfo {
files: number;
folders: number;
subfolders: PpFolder[];
}
export async function getImportInfo(): Promise<ImportInfo> {
const res = await http.get<{ folders?: PpFolder[] }>('/folders/import', {
params: { recursive: true, uncached: true, files: false }
});
const num = (h: unknown) => {
const n = typeof h === 'string' ? parseInt(h, 10) : NaN;
return Number.isFinite(n) ? n : 0;
};
return {
files: num(res.headers['x-files'] ?? res.headers['X-Files']),
folders: num(res.headers['x-folders'] ?? res.headers['X-Folders']),
subfolders: res.data.folders ?? []
};
}
/**
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
* endpoint reports `FileCount: 0` even when populated, and the `/photos`
* response has no total-rows header — X-Count is the per-page row count.
* So we fire one `/photos?q=path:X&count=1000` per folder and dedupe by
* UID — `merged=false` returns one row per FILE, so a HEIC+JPG companion
* pair counts twice if we trusted `data.length`. Capped at the server's
* 1000-row ceiling; folders that overflow render as "1000+" in the UI.
*
* `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.
*
* 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>> {
const entries = await Promise.all(
paths.map(async (path) => {
const { data } = await http.get<PpPhoto[]>('/photos', {
params: { count: 1000, offset: 0, merged: false, q: `path:${path}` }
});
const uids = new Set<string>();
for (const p of data) uids.add(p.UID);
return [path, uids.size] as const;
})
);
return Object.fromEntries(entries);
}
// ── Geo ──────────────────────────────────────────────────────────────────────
export interface PpGeoFeature {
@@ -610,8 +680,21 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
// merges server-side, so it's safe to round-trip an incomplete object.
export interface PpSettings {
ui?: { theme?: string; language?: string; scrollbar?: boolean; zoom?: boolean };
search?: { batchSize?: number; listView?: boolean; showTitles?: boolean; showCaptions?: boolean };
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 };
import?: { path?: string; move?: boolean; dest?: string };
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };

View File

@@ -11,7 +11,9 @@
export type Section =
| 'all-photos'
| 'favorites'
| 'review'
| 'archive'
| 'hidden'
| 'heap';
export interface FilterState {
@@ -24,10 +26,14 @@ export interface FilterState {
search: string;
}
// Default landing = root folder (`/`). The Folders group sits at the top
// of the sidebar; landing inside it gives users a stable starting point
// instead of dumping them into the full library. Picking any other view
// (favorites, a heap, "All photos") clears `folderPath` to `null`.
export const filters = $state<FilterState>({
section: 'all-photos',
heapUid: null,
folderPath: null,
folderPath: '/',
search: ''
});
@@ -65,9 +71,21 @@ export function filtersToQ(f: FilterState = filters): string {
case 'favorites':
parts.push('favorite:true');
break;
case 'review':
// PhotoPrism's review pile: photos the indexer flagged as
// uncertain (low quality score). Cleared per-photo via the
// `/approve` endpoint or by archiving.
parts.push('review:true');
break;
case 'archive':
parts.push('archived:true');
break;
case 'hidden':
// Auto-hidden by the indexer (broken files, very low quality
// score). Excluded from every other view — this section is the
// only way to see them without a manual `q=hidden:true`.
parts.push('hidden:true');
break;
case 'heap':
if (f.heapUid) parts.push(`album:${f.heapUid}`);
break;
@@ -75,7 +93,13 @@ export function filtersToQ(f: FilterState = filters): string {
default:
break;
}
if (f.folderPath) parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
// `/` is the root-folder sentinel. PhotoPrism's `path:` operator can't
// express "exact root match" (path:"" / path:/ both fall back to "no
// filter"), so we leave the server query unfiltered and let the
// timeline post-filter to `Path === ''` client-side.
if (f.folderPath && f.folderPath !== '/') {
parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
}
if (f.search) parts.push(quoteIfNeeded(f.search));
return parts.join(' ');
}
@@ -84,13 +108,24 @@ export function filtersToQ(f: FilterState = filters): string {
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
const sectionRaw = params.get('section') as Section | null;
const section: Section =
sectionRaw && ['all-photos', 'favorites', 'archive', 'heap'].includes(sectionRaw)
sectionRaw && ['all-photos', 'favorites', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
? sectionRaw
: 'all-photos';
// Bare URL (no section/folder/heap/q params) lands on the root folder
// — same default the store carries. Any explicit param means the user
// asked for a specific view, so the folder filter clears unless
// `folder=` is supplied on top.
const bare =
!params.has('section') &&
!params.has('folder') &&
!params.has('heap') &&
!params.has('q');
const folderRaw = params.get('folder');
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
return {
section,
heapUid: params.get('heap'),
folderPath: params.get('folder'),
folderPath,
search: params.get('q') ?? ''
};
}

View File

@@ -23,12 +23,37 @@ export interface PpClientConfig {
previewToken: string;
downloadToken: string;
flags?: string;
/**
* Precomputed library counters. PhotoPrism updates these incrementally
* on every mutation, so they're cheap to read and accurate without a
* separate aggregate query. `all` already nets out review/hidden, which
* is what the timeline shows — prefer it over `photos + videos`.
*/
count?: {
all?: number;
photos?: number;
videos?: number;
live?: number;
animated?: number;
audio?: number;
documents?: number;
archived?: number;
hidden?: number;
favorites?: number;
review?: number;
private?: number;
albums?: number;
labels?: number;
moments?: number;
months?: number;
states?: number;
folders?: number;
files?: number;
people?: number;
places?: number;
labels?: number;
cameras?: number;
lenses?: number;
countries?: number;
};
}
@@ -50,6 +75,10 @@ export interface PpPhoto {
Hash?: string;
/** Auto-derived display name (no extension). */
Name?: string;
/** Originals-relative folder this photo lives in. Empty string for
* photos directly under the originals root; otherwise the folder
* path (e.g. `2024/lyon`). Populated on list responses. */
Path?: string;
/** PhotoPrism filename + extension, populated on list responses. */
FileName?: string;
/** User-editable original/preferred name. Persisted in DB + sidecar. */

View File

@@ -29,7 +29,7 @@
setFocused,
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { openPreview, preview } from '$lib/stores/preview.svelte';
import {
setRightSidebarWidth,
setThumbnailSize,
@@ -59,6 +59,7 @@
const next = parseUrlParams(page.url.searchParams);
if (next.section !== undefined) filters.section = next.section;
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
if (next.search !== undefined) filters.search = next.search;
});
@@ -90,13 +91,25 @@
switch (filters.section) {
case 'favorites':
return 'Favorites';
case 'review':
return 'Review';
case 'archive':
return 'Archive';
case 'hidden':
return 'Hidden';
case 'heap': {
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
return heap ? `Heap · ${heap.Title}` : 'Heap';
}
default:
// 'all-photos' is the internal "no section filter" state —
// the visible context now comes from the folder filter
// (root by default). Show the folder path so the title
// reflects what's actually on screen; only the rare
// `folderPath === null` case (e.g. right after deleting a
// heap) still reads as "All photos".
if (filters.folderPath === '/') return 'Folder · /';
if (filters.folderPath) return `Folder · ${filters.folderPath}`;
return 'All photos';
}
}
@@ -133,8 +146,15 @@
* pages can repeat a photo when its file-row span straddles the offset
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
* preserves order. Downstream (`setOrder`, `rows`, preview, click
* handlers) treat this as the single source of truth. */
const photos = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
* handlers) treat this as the single source of truth.
*
* When the user picks the root entry in the folder tree we filter
* to `Path === ''` here — PhotoPrism's `path:` operator can't
* express that match, so the query fetches the whole library and
* we strip subfolder rows post-hoc. */
const photos = $derived<PpPhoto[]>(
applyFolderScope(dedupedPhotos(photosQuery.data?.pages), filters)
);
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
if (!pages) return [];
const seen = new Set<string>();
@@ -148,6 +168,20 @@
}
return out;
}
// Root-folder scope is the only client-side filter we apply, and only
// when the timeline is actually showing a folder view — never when the
// user is in a heap, has a free-form search, or is on a non-default
// section (favorites / archive / review / hidden). Those views are
// scoped server-side via the q-DSL and must not be re-filtered here,
// or labels / search will silently drop subfolder photos when the
// store hasn't fully hydrated from the URL yet.
function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
if (f.folderPath !== '/') return list;
if (f.section !== 'all-photos') return list;
if (f.heapUid) return list;
if (f.search) return list;
return list.filter((p) => !p.Path);
}
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
$effect(() => {
@@ -331,6 +365,25 @@
if (el) scrollTileIntoView(el);
}
// When the preview overlay closes, scroll the just-shown photo back
// into the timeline window. PreviewOverlay already keeps
// selection.focused in lockstep with preview.uid, so we just need to
// make sure that tile is mounted (forcedExpand) and visible — the
// blue ring renders itself once the inner button is in the DOM.
let wasPreviewOpen = $state(false);
$effect(() => {
const open = preview.uid !== null;
const closing = wasPreviewOpen && !open;
wasPreviewOpen = open;
if (!closing) return;
const uid = selection.focused;
if (!uid) return;
untrack(() => {
const i = photos.findIndex((p) => p.UID === uid);
if (i >= 0) void scrollToIndex(i);
});
});
// ── Visual rows for keyboard navigation ──────────────────────────────────
// The CSS Grid lays each photo into a cell with column count derived from
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
@@ -557,17 +610,26 @@
}
function onTileClick(e: MouseEvent, uid: string) {
// Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
// document-level click handler — let them bubble.
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
if (selection.ids.size > 0) return;
// Establish the "starting photo" so a subsequent shift-click extends
// the range from this tile. Reset both focus and anchor — anchor on
// its own would stick to an older toggle/selectOnly tile and the
// shift-range would silently use the wrong starting point. Also
// clear the sticky-column intent so the next arrow press anchors
// off the clicked tile's actual column.
// Plain click: select this tile only. Replaces the previous
// "click opens preview" semantics — preview now lives on dblclick.
// Reset both focus and anchor so a subsequent shift-click extends
// the range from this tile, and clear sticky-column intent so
// arrow nav re-anchors off this tile's actual column.
selection.ids.clear();
selection.ids.add(uid);
setFocused(uid);
setAnchor(uid);
intendedCol = null;
}
function onTileDblclick(e: MouseEvent, uid: string) {
// Modifier-modified dblclicks shouldn't open the preview either —
// gridKeyNav already handled the underlying click.
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
e.preventDefault();
openPreview(uid, photos.map((p) => p.UID));
}
@@ -657,6 +719,12 @@
</Toolbar>
<div class="flex min-h-0 flex-1">
<!--
Main column wraps the scrollable timeline and the action bar so
the bar's width matches the timeline only — the right aside is a
sibling at row level and stays full height when the bar appears.
-->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<main
bind:this={scrollRoot}
class="flex-1 overflow-y-auto outline-none focus:outline-none"
@@ -684,6 +752,13 @@
Archive is empty.
{:else if filters.section === 'favorites'}
No favorites yet. Heart a photo to add it here.
{:else if filters.section === 'review'}
Nothing left to review. Photos PhotoPrism's indexer wasn't sure about
land here — use Keep to accept them into the timeline or Archive to
set them aside.
{:else if filters.section === 'hidden'}
No hidden photos. PhotoPrism auto-hides files it can't index (broken
files, very low quality); they only ever show up here.
{:else if filters.section === 'heap'}
This heap has no photos yet. Select some photos and use the bulk bar's
" Add to heap" button.
@@ -742,6 +817,7 @@
data-tile
data-uid={photo.UID}
onclick={(e) => onTileClick(e, photo.UID)}
ondblclick={(e) => onTileDblclick(e, photo.UID)}
class:scale-90={sel}
class:ring-2={sel}
class:ring-blue-500={sel}
@@ -804,6 +880,8 @@
{/if}
</div>
</main>
<BulkActionBar />
</div>
{#if !view.rightSidebarCollapsed}
<aside
@@ -850,5 +928,3 @@
</aside>
{/if}
</div>
<BulkActionBar />

View File

@@ -6,8 +6,11 @@
type PhotoMarksMap
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// PhotoPrism's Color is auto-derived from image content — the user-set
@@ -110,7 +113,10 @@
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto p-6">
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if marksQuery.isPending || photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading colors…</p>
{:else if marksQuery.isError || photosQuery.isError}
@@ -121,34 +127,11 @@
sidebar to tag it.
</p>
{:else if selectedGroup}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
>
{#each selectedGroup.photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
<button
type="button"
onclick={() =>
openPreview(
photo.UID,
selectedGroup.photos.map((p) => p.UID)
)}
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
/>
</button>
{/each}
</div>
<PhotoGrid photos={selectedGroup.photos} />
{:else}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each groups as group (group.key)}
{@const rep = group.photos[0]}
@@ -178,3 +161,5 @@
</div>
{/if}
</main>
<BulkActionBar />

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner';
import {
cancelImport,
getImportInfo,
startImport,
type ImportInfo
} from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// PhotoPrism's `/import` root holds uploaded-but-not-yet-indexed files
// (separate from /originals which is what the timeline reads). The
// folders endpoint returns the staging tree + counts via headers;
// kicking off the import is a single POST to /import. Mutations land
// in originals after PhotoPrism finishes processing — invalidate the
// originals/folders/config caches so the rest of the UI catches up.
const importQuery = createQuery<ImportInfo>(() => ({
queryKey: ['import'],
queryFn: getImportInfo,
enabled: isAuthenticated(),
// Refetch every 5 s while the page is open so progress is visible
// without the user having to refresh. Cheap call — just headers
// and a folder list.
refetchInterval: 5_000
}));
const qc = useQueryClient();
const importMut = createMutation(() => ({
// `move: true` is the typical workflow — once a file is indexed
// into originals it doesn't need to linger in the staging area.
mutationFn: () => startImport({ move: true }),
onSuccess: (r) => {
toast.success(r.message ?? 'Import started');
void qc.invalidateQueries({ queryKey: ['import'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
void qc.invalidateQueries({ queryKey: ['folders'] });
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Import failed')
}));
const cancelMut = createMutation(() => ({
mutationFn: cancelImport,
onSuccess: () => {
toast.message('Import cancelled');
void qc.invalidateQueries({ queryKey: ['import'] });
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : 'Cancel failed')
}));
const fileCount = $derived(importQuery.data?.files ?? 0);
const folderCount = $derived(importQuery.data?.folders ?? 0);
const empty = $derived(fileCount === 0 && folderCount === 0);
</script>
<Toolbar>
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Inbox
</span>
<span class="text-[11px] text-muted-foreground">
{fileCount} file{fileCount === 1 ? '' : 's'} · {folderCount} folder{folderCount === 1
? ''
: 's'}
</span>
{#snippet trailing()}
<button
type="button"
class="rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-xs text-primary hover:bg-primary/20 disabled:opacity-50"
disabled={empty || importMut.isPending}
onclick={() => importMut.mutate()}
title="Index files from the inbox into the main library"
>
{importMut.isPending ? 'Importing…' : 'Start import'}
</button>
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={!importMut.isPending}
onclick={() => cancelMut.mutate()}
>
Cancel
</button>
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto p-6">
{#if importQuery.isPending}
<p class="text-sm text-muted-foreground">Loading inbox…</p>
{:else if importQuery.isError}
<p class="text-sm text-destructive">
Failed to read inbox: {importQuery.error instanceof Error
? importQuery.error.message
: 'unknown error'}
</p>
{:else if empty}
<div class="space-y-2 text-sm text-muted-foreground">
<p>The inbox is empty.</p>
<p>
Drop files into <code class="rounded bg-muted px-1">/photoprism/import</code> (the
bind mount in <code class="rounded bg-muted px-1">docker-compose.photoprism.yml</code>)
and they'll show up here. Click <strong>Start import</strong> to move them into the
main library; PhotoPrism indexes them, deduplicates against existing originals, and
files them under <code class="rounded bg-muted px-1">originals/{'{Y}/{M}'}</code>.
</p>
</div>
{:else}
<div class="space-y-3">
<p class="text-sm text-muted-foreground">
{fileCount} file{fileCount === 1 ? '' : 's'} ready to import across {folderCount} subfolder{folderCount === 1
? ''
: 's'}.
</p>
{#if importQuery.data && importQuery.data.subfolders.length > 0}
<!-- PhotoPrism doesn't surface a per-folder file count for
/import; we just list the staging subfolders so the
user has a sense of what's in there. -->
<ul class="space-y-1 text-[12px]">
{#each importQuery.data.subfolders as f (f.Path)}
<li class="flex items-center gap-2 rounded border border-border px-2 py-1">
<span class="truncate font-mono">{f.Path || '/'}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</main>

View File

@@ -6,8 +6,11 @@
type PhotoMarksMap
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { view } from '$lib/stores/view.svelte';
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
@@ -102,7 +105,10 @@
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto p-6">
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if marksQuery.isPending || photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading ratings…</p>
{:else if marksQuery.isError || photosQuery.isError}
@@ -113,34 +119,11 @@
(or 15 in bulk mode) to rate it.
</p>
{:else if selectedGroup}
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
>
{#each selectedGroup.photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
<button
type="button"
onclick={() =>
openPreview(
photo.UID,
selectedGroup.photos.map((p) => p.UID)
)}
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
/>
</button>
{/each}
</div>
<PhotoGrid photos={selectedGroup.photos} />
{:else}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each groups as group (group.rating)}
{@const rep = group.photos[0]}
@@ -169,3 +152,5 @@
</div>
{/if}
</main>
<BulkActionBar />

View File

@@ -1,20 +1,52 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import { listLabels, type PpLabel } from '$lib/services/photoprism';
import { listLabels, listPhotos, type PpLabel } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { view } from '$lib/stores/view.svelte';
import { type PpPhoto } from '$lib/types/photoprism';
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte';
// Mirror /colors: a label grid that drills into a photo grid in place,
// instead of navigating away to the timeline. Stays inside /tags so
// the user keeps their place when they back out.
const labelsQuery = createQuery<PpLabel[]>(() => ({
queryKey: ['labels'],
queryFn: listLabels,
enabled: isAuthenticated()
}));
async function openLabel(slug: string) {
// Tags drive search — clicking jumps to the timeline with the label
// term applied. Bookmarkable URL via the existing filter sync.
await goto(`/?q=${encodeURIComponent(`label:${slug}`)}`);
let selectedSlug = $state<string | null>(null);
const selectedLabel = $derived(
selectedSlug !== null
? (labelsQuery.data ?? []).find(
(l) => (l.CustomSlug ?? l.Slug) === selectedSlug
) ?? null
: null
);
// Photo pool for the selected label. PhotoPrism's q-DSL filters
// server-side; we cap at 1000 (the server's hard ceiling) to keep the
// page reactive without paginating in place.
const labelPhotosQuery = createQuery<PpPhoto[]>(() => ({
queryKey: ['photos', 'label', selectedSlug ?? ''],
queryFn: () =>
listPhotos({
q: `label:${selectedSlug}`,
count: 1000,
order: 'newest',
merged: true
}),
enabled: isAuthenticated() && Boolean(selectedSlug)
}));
function pickLabel(slug: string) {
selectedSlug = slug;
}
function clearSelection() {
selectedSlug = null;
}
</script>
@@ -22,6 +54,24 @@
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
Tags
</span>
{#if selectedLabel}
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
onclick={clearSelection}
>
← Back
</button>
<span class="flex items-center gap-1.5 text-[11px] font-medium">
{selectedLabel.Name}
</span>
<span class="text-[11px] text-muted-foreground">
{labelPhotosQuery.data?.length ?? selectedLabel.PhotoCount ?? 0} photo{(labelPhotosQuery
.data?.length ?? 0) === 1
? ''
: 's'}
</span>
{/if}
{#snippet trailing()}
<span class="text-[11px] text-muted-foreground">
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
@@ -29,7 +79,10 @@
{/snippet}
</Toolbar>
<main class="min-h-0 flex-1 overflow-y-auto p-6">
<main
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
use:gridKeyNav={{}}
>
{#if labelsQuery.isPending}
<p class="text-sm text-muted-foreground">Loading labels…</p>
{:else if labelsQuery.isError}
@@ -39,13 +92,26 @@
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
indexer hasn't run on real photos yet, the list will be empty.
</p>
{:else if selectedLabel}
{#if labelPhotosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading photos…</p>
{:else if labelPhotosQuery.isError}
<p class="text-sm text-destructive">Failed to load photos for this label.</p>
{:else if (labelPhotosQuery.data ?? []).length === 0}
<p class="text-sm text-muted-foreground">No photos tagged with this label.</p>
{:else}
<PhotoGrid photos={labelPhotosQuery.data ?? []} />
{/if}
{:else}
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));">
<div
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each labelsQuery.data ?? [] as label (label.UID)}
<button
type="button"
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
onclick={() => openLabel(label.CustomSlug ?? label.Slug)}
onclick={() => pickLabel(label.CustomSlug ?? label.Slug)}
>
{#if label.Thumb}
<img
@@ -66,3 +132,5 @@
</div>
{/if}
</main>
<BulkActionBar />