Compare commits
4 Commits
0766b47bb2
...
d5e4f23c0f
| Author | SHA1 | Date | |
|---|---|---|---|
| d5e4f23c0f | |||
| 5153aeebec | |||
| 4362e475a7 | |||
| 032dce6c85 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -72,6 +72,9 @@ docker-compose.override.yml
|
|||||||
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
|
# Sidecar runtime state (per-user marks etc.) — generated, not seed data.
|
||||||
/sidecar/data/
|
/sidecar/data/
|
||||||
|
|
||||||
|
# Sidecar Go build output.
|
||||||
|
/sidecar/mule-sidecar
|
||||||
|
|
||||||
# Photos (for development)
|
# Photos (for development)
|
||||||
/photos/
|
/photos/
|
||||||
|
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ services:
|
|||||||
# host UID that owns the bind-mounted originals/.
|
# host UID that owns the bind-mounted originals/.
|
||||||
photoprism:
|
photoprism:
|
||||||
userns_mode: keep-id
|
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
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ services:
|
|||||||
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||||
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.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}
|
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:
|
volumes:
|
||||||
- pp_mariadb_data:/var/lib/mysql
|
- pp_mariadb_data:/var/lib/mysql
|
||||||
# The init script creates the mule_sidecar database + user that the Go
|
# The init script creates the mule_sidecar database + user that the Go
|
||||||
@@ -119,6 +125,49 @@ services:
|
|||||||
- "./pp/import:/photoprism/import:Z"
|
- "./pp/import:/photoprism/import:Z"
|
||||||
networks: [photoprism-network]
|
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:
|
networks:
|
||||||
photoprism-network:
|
photoprism-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
13
sidecar/.dockerignore
Normal file
13
sidecar/.dockerignore
Normal 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
28
sidecar/Dockerfile
Normal 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"]
|
||||||
@@ -1,43 +1,116 @@
|
|||||||
# mule-sidecar
|
# 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`,
|
| Method | Path | Purpose |
|
||||||
a Go + Gin + GORM service (matching PhotoPrism's stack) will eventually own:
|
| ------ | --------------------------------------- | --------------------------------------------- |
|
||||||
|
| 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
|
Auth: every endpoint except `healthz` requires the caller's
|
||||||
- Folder mutations under `originals/` (create / rename / delete / move)
|
`X-Auth-Token` header. The sidecar holds no service credentials — it
|
||||||
- **File rename** on disk (PhotoPrism's `OriginalName` is a display-only rename)
|
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.
|
Marks persist to **MariaDB** (`mule_sidecar.marks`); everything else
|
||||||
|
operates on the filesystem under `${ORIGINALS_ROOT}` and triggers a
|
||||||
## What ships today
|
PhotoPrism reindex of the affected parent in the background.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```
|
The sidecar is a `sidecar` service in the PhotoPrism compose stack.
|
||||||
ORIGINALS_ROOT=/home/dtoro/projects/mule-image/photos-sample \
|
Bringing the whole stack up brings it up too:
|
||||||
PHOTOPRISM_BASE_URL=http://localhost:2342 \
|
|
||||||
SIDECAR_PORT=8000 \
|
```sh
|
||||||
node server.mjs
|
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
44
sidecar/auth.go
Normal 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
62
sidecar/config.go
Normal 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
63
sidecar/db.go
Normal 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
213
sidecar/fs.go
Normal 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
45
sidecar/go.mod
Normal 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
101
sidecar/go.sum
Normal 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
230
sidecar/handlers_dups.go
Normal 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
178
sidecar/handlers_folders.go
Normal 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
262
sidecar/handlers_heap.go
Normal 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
206
sidecar/handlers_marks.go
Normal 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
143
sidecar/handlers_rename.go
Normal 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
113
sidecar/main.go
Normal 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
115
sidecar/pp.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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})`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { batchEdit } from '$lib/services/batch';
|
import { batchEdit } from '$lib/services/batch';
|
||||||
import { patchTargets } from '$lib/services/bulk';
|
import { invalidatePhotos } from '$lib/services/bulk';
|
||||||
import {
|
import {
|
||||||
addToHeap,
|
addToHeap,
|
||||||
|
approvePhoto,
|
||||||
|
batchArchive,
|
||||||
|
batchDelete,
|
||||||
|
batchRestore,
|
||||||
likePhoto,
|
likePhoto,
|
||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
unlikePhoto,
|
unlikePhoto,
|
||||||
@@ -203,12 +207,77 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
target = !(first?.Archived ?? false);
|
target = !(first?.Archived ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await patchTargets(
|
// PhotoPrism's photo PUT silently drops the Archived field — the
|
||||||
ids,
|
// only working path is /api/v1/batch/photos/{archive,restore}. The
|
||||||
{ Archived: target },
|
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
|
||||||
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
|
// back, so the toast fired but nothing moved.
|
||||||
(p) => ({ Archived: p.Archived ?? false })
|
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
|
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
||||||
@@ -427,6 +496,13 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
case 'X':
|
case 'X':
|
||||||
if (meta || shift) return;
|
if (meta || shift) return;
|
||||||
e.preventDefault();
|
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');
|
void toggleArchive('toggle');
|
||||||
return;
|
return;
|
||||||
case 'u':
|
case 'u':
|
||||||
@@ -444,9 +520,26 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
case 's':
|
case 's':
|
||||||
case 'S':
|
case 'S':
|
||||||
if (meta || shift) return;
|
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 1–9 within S_CHORD_MS picks heap N;
|
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||||||
// otherwise we fall back to the currently-viewed heap.
|
// otherwise we fall back to the currently-viewed heap.
|
||||||
e.preventDefault();
|
|
||||||
clearSChord();
|
clearSChord();
|
||||||
sChordTimer = window.setTimeout(() => {
|
sChordTimer = window.setTimeout(() => {
|
||||||
sChordTimer = null;
|
sChordTimer = null;
|
||||||
@@ -461,6 +554,9 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
if (!tile) return;
|
if (!tile) return;
|
||||||
const uid = tile.dataset.uid;
|
const uid = tile.dataset.uid;
|
||||||
if (!uid) return;
|
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) {
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
selectRange(uid);
|
selectRange(uid);
|
||||||
@@ -469,13 +565,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggle(uid);
|
toggle(uid);
|
||||||
setFocused(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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,19 +27,24 @@ export interface NearBottomParams {
|
|||||||
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
||||||
let current: NearBottomParams = params;
|
let current: NearBottomParams = params;
|
||||||
let io: IntersectionObserver | null = null;
|
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) {
|
function buildObserver(p: NearBottomParams) {
|
||||||
io?.disconnect();
|
io?.disconnect();
|
||||||
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
||||||
io = new IntersectionObserver(
|
io = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (!current.enabled) return;
|
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (e.isIntersecting) {
|
lastIntersecting = e.isIntersecting;
|
||||||
current.onHit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (lastIntersecting && current.enabled) current.onHit();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: p.root ?? null,
|
root: p.root ?? null,
|
||||||
@@ -57,11 +62,15 @@ export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
|||||||
update(next: NearBottomParams) {
|
update(next: NearBottomParams) {
|
||||||
const rootChanged = next.root !== current.root;
|
const rootChanged = next.root !== current.root;
|
||||||
const preloadChanged = next.preloadPx !== current.preloadPx;
|
const preloadChanged = next.preloadPx !== current.preloadPx;
|
||||||
|
const enabledRose = !current.enabled && !!next.enabled;
|
||||||
current = next;
|
current = next;
|
||||||
// `enabled` and `onHit` are read live inside the callback,
|
if (rootChanged || preloadChanged) {
|
||||||
// so they don't require rebuilding the observer. Root and
|
buildObserver(current);
|
||||||
// preloadPx are baked in at construction.
|
return;
|
||||||
if (rootChanged || preloadChanged) buildObserver(current);
|
}
|
||||||
|
// `enabled` rising while the sentinel is still in the preload
|
||||||
|
// zone — no IO event coming, so fire manually.
|
||||||
|
if (enabledRose && lastIntersecting) current.onHit();
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
io?.disconnect();
|
io?.disconnect();
|
||||||
|
|||||||
@@ -60,6 +60,11 @@
|
|||||||
* `filters.folderPath` matches (the sidebar nav case); the picker
|
* `filters.folderPath` matches (the sidebar nav case); the picker
|
||||||
* passes its own selection so the dialog has independent state. */
|
* passes its own selection so the dialog has independent state. */
|
||||||
selectedPath?: string | null;
|
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 {
|
let {
|
||||||
nodes,
|
nodes,
|
||||||
@@ -69,7 +74,8 @@
|
|||||||
onDelete,
|
onDelete,
|
||||||
onCreateChild,
|
onCreateChild,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
selectedPath
|
selectedPath,
|
||||||
|
counts
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Auto-expanded folders, persisted to localStorage so the tree state
|
// 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.
|
with the px-2 of Views/Heaps rows; +12px per nested level.
|
||||||
-->
|
-->
|
||||||
<div
|
<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:bg-primary={active}
|
||||||
class:text-primary-foreground={active}
|
class:text-primary-foreground={active}
|
||||||
class:hover:bg-primary={active}
|
class:hover:bg-primary={active}
|
||||||
@@ -137,7 +143,7 @@
|
|||||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<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}
|
class:px-1={hasChildren || depth > 0}
|
||||||
onclick={() => onPick(node.path)}
|
onclick={() => onPick(node.path)}
|
||||||
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
||||||
@@ -145,11 +151,23 @@
|
|||||||
>
|
>
|
||||||
<span class="truncate">{node.name}</span>
|
<span class="truncate">{node.name}</span>
|
||||||
</button>
|
</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}
|
{#if !readonly}
|
||||||
<!-- Hover-revealed kebab. Reserves zero width when idle so the
|
<!-- Hover-revealed kebab. `display: none` until row hover
|
||||||
row stays compact; expands on hover and stays visible while
|
(or while the menu is open via has-[[data-state=open]])
|
||||||
the menu is open. Suppressed in readonly mode (picker). -->
|
so the count holds the row's right edge by default
|
||||||
<div class="mr-1">
|
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">
|
<KebabMenu label="Folder actions">
|
||||||
<Item
|
<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"
|
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}
|
{onCreateChild}
|
||||||
{readonly}
|
{readonly}
|
||||||
{selectedPath}
|
{selectedPath}
|
||||||
|
{counts}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
<!--
|
<!--
|
||||||
General app preferences. Distinct from the PhotoPrism-library admin dialog:
|
General app preferences. The UI tab owns the SvelteKit shell's
|
||||||
this one owns settings that affect *this* SvelteKit shell (theme), not the
|
light/dark/system theme (mode-watcher) plus the per-user UI knobs
|
||||||
server. Opened from the bottom of the left sidebar.
|
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">
|
<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 { 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 {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -14,11 +25,98 @@
|
|||||||
}
|
}
|
||||||
let { open, onClose }: Props = $props();
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
let activeTab = $state<'ui' | 'search' | 'maps'>('ui');
|
||||||
|
|
||||||
const themeOptions = [
|
const themeOptions = [
|
||||||
{ value: 'light', label: 'Light', Icon: Sun },
|
{ value: 'light', label: 'Light', Icon: Sun },
|
||||||
{ value: 'dark', label: 'Dark', Icon: Moon },
|
{ value: 'dark', label: 'Dark', Icon: Moon },
|
||||||
{ value: 'system', label: 'System', Icon: Monitor }
|
{ value: 'system', label: 'System', Icon: Monitor }
|
||||||
] as const;
|
] 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>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root
|
<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"
|
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
|
<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">
|
<div class="flex items-start gap-2">
|
||||||
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
@@ -41,8 +139,8 @@
|
|||||||
General settings
|
General settings
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||||
Preferences for this app. Library-side settings live under
|
Preferences for this app and your PhotoPrism account.
|
||||||
Folders → ⚙.
|
Library admin lives under Folders → ⚙.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Close
|
<Dialog.Close
|
||||||
@@ -53,9 +151,24 @@
|
|||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="space-y-2 text-[12px]">
|
<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"
|
||||||
|
>
|
||||||
|
{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">
|
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
Appearance
|
App theme
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<div
|
||||||
class="flex items-center overflow-hidden rounded-md border border-border"
|
class="flex items-center overflow-hidden rounded-md border border-border"
|
||||||
@@ -77,7 +190,180 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-[11px] text-muted-foreground">
|
||||||
|
Light/dark for this app. Persists locally; no Save needed.
|
||||||
|
</p>
|
||||||
</section>
|
</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>
|
||||||
|
{/if}
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog.Root>
|
</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>
|
||||||
|
|||||||
@@ -92,7 +92,9 @@
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function submit() {
|
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({
|
convertMut.mutate({
|
||||||
uid: heap.UID,
|
uid: heap.UID,
|
||||||
body: {
|
body: {
|
||||||
@@ -154,6 +156,20 @@
|
|||||||
No folders. Create one from the sidebar first.
|
No folders. Create one from the sidebar first.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{: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
|
<FolderTree
|
||||||
nodes={folderTree}
|
nodes={folderTree}
|
||||||
onPick={(p) => (pickedPath = p)}
|
onPick={(p) => (pickedPath = p)}
|
||||||
@@ -213,7 +229,7 @@
|
|||||||
type="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"
|
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}
|
onclick={submit}
|
||||||
disabled={!pickedPath || convertMut.isPending}
|
disabled={pickedPath === null || convertMut.isPending}
|
||||||
>
|
>
|
||||||
{#if convertMut.isPending}
|
{#if convertMut.isPending}
|
||||||
<Loader2 class="h-3 w-3 animate-spin" />
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -10,14 +11,21 @@
|
|||||||
deleteFolder,
|
deleteFolder,
|
||||||
deleteHeap,
|
deleteHeap,
|
||||||
duplicateHeap,
|
duplicateHeap,
|
||||||
|
getAllMarks,
|
||||||
|
getConfig,
|
||||||
|
getImportInfo,
|
||||||
heapDownloadUrl,
|
heapDownloadUrl,
|
||||||
|
listFolderCounts,
|
||||||
listFolders,
|
listFolders,
|
||||||
listHeaps,
|
listHeaps,
|
||||||
logout,
|
logout,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
renameHeap,
|
renameHeap,
|
||||||
triggerDownload,
|
triggerDownload,
|
||||||
|
type ImportInfo,
|
||||||
|
type PhotoMarksMap,
|
||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
|
type PpClientConfig,
|
||||||
type PpFolder
|
type PpFolder
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +44,7 @@
|
|||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
|
FolderPlus,
|
||||||
LogOut,
|
LogOut,
|
||||||
Moon,
|
Moon,
|
||||||
Pencil,
|
Pencil,
|
||||||
@@ -58,10 +67,90 @@
|
|||||||
enabled: isAuthenticated()
|
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(
|
const folderTree = $derived(
|
||||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
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(() => ({
|
const createMut = createMutation(() => ({
|
||||||
mutationFn: (title: string) => createHeap(title),
|
mutationFn: (title: string) => createHeap(title),
|
||||||
onSuccess: (h) => {
|
onSuccess: (h) => {
|
||||||
@@ -109,6 +198,24 @@
|
|||||||
// admin dialog above — opened from the bottom-of-sidebar footer.
|
// admin dialog above — opened from the bottom-of-sidebar footer.
|
||||||
let generalSettingsOpen = $state(false);
|
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() {
|
async function onSignOut() {
|
||||||
await logout();
|
await logout();
|
||||||
await goto('/login', { replaceState: true });
|
await goto('/login', { replaceState: true });
|
||||||
@@ -221,24 +328,40 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single Views group — section-driven entries and route-driven entries
|
// Two groups: Views (everyday browse) and Manage (curation flows that
|
||||||
// mixed in display order. `kind` discriminates which click handler runs
|
// decide a photo's fate — review, dedup, unhide, delete). `kind`
|
||||||
// (sections go through `navigateTo` to seed filter state; routes are
|
// discriminates which click handler runs (sections go through
|
||||||
// plain links). Archive intentionally sits at the bottom to keep it out
|
// `navigateTo` to seed filter state; routes are plain links).
|
||||||
// of the way of the everyday-browse rows.
|
//
|
||||||
|
// `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 =
|
type ViewItem =
|
||||||
| { kind: 'section'; id: Section; label: string }
|
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||||
| { kind: 'route'; href: string; label: string };
|
| { 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[] = [
|
const views: ViewItem[] = [
|
||||||
{ kind: 'section', id: 'all-photos', label: 'All photos' },
|
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
|
||||||
{ kind: 'section', id: 'favorites', label: 'Favorites' },
|
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
|
||||||
{ kind: 'route', href: '/duplicates', label: 'Duplicates' },
|
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
|
||||||
{ kind: 'route', href: '/map', label: 'Map' },
|
{ kind: 'route', href: '/ratings', label: 'Ratings', getCount: () => ratingsCount },
|
||||||
{ kind: 'route', href: '/ratings', label: 'Ratings' },
|
{ kind: 'route', href: '/colors', label: 'Colors', getCount: () => colorsCount },
|
||||||
{ kind: 'route', href: '/colors', label: 'Colors' },
|
{ kind: 'route', href: '/tags', label: 'Tags', getCount: () => configQuery.data?.count?.labels }
|
||||||
{ kind: 'route', href: '/tags', label: 'Tags' },
|
];
|
||||||
{ kind: 'section', id: 'archive', label: 'Archive' }
|
|
||||||
|
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 {
|
function isRouteActive(href: string): boolean {
|
||||||
@@ -246,10 +369,176 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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">
|
<div class="flex h-full flex-col">
|
||||||
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
|
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
|
||||||
<!-- Views — section-driven entries + route-driven entries under a
|
<!-- Folders — top of the sidebar because the root folder is the
|
||||||
single uppercase eyebrow. Compact rows, no icons. -->
|
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>
|
||||||
<div class="px-3 pb-1">
|
<div class="px-3 pb-1">
|
||||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
@@ -257,27 +546,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||||
{#if v.kind === 'section'}
|
{@render viewRow(v)}
|
||||||
<button
|
{/each}
|
||||||
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
|
</div>
|
||||||
class:bg-primary={isActive(v.id)}
|
|
||||||
class:text-primary-foreground={isActive(v.id)}
|
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||||
class:hover:bg-primary={isActive(v.id)}
|
row shape as Views; grouped separately so the binary-decision
|
||||||
onclick={() => navigateTo(v.id)}
|
destinations (Review/Archive) don't crowd the browse list. -->
|
||||||
>
|
<div>
|
||||||
<span class="truncate">{v.label}</span>
|
<div class="px-3 pb-1">
|
||||||
</button>
|
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
{:else}
|
Manage
|
||||||
<a
|
</span>
|
||||||
href={v.href}
|
</div>
|
||||||
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||||
class:bg-primary={isRouteActive(v.href)}
|
{@render viewRow(v)}
|
||||||
class:text-primary-foreground={isRouteActive(v.href)}
|
|
||||||
class:hover:bg-primary={isRouteActive(v.href)}
|
|
||||||
>
|
|
||||||
<span class="truncate">{v.label}</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -306,19 +589,28 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||||
{@const active = isActive('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
|
<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:bg-primary={active}
|
||||||
class:text-primary-foreground={active}
|
class:text-primary-foreground={active}
|
||||||
class:hover:bg-primary={active}
|
class:hover:bg-primary={active}
|
||||||
>
|
>
|
||||||
<button
|
<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)}
|
onclick={() => navigateTo('heap', heap.UID)}
|
||||||
ondblclick={() => onRenameHeap(heap)}
|
ondblclick={() => onRenameHeap(heap)}
|
||||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||||
>
|
>
|
||||||
<span class="truncate">{heap.Title}</span>
|
<span class="truncate">{heap.Title}</span>
|
||||||
|
</button>
|
||||||
<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
|
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-primary-foreground/15 text-primary-foreground'
|
||||||
@@ -326,8 +618,7 @@
|
|||||||
>
|
>
|
||||||
{heap.PhotoCount ?? 0}
|
{heap.PhotoCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||||
<div class="mr-1">
|
|
||||||
<KebabMenu label="Heap actions">
|
<KebabMenu label="Heap actions">
|
||||||
<Item
|
<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"
|
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}
|
{/if}
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -429,7 +672,7 @@
|
|||||||
sign-out) that used to live in the top toolbar.
|
sign-out) that used to live in the top toolbar.
|
||||||
-->
|
-->
|
||||||
<footer
|
<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
|
<span
|
||||||
class="min-w-0 flex-1 truncate text-[12px] text-foreground"
|
class="min-w-0 flex-1 truncate text-[12px] text-foreground"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import { getPhoto } from '$lib/services/photoprism';
|
import { getPhoto } from '$lib/services/photoprism';
|
||||||
import {
|
import {
|
||||||
@@ -19,25 +18,14 @@
|
|||||||
enabled: Boolean(preview.uid)
|
enabled: Boolean(preview.uid)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Track the last visible uid so we can return focus to the matching
|
// Mirror the currently-shown photo into selection.focused. The
|
||||||
// timeline tile when the overlay closes — lets the user keep moving
|
// timeline's tile uses `selection.focused === photo.UID` to draw the
|
||||||
// with arrow keys without re-clicking.
|
// blue ring, so this keeps the selection in lockstep with whatever
|
||||||
let lastShown: string | null = null;
|
// 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(() => {
|
$effect(() => {
|
||||||
if (preview.uid !== null) {
|
if (preview.uid !== null) setFocused(preview.uid);
|
||||||
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' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard handling lives at the document level so it works regardless
|
// Keyboard handling lives at the document level so it works regardless
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
addToHeap,
|
addToHeap,
|
||||||
|
approvePhoto,
|
||||||
batchArchive,
|
batchArchive,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
@@ -43,6 +44,16 @@
|
|||||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||||
);
|
);
|
||||||
const isBulk = $derived(selection.ids.size > 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() {
|
function clearAll() {
|
||||||
clearSelection();
|
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() {
|
async function onArchive() {
|
||||||
const ids = snapshotIds();
|
const ids = snapshotIds();
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
@@ -160,11 +189,16 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if targetCount > 0}
|
{#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
|
<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="shrink-0 text-[11px] font-medium text-foreground">
|
||||||
<span class="text-sm font-medium text-foreground">
|
|
||||||
{#if isBulk}
|
{#if isBulk}
|
||||||
{targetCount} selected
|
{targetCount} selected
|
||||||
{:else}
|
{:else}
|
||||||
@@ -172,16 +206,73 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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">
|
<div class="relative">
|
||||||
<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}
|
disabled={busy}
|
||||||
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
||||||
title="Add to heap (S then 1–9 picks a heap)"
|
title="Add to heap (S then 1–9 picks a heap)"
|
||||||
>
|
>
|
||||||
+ Add to 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 N</kbd
|
>S N</kbd
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
@@ -220,70 +311,50 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<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}
|
disabled={busy}
|
||||||
onclick={onFavorite}
|
onclick={onFavorite}
|
||||||
title="Favorite"
|
title="Favorite"
|
||||||
>
|
>
|
||||||
♥ Favorite
|
♥ Favorite
|
||||||
<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">F</kbd>
|
||||||
>F</kbd
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<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}
|
disabled={busy}
|
||||||
onclick={onArchive}
|
onclick={onArchive}
|
||||||
title="Archive"
|
title="Archive"
|
||||||
>
|
>
|
||||||
Archive
|
Archive
|
||||||
<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">X</kbd>
|
||||||
>X</kbd
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<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}
|
disabled={busy}
|
||||||
onclick={onRestore}
|
onclick={onRestore}
|
||||||
title="Restore"
|
title="Restore"
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
<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">U</kbd>
|
||||||
>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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<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 || undoStack.entries.length === 0}
|
disabled={busy || undoStack.entries.length === 0}
|
||||||
onclick={onUndo}
|
onclick={onUndo}
|
||||||
title="Undo last action"
|
title="Undo last action"
|
||||||
>
|
>
|
||||||
Undo
|
Undo
|
||||||
<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">⌘Z</kbd>
|
||||||
>⌘Z</kbd
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
<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"
|
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||||
onclick={clearAll}
|
onclick={clearAll}
|
||||||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||||
>
|
>
|
||||||
{isBulk ? 'Clear' : 'Dismiss'}
|
{isBulk ? 'Clear' : 'Dismiss'}
|
||||||
<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">Esc</kbd>
|
||||||
>Esc</kbd
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
106
web/src/lib/components/timeline/PhotoGrid.svelte
Normal file
106
web/src/lib/components/timeline/PhotoGrid.svelte
Normal 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 XS–XL
|
||||||
|
* 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>
|
||||||
@@ -192,6 +192,17 @@ export async function batchDelete(uids: string[]): Promise<void> {
|
|||||||
await http.post('/batch/photos/delete', toBatchBody(uids));
|
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
|
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
|
||||||
* routes that are atomic; preferred over PUT for this one field.
|
* routes that are atomic; preferred over PUT for this one field.
|
||||||
@@ -269,6 +280,65 @@ export async function listFolders(): Promise<PpFolder[]> {
|
|||||||
return data.folders ?? [];
|
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 ──────────────────────────────────────────────────────────────────────
|
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface PpGeoFeature {
|
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.
|
// merges server-side, so it's safe to round-trip an incomplete object.
|
||||||
|
|
||||||
export interface PpSettings {
|
export interface PpSettings {
|
||||||
ui?: { theme?: string; language?: string; scrollbar?: boolean; zoom?: boolean };
|
ui?: {
|
||||||
search?: { batchSize?: number; listView?: boolean; showTitles?: boolean; showCaptions?: boolean };
|
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 };
|
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
|
||||||
import?: { path?: string; move?: boolean; dest?: string };
|
import?: { path?: string; move?: boolean; dest?: string };
|
||||||
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
|
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
export type Section =
|
export type Section =
|
||||||
| 'all-photos'
|
| 'all-photos'
|
||||||
| 'favorites'
|
| 'favorites'
|
||||||
|
| 'review'
|
||||||
| 'archive'
|
| 'archive'
|
||||||
|
| 'hidden'
|
||||||
| 'heap';
|
| 'heap';
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
@@ -24,10 +26,14 @@ export interface FilterState {
|
|||||||
search: string;
|
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>({
|
export const filters = $state<FilterState>({
|
||||||
section: 'all-photos',
|
section: 'all-photos',
|
||||||
heapUid: null,
|
heapUid: null,
|
||||||
folderPath: null,
|
folderPath: '/',
|
||||||
search: ''
|
search: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,9 +71,21 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
case 'favorites':
|
case 'favorites':
|
||||||
parts.push('favorite:true');
|
parts.push('favorite:true');
|
||||||
break;
|
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':
|
case 'archive':
|
||||||
parts.push('archived:true');
|
parts.push('archived:true');
|
||||||
break;
|
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':
|
case 'heap':
|
||||||
if (f.heapUid) parts.push(`album:${f.heapUid}`);
|
if (f.heapUid) parts.push(`album:${f.heapUid}`);
|
||||||
break;
|
break;
|
||||||
@@ -75,7 +93,13 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
default:
|
default:
|
||||||
break;
|
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));
|
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
@@ -84,13 +108,24 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
||||||
const sectionRaw = params.get('section') as Section | null;
|
const sectionRaw = params.get('section') as Section | null;
|
||||||
const section: Section =
|
const section: Section =
|
||||||
sectionRaw && ['all-photos', 'favorites', 'archive', 'heap'].includes(sectionRaw)
|
sectionRaw && ['all-photos', 'favorites', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
|
||||||
? sectionRaw
|
? sectionRaw
|
||||||
: 'all-photos';
|
: '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 {
|
return {
|
||||||
section,
|
section,
|
||||||
heapUid: params.get('heap'),
|
heapUid: params.get('heap'),
|
||||||
folderPath: params.get('folder'),
|
folderPath,
|
||||||
search: params.get('q') ?? ''
|
search: params.get('q') ?? ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,37 @@ export interface PpClientConfig {
|
|||||||
previewToken: string;
|
previewToken: string;
|
||||||
downloadToken: string;
|
downloadToken: string;
|
||||||
flags?: 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?: {
|
count?: {
|
||||||
|
all?: number;
|
||||||
photos?: number;
|
photos?: number;
|
||||||
videos?: number;
|
videos?: number;
|
||||||
|
live?: number;
|
||||||
|
animated?: number;
|
||||||
|
audio?: number;
|
||||||
|
documents?: number;
|
||||||
|
archived?: number;
|
||||||
|
hidden?: number;
|
||||||
|
favorites?: number;
|
||||||
|
review?: number;
|
||||||
|
private?: number;
|
||||||
albums?: number;
|
albums?: number;
|
||||||
labels?: number;
|
moments?: number;
|
||||||
|
months?: number;
|
||||||
|
states?: number;
|
||||||
|
folders?: number;
|
||||||
|
files?: number;
|
||||||
people?: number;
|
people?: number;
|
||||||
|
places?: number;
|
||||||
|
labels?: number;
|
||||||
|
cameras?: number;
|
||||||
|
lenses?: number;
|
||||||
|
countries?: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +75,10 @@ export interface PpPhoto {
|
|||||||
Hash?: string;
|
Hash?: string;
|
||||||
/** Auto-derived display name (no extension). */
|
/** Auto-derived display name (no extension). */
|
||||||
Name?: string;
|
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. */
|
/** PhotoPrism filename + extension, populated on list responses. */
|
||||||
FileName?: string;
|
FileName?: string;
|
||||||
/** User-editable original/preferred name. Persisted in DB + sidecar. */
|
/** User-editable original/preferred name. Persisted in DB + sidecar. */
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
setFocused,
|
setFocused,
|
||||||
setOrder
|
setOrder
|
||||||
} from '$lib/stores/selection.svelte';
|
} from '$lib/stores/selection.svelte';
|
||||||
import { openPreview } from '$lib/stores/preview.svelte';
|
import { openPreview, preview } from '$lib/stores/preview.svelte';
|
||||||
import {
|
import {
|
||||||
setRightSidebarWidth,
|
setRightSidebarWidth,
|
||||||
setThumbnailSize,
|
setThumbnailSize,
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
const next = parseUrlParams(page.url.searchParams);
|
const next = parseUrlParams(page.url.searchParams);
|
||||||
if (next.section !== undefined) filters.section = next.section;
|
if (next.section !== undefined) filters.section = next.section;
|
||||||
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||||||
|
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||||||
if (next.search !== undefined) filters.search = next.search;
|
if (next.search !== undefined) filters.search = next.search;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,13 +91,25 @@
|
|||||||
switch (filters.section) {
|
switch (filters.section) {
|
||||||
case 'favorites':
|
case 'favorites':
|
||||||
return 'Favorites';
|
return 'Favorites';
|
||||||
|
case 'review':
|
||||||
|
return 'Review';
|
||||||
case 'archive':
|
case 'archive':
|
||||||
return 'Archive';
|
return 'Archive';
|
||||||
|
case 'hidden':
|
||||||
|
return 'Hidden';
|
||||||
case 'heap': {
|
case 'heap': {
|
||||||
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
|
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
|
||||||
return heap ? `Heap · ${heap.Title}` : 'Heap';
|
return heap ? `Heap · ${heap.Title}` : 'Heap';
|
||||||
}
|
}
|
||||||
default:
|
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';
|
return 'All photos';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,8 +146,15 @@
|
|||||||
* pages can repeat a photo when its file-row span straddles the offset
|
* pages can repeat a photo when its file-row span straddles the offset
|
||||||
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
||||||
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
||||||
* handlers) treat this as the single source of truth. */
|
* handlers) treat this as the single source of truth.
|
||||||
const photos = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
|
*
|
||||||
|
* 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[] {
|
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||||
if (!pages) return [];
|
if (!pages) return [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -148,6 +168,20 @@
|
|||||||
}
|
}
|
||||||
return out;
|
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);
|
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -331,6 +365,25 @@
|
|||||||
if (el) scrollTileIntoView(el);
|
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 ──────────────────────────────────
|
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
||||||
// The CSS Grid lays each photo into a cell with column count derived from
|
// The CSS Grid lays each photo into a cell with column count derived from
|
||||||
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
||||||
@@ -557,17 +610,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTileClick(e: MouseEvent, uid: string) {
|
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 (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||||
if (selection.ids.size > 0) return;
|
// Plain click: select this tile only. Replaces the previous
|
||||||
// Establish the "starting photo" so a subsequent shift-click extends
|
// "click opens preview" semantics — preview now lives on dblclick.
|
||||||
// the range from this tile. Reset both focus and anchor — anchor on
|
// Reset both focus and anchor so a subsequent shift-click extends
|
||||||
// its own would stick to an older toggle/selectOnly tile and the
|
// the range from this tile, and clear sticky-column intent so
|
||||||
// shift-range would silently use the wrong starting point. Also
|
// arrow nav re-anchors off this tile's actual column.
|
||||||
// clear the sticky-column intent so the next arrow press anchors
|
selection.ids.clear();
|
||||||
// off the clicked tile's actual column.
|
selection.ids.add(uid);
|
||||||
setFocused(uid);
|
setFocused(uid);
|
||||||
setAnchor(uid);
|
setAnchor(uid);
|
||||||
intendedCol = null;
|
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));
|
openPreview(uid, photos.map((p) => p.UID));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,6 +719,12 @@
|
|||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1">
|
<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
|
<main
|
||||||
bind:this={scrollRoot}
|
bind:this={scrollRoot}
|
||||||
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
||||||
@@ -684,6 +752,13 @@
|
|||||||
Archive is empty.
|
Archive is empty.
|
||||||
{:else if filters.section === 'favorites'}
|
{:else if filters.section === 'favorites'}
|
||||||
No favorites yet. Heart a photo to add it here.
|
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'}
|
{:else if filters.section === 'heap'}
|
||||||
This heap has no photos yet. Select some photos and use the bulk bar's
|
This heap has no photos yet. Select some photos and use the bulk bar's
|
||||||
"+ Add to heap" button.
|
"+ Add to heap" button.
|
||||||
@@ -742,6 +817,7 @@
|
|||||||
data-tile
|
data-tile
|
||||||
data-uid={photo.UID}
|
data-uid={photo.UID}
|
||||||
onclick={(e) => onTileClick(e, photo.UID)}
|
onclick={(e) => onTileClick(e, photo.UID)}
|
||||||
|
ondblclick={(e) => onTileDblclick(e, photo.UID)}
|
||||||
class:scale-90={sel}
|
class:scale-90={sel}
|
||||||
class:ring-2={sel}
|
class:ring-2={sel}
|
||||||
class:ring-blue-500={sel}
|
class:ring-blue-500={sel}
|
||||||
@@ -804,6 +880,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<BulkActionBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !view.rightSidebarCollapsed}
|
{#if !view.rightSidebarCollapsed}
|
||||||
<aside
|
<aside
|
||||||
@@ -850,5 +928,3 @@
|
|||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BulkActionBar />
|
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
type PhotoMarksMap
|
type PhotoMarksMap
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
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 { 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';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
// PhotoPrism's Color is auto-derived from image content — the user-set
|
// PhotoPrism's Color is auto-derived from image content — the user-set
|
||||||
@@ -110,7 +113,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</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}
|
{#if marksQuery.isPending || photosQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
||||||
{:else if marksQuery.isError || photosQuery.isError}
|
{:else if marksQuery.isError || photosQuery.isError}
|
||||||
@@ -121,34 +127,11 @@
|
|||||||
sidebar to tag it.
|
sidebar to tag it.
|
||||||
</p>
|
</p>
|
||||||
{:else if selectedGroup}
|
{:else if selectedGroup}
|
||||||
<div
|
<PhotoGrid photos={selectedGroup.photos} />
|
||||||
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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="grid gap-3"
|
class="grid gap-2"
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
>
|
>
|
||||||
{#each groups as group (group.key)}
|
{#each groups as group (group.key)}
|
||||||
{@const rep = group.photos[0]}
|
{@const rep = group.photos[0]}
|
||||||
@@ -178,3 +161,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<BulkActionBar />
|
||||||
|
|||||||
130
web/src/routes/inbox/+page.svelte
Normal file
130
web/src/routes/inbox/+page.svelte
Normal 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>
|
||||||
@@ -6,8 +6,11 @@
|
|||||||
type PhotoMarksMap
|
type PhotoMarksMap
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
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 { 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';
|
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||||
|
|
||||||
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
|
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
|
||||||
@@ -102,7 +105,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</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}
|
{#if marksQuery.isPending || photosQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
||||||
{:else if marksQuery.isError || photosQuery.isError}
|
{:else if marksQuery.isError || photosQuery.isError}
|
||||||
@@ -113,34 +119,11 @@
|
|||||||
(or 1–5 in bulk mode) to rate it.
|
(or 1–5 in bulk mode) to rate it.
|
||||||
</p>
|
</p>
|
||||||
{:else if selectedGroup}
|
{:else if selectedGroup}
|
||||||
<div
|
<PhotoGrid photos={selectedGroup.photos} />
|
||||||
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>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="grid gap-3"
|
class="grid gap-2"
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
>
|
>
|
||||||
{#each groups as group (group.rating)}
|
{#each groups as group (group.rating)}
|
||||||
{@const rep = group.photos[0]}
|
{@const rep = group.photos[0]}
|
||||||
@@ -169,3 +152,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<BulkActionBar />
|
||||||
|
|||||||
@@ -1,20 +1,52 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
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 { 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';
|
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[]>(() => ({
|
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||||
queryKey: ['labels'],
|
queryKey: ['labels'],
|
||||||
queryFn: listLabels,
|
queryFn: listLabels,
|
||||||
enabled: isAuthenticated()
|
enabled: isAuthenticated()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function openLabel(slug: string) {
|
let selectedSlug = $state<string | null>(null);
|
||||||
// Tags drive search — clicking jumps to the timeline with the label
|
const selectedLabel = $derived(
|
||||||
// term applied. Bookmarkable URL via the existing filter sync.
|
selectedSlug !== null
|
||||||
await goto(`/?q=${encodeURIComponent(`label:${slug}`)}`);
|
? (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>
|
</script>
|
||||||
|
|
||||||
@@ -22,6 +54,24 @@
|
|||||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
Tags
|
Tags
|
||||||
</span>
|
</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()}
|
{#snippet trailing()}
|
||||||
<span class="text-[11px] text-muted-foreground">
|
<span class="text-[11px] text-muted-foreground">
|
||||||
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
|
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
|
||||||
@@ -29,7 +79,10 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Toolbar>
|
</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}
|
{#if labelsQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
||||||
{:else if labelsQuery.isError}
|
{:else if labelsQuery.isError}
|
||||||
@@ -39,13 +92,26 @@
|
|||||||
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
|
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.
|
indexer hasn't run on real photos yet, the list will be empty.
|
||||||
</p>
|
</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}
|
{:else}
|
||||||
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));">
|
<PhotoGrid photos={labelPhotosQuery.data ?? []} />
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="grid gap-2"
|
||||||
|
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||||
|
>
|
||||||
{#each labelsQuery.data ?? [] as label (label.UID)}
|
{#each labelsQuery.data ?? [] as label (label.UID)}
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
{#if label.Thumb}
|
||||||
<img
|
<img
|
||||||
@@ -66,3 +132,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<BulkActionBar />
|
||||||
|
|||||||
Reference in New Issue
Block a user