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/data/
|
||||
|
||||
# Sidecar Go build output.
|
||||
/sidecar/mule-sidecar
|
||||
|
||||
# Photos (for development)
|
||||
/photos/
|
||||
|
||||
|
||||
@@ -26,3 +26,9 @@ services:
|
||||
# host UID that owns the bind-mounted originals/.
|
||||
photoprism:
|
||||
userns_mode: keep-id
|
||||
|
||||
# Sidecar mutates the originals tree (rename / folder ops / heap
|
||||
# convert / .duplicates archive) — same keep-id mapping so its writes
|
||||
# land as the host user, not as a podman-subuid the host doesn't own.
|
||||
sidecar:
|
||||
userns_mode: keep-id
|
||||
|
||||
@@ -33,6 +33,12 @@ services:
|
||||
MARIADB_USER: ${PP_DB_USER:-photoprism}
|
||||
MARIADB_PASSWORD: ${PP_DB_PASSWORD:?set PP_DB_PASSWORD in .env.photoprism}
|
||||
MARIADB_ROOT_PASSWORD: ${PP_DB_ROOT_PASSWORD:?set PP_DB_ROOT_PASSWORD in .env.photoprism}
|
||||
# Loopback-only host port so the mule-sidecar (running as a host process
|
||||
# in M4) can reach `mule_sidecar.*` over TCP. Not exposed beyond
|
||||
# 127.0.0.1; the photoprism container still resolves mariadb by service
|
||||
# name on the photoprism-network bridge.
|
||||
ports:
|
||||
- "127.0.0.1:${PP_DB_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- pp_mariadb_data:/var/lib/mysql
|
||||
# The init script creates the mule_sidecar database + user that the Go
|
||||
@@ -119,6 +125,49 @@ services:
|
||||
- "./pp/import:/photoprism/import:Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
# mule-sidecar — Go + Gin + GORM service for endpoints PhotoPrism's API
|
||||
# does not expose (file rename, folder mutations, heap convert, duplicate
|
||||
# scan, per-photo marks). Same wire contract as the M3 Node prototype;
|
||||
# the SvelteKit dev server proxies /api/sidecar/* here.
|
||||
sidecar:
|
||||
build:
|
||||
context: ./sidecar
|
||||
container_name: pp-sidecar
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
photoprism:
|
||||
condition: service_started
|
||||
# Match PhotoPrism's UID/GID so renames/folder mutations preserve the
|
||||
# ownership the indexer expects on the bind-mounted originals.
|
||||
user: "${PP_UID:-1000}:${PP_GID:-1000}"
|
||||
ports:
|
||||
# Loopback only — Vite (host) proxies /api/sidecar/* to this port.
|
||||
# Behind a reverse proxy in production; never published beyond the
|
||||
# host.
|
||||
- "127.0.0.1:${SIDECAR_PORT:-8000}:8000"
|
||||
environment:
|
||||
ORIGINALS_ROOT: /photoprism/originals
|
||||
PHOTOPRISM_BASE_URL: http://photoprism:2342
|
||||
# Bind on all interfaces inside the container so the host-side
|
||||
# 127.0.0.1:8000 port mapping can reach the listener. The Go
|
||||
# binary defaults to 127.0.0.1 for the host-mode dev loop.
|
||||
SIDECAR_LISTEN_ADDR: 0.0.0.0
|
||||
SIDECAR_PORT: "8000"
|
||||
SIDECAR_DB_HOST: mariadb
|
||||
SIDECAR_DB_PORT: "3306"
|
||||
SIDECAR_DB_USER: sidecar
|
||||
# Rotate before any non-local deployment. Provisioned by
|
||||
# mariadb/init/01-sidecar.sql on first boot of the mariadb volume.
|
||||
SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup}
|
||||
SIDECAR_DB_NAME: mule_sidecar
|
||||
volumes:
|
||||
# Sidecar mutates originals (rename, folder mutations, heap
|
||||
# convert) — always rw regardless of PhotoPrism's mount mode.
|
||||
- "${PHOTO_DIRS:?set PHOTO_DIRS in .env.photoprism}:/photoprism/originals:rw,Z"
|
||||
networks: [photoprism-network]
|
||||
|
||||
networks:
|
||||
photoprism-network:
|
||||
driver: bridge
|
||||
|
||||
13
sidecar/.dockerignore
Normal file
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
|
||||
|
||||
Auxiliary service that handles operations PhotoPrism's REST API does not expose.
|
||||
Go + Gin + GORM service for the endpoints PhotoPrism's REST API does not
|
||||
expose. Same wire contract as the M3 Node prototype it replaces; the
|
||||
SvelteKit client at [web/](../web/) talks to it transparently through
|
||||
Vite's `/api/sidecar/*` proxy.
|
||||
|
||||
## Why this exists
|
||||
## What it owns
|
||||
|
||||
Per the merge plan at `/home/dtoro/.claude/plans/i-want-you-to-twinkly-galaxy.md`,
|
||||
a Go + Gin + GORM service (matching PhotoPrism's stack) will eventually own:
|
||||
| Method | Path | Purpose |
|
||||
| ------ | --------------------------------------- | --------------------------------------------- |
|
||||
| GET | `/api/sidecar/healthz` | Unauthenticated liveness probe. |
|
||||
| GET | `/api/sidecar/photos/marks` | Every per-photo `{rating, color}` mark. |
|
||||
| GET | `/api/sidecar/photos/:uid/marks` | One photo's mark (or `{}` if none). |
|
||||
| PUT | `/api/sidecar/photos/:uid/marks` | Patch one photo's mark. |
|
||||
| POST | `/api/sidecar/photos/marks/bulk` | Stamp the same mark onto many photos. |
|
||||
| POST | `/api/sidecar/files/:uid/rename` | Rename the primary file on disk + reindex. |
|
||||
| POST | `/api/sidecar/folders` | Create a folder under `${ORIGINALS_ROOT}`. |
|
||||
| POST | `/api/sidecar/folders/:rel/rename` | Rename a folder (rel path URL-encoded). |
|
||||
| DELETE | `/api/sidecar/folders/:rel` | Delete an **empty** folder. |
|
||||
| POST | `/api/sidecar/albums/:uid/convert` | Move/copy every photo in a heap into folder X. |
|
||||
| GET | `/api/sidecar/duplicates/scan` | Walk originals, return same-hash groups. |
|
||||
| POST | `/api/sidecar/duplicates/archive` | Move duplicate paths into `.duplicates/<ts>/`. |
|
||||
|
||||
- Per-user heap sharing with pending invitations
|
||||
- Folder mutations under `originals/` (create / rename / delete / move)
|
||||
- **File rename** on disk (PhotoPrism's `OriginalName` is a display-only rename)
|
||||
Auth: every endpoint except `healthz` requires the caller's
|
||||
`X-Auth-Token` header. The sidecar holds no service credentials — it
|
||||
proxies the token straight back to PhotoPrism's `/api/v1/photos?count=1`
|
||||
to confirm the session is live before doing anything destructive.
|
||||
|
||||
The plan picks Go for stack consistency and the option to upstream features.
|
||||
|
||||
## What ships today
|
||||
|
||||
A **Node.js prototype** (`server.mjs`) covering only the **file rename** endpoint.
|
||||
|
||||
The decision to ship Node first is pragmatic — Go isn't installed on this dev
|
||||
box and `sudo dnf install golang` needs a password. Node is already on PATH for
|
||||
the SvelteKit dev server, so a single-file Node service unblocks the feature
|
||||
without changing the host setup.
|
||||
|
||||
The endpoint contract is stable: when M4 lands the proper Go service, the
|
||||
SvelteKit client keeps calling the same paths.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `POST /api/sidecar/files/:photoUid/rename` `{ "newName": "newfile.png" }`
|
||||
Renames the primary file of the photo on disk under `${ORIGINALS_ROOT}`,
|
||||
then triggers a PhotoPrism reindex of the parent path.
|
||||
Marks persist to **MariaDB** (`mule_sidecar.marks`); everything else
|
||||
operates on the filesystem under `${ORIGINALS_ROOT}` and triggers a
|
||||
PhotoPrism reindex of the affected parent in the background.
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
ORIGINALS_ROOT=/home/dtoro/projects/mule-image/photos-sample \
|
||||
PHOTOPRISM_BASE_URL=http://localhost:2342 \
|
||||
SIDECAR_PORT=8000 \
|
||||
node server.mjs
|
||||
The sidecar is a `sidecar` service in the PhotoPrism compose stack.
|
||||
Bringing the whole stack up brings it up too:
|
||||
|
||||
```sh
|
||||
podman-compose --env-file .env.photoprism \
|
||||
-f docker-compose.photoprism.yml \
|
||||
-f docker-compose.photoprism.podman.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
The SvelteKit dev server proxies `/api/sidecar/*` to `http://localhost:8000`.
|
||||
This builds [Dockerfile](Dockerfile) (multi-stage `golang:1.25-alpine` →
|
||||
`gcr.io/distroless/static`, ~12 MB final image), starts the container,
|
||||
and binds `127.0.0.1:8000` to the service. The SvelteKit dev server
|
||||
proxies `/api/sidecar/*` to that port transparently.
|
||||
|
||||
### Dev-iteration loop (host build)
|
||||
|
||||
For tight iteration without rebuilding the image on every change you
|
||||
can run it as a host process — Go is already on the dev machine:
|
||||
|
||||
```sh
|
||||
cd sidecar
|
||||
go build -o mule-sidecar .
|
||||
|
||||
ORIGINALS_ROOT=/path/to/photoprism/originals \
|
||||
PHOTOPRISM_BASE_URL=http://localhost:2342 \
|
||||
SIDECAR_PORT=8000 \
|
||||
./mule-sidecar
|
||||
```
|
||||
|
||||
The host build connects to `mariadb` via the loopback port the compose
|
||||
file publishes; stop `pp-sidecar` first so they don't fight for 8000.
|
||||
|
||||
## Env
|
||||
|
||||
| Var | Default | Notes |
|
||||
| --------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `ORIGINALS_ROOT` | `/photoprism/originals` | Absolute path; must match PhotoPrism's mount. |
|
||||
| `PHOTOPRISM_BASE_URL` | `http://localhost:2342` | Where to reach PhotoPrism for session validation + reindex calls. |
|
||||
| `SIDECAR_PORT` | `8000` | Loopback-only; reverse-proxy fronts it in production. |
|
||||
| `SIDECAR_DSN` | _(built from the vars below)_ | Set this to override the assembled MySQL DSN entirely. |
|
||||
| `SIDECAR_DB_HOST` | `127.0.0.1` | Host of the MariaDB the compose stack publishes on `127.0.0.1:3306`. |
|
||||
| `SIDECAR_DB_PORT` | `3306` | |
|
||||
| `SIDECAR_DB_USER` | `sidecar` | Provisioned by [`mariadb/init/01-sidecar.sql`](../mariadb/init/01-sidecar.sql) on first boot. |
|
||||
| `SIDECAR_DB_PASSWORD` | `replace-at-m4-bringup` | Literal placeholder — **rotate before any non-local deployment**. |
|
||||
| `SIDECAR_DB_NAME` | `mule_sidecar` | |
|
||||
|
||||
## Schema
|
||||
|
||||
GORM `AutoMigrate` creates the only table the service owns:
|
||||
|
||||
```sql
|
||||
CREATE TABLE marks (
|
||||
photo_uid VARCHAR(64) PRIMARY KEY,
|
||||
rating BIGINT NULL,
|
||||
color VARCHAR(16) NULL,
|
||||
updated_at DATETIME(3)
|
||||
);
|
||||
```
|
||||
|
||||
The M3 Node prototype kept the same data in `sidecar/data/marks.json`.
|
||||
There is no migration path — the prototype's marks file was dev-only
|
||||
state. Heap-sharing tables (M4) will land in subsequent migrations.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
sidecar/
|
||||
├── Dockerfile multi-stage golang:1.25 → distroless/static
|
||||
├── main.go entrypoint, route wiring, graceful shutdown
|
||||
├── config.go env-driven Config
|
||||
├── db.go GORM open + Mark model + AutoMigrate
|
||||
├── auth.go requireSession middleware + ctxToken
|
||||
├── fs.go path safety, walk, sha1
|
||||
├── pp.go PhotoPrism HTTP client (validateSession, reindex)
|
||||
├── handlers_rename.go
|
||||
├── handlers_folders.go
|
||||
├── handlers_marks.go
|
||||
├── handlers_heap.go
|
||||
└── handlers_dups.go
|
||||
```
|
||||
|
||||
44
sidecar/auth.go
Normal file
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 { batchEdit } from '$lib/services/batch';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
import { invalidatePhotos } from '$lib/services/bulk';
|
||||
import {
|
||||
addToHeap,
|
||||
approvePhoto,
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchRestore,
|
||||
likePhoto,
|
||||
removeFromHeap,
|
||||
unlikePhoto,
|
||||
@@ -203,12 +207,77 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
target = !(first?.Archived ?? false);
|
||||
}
|
||||
|
||||
await patchTargets(
|
||||
ids,
|
||||
{ Archived: target },
|
||||
target ? `Archived ${ids.length}` : `Restored ${ids.length}`,
|
||||
(p) => ({ Archived: p.Archived ?? false })
|
||||
);
|
||||
// PhotoPrism's photo PUT silently drops the Archived field — the
|
||||
// only working path is /api/v1/batch/photos/{archive,restore}. The
|
||||
// previous patchTargets call PUT'd `{Archived: true}` and got a 200
|
||||
// back, so the toast fired but nothing moved.
|
||||
try {
|
||||
if (target) await batchArchive(ids);
|
||||
else await batchRestore(ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
return;
|
||||
}
|
||||
invalidatePhotos(ids);
|
||||
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||
toast.success(label);
|
||||
pushUndo(label, async () => {
|
||||
if (target) await batchRestore(ids);
|
||||
else await batchArchive(ids);
|
||||
invalidatePhotos(ids);
|
||||
});
|
||||
}
|
||||
|
||||
/** Permanently delete cull targets — only callable from the archive
|
||||
* section (X is rerouted away from archive-toggle there). PhotoPrism
|
||||
* rejects deletion of un-archived photos with a 4xx, so the section
|
||||
* gate doubles as a safety guard against accidental deletes from the
|
||||
* main timeline. Confirm dialog is mandatory — no undo path exists. */
|
||||
async function deleteCullTargets() {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to delete', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const msg =
|
||||
ids.length === 1
|
||||
? 'Permanently delete this photo? This cannot be undone.'
|
||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||
if (!confirm(msg)) return;
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
return;
|
||||
}
|
||||
invalidatePhotos(ids);
|
||||
toast.success(`Deleted ${ids.length}`);
|
||||
}
|
||||
|
||||
/** Approve cull targets — clears them out of the review pile by
|
||||
* bumping each photo's quality score above PhotoPrism's review
|
||||
* threshold. The op is one-way (no /unapprove route), so we don't
|
||||
* push an undo entry: a re-keyed S would just be a no-op on
|
||||
* already-approved photos. */
|
||||
async function approveCullTargets() {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
toast.message('Nothing to keep', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||
invalidatePhotos(ids);
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
}
|
||||
|
||||
/** Flip the Favorite (heart) flag on cull targets. Reads the first
|
||||
@@ -427,6 +496,13 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
case 'X':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
// Archive section: X becomes permanent delete (Keep/Delete
|
||||
// is the binary flow there, mirroring Review's Keep/Archive).
|
||||
// Everywhere else X toggles archive on the cull targets.
|
||||
if (filters.section === 'archive') {
|
||||
void deleteCullTargets();
|
||||
return;
|
||||
}
|
||||
void toggleArchive('toggle');
|
||||
return;
|
||||
case 'u':
|
||||
@@ -444,9 +520,26 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
case 's':
|
||||
case 'S':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
// Review section repurposes S as the Keep affordance —
|
||||
// matches the BulkActionBar button and keeps the binary
|
||||
// Keep/Archive flow on home-row keys (S/X). The heap chord
|
||||
// is meaningless here anyway (review photos can't sensibly
|
||||
// be filed before they're approved).
|
||||
if (filters.section === 'review') {
|
||||
void approveCullTargets();
|
||||
return;
|
||||
}
|
||||
// Archive section: S = Keep = restore back to the timeline
|
||||
// (inverse of Delete on X). Same rationale as review —
|
||||
// heap-filing an archived photo isn't a flow that fits the
|
||||
// section's intent.
|
||||
if (filters.section === 'archive') {
|
||||
void toggleArchive('restore');
|
||||
return;
|
||||
}
|
||||
// Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N;
|
||||
// otherwise we fall back to the currently-viewed heap.
|
||||
e.preventDefault();
|
||||
clearSChord();
|
||||
sChordTimer = window.setTimeout(() => {
|
||||
sChordTimer = null;
|
||||
@@ -461,6 +554,9 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
if (!tile) return;
|
||||
const uid = tile.dataset.uid;
|
||||
if (!uid) return;
|
||||
// Modifier clicks are the only paths this document-level handler
|
||||
// owns. Plain clicks bubble to the tile button's onclick, which
|
||||
// reduces selection to just that tile.
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRange(uid);
|
||||
@@ -469,13 +565,6 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
e.preventDefault();
|
||||
toggle(uid);
|
||||
setFocused(uid);
|
||||
} else if (selection.ids.size > 0) {
|
||||
// When a multi-selection is active, a plain click reduces it to
|
||||
// just this tile (matches mule-image's "selection mode" behaviour).
|
||||
e.preventDefault();
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,19 +27,24 @@ export interface NearBottomParams {
|
||||
export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
||||
let current: NearBottomParams = params;
|
||||
let io: IntersectionObserver | null = null;
|
||||
// IntersectionObserver only emits on state changes. With a 4-viewport
|
||||
// preload zone, the sentinel typically stays continuously intersecting
|
||||
// across a whole fetchNextPage cycle: enabled flips false (fetching),
|
||||
// the IO callback runs but no-ops, enabled flips back true — and no new
|
||||
// event is emitted because the intersection state never changed. We'd
|
||||
// stall mid-pagination. Remember the last reported intersection so the
|
||||
// next `enabled` rising edge can re-fire manually.
|
||||
let lastIntersecting = false;
|
||||
|
||||
function buildObserver(p: NearBottomParams) {
|
||||
io?.disconnect();
|
||||
const preload = p.preloadPx ?? Math.max(800, window.innerHeight * 4);
|
||||
io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!current.enabled) return;
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
current.onHit();
|
||||
return;
|
||||
}
|
||||
lastIntersecting = e.isIntersecting;
|
||||
}
|
||||
if (lastIntersecting && current.enabled) current.onHit();
|
||||
},
|
||||
{
|
||||
root: p.root ?? null,
|
||||
@@ -57,11 +62,15 @@ export function nearBottom(node: HTMLElement, params: NearBottomParams) {
|
||||
update(next: NearBottomParams) {
|
||||
const rootChanged = next.root !== current.root;
|
||||
const preloadChanged = next.preloadPx !== current.preloadPx;
|
||||
const enabledRose = !current.enabled && !!next.enabled;
|
||||
current = next;
|
||||
// `enabled` and `onHit` are read live inside the callback,
|
||||
// so they don't require rebuilding the observer. Root and
|
||||
// preloadPx are baked in at construction.
|
||||
if (rootChanged || preloadChanged) buildObserver(current);
|
||||
if (rootChanged || preloadChanged) {
|
||||
buildObserver(current);
|
||||
return;
|
||||
}
|
||||
// `enabled` rising while the sentinel is still in the preload
|
||||
// zone — no IO event coming, so fire manually.
|
||||
if (enabledRose && lastIntersecting) current.onHit();
|
||||
},
|
||||
destroy() {
|
||||
io?.disconnect();
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
* `filters.folderPath` matches (the sidebar nav case); the picker
|
||||
* passes its own selection so the dialog has independent state. */
|
||||
selectedPath?: string | null;
|
||||
/** Optional per-path photo count. When provided, each row renders a
|
||||
* compact badge with the count — matching the heaps section's
|
||||
* "{n} photos" affordance. Undefined keeps the badge off entirely
|
||||
* (the picker dialog doesn't need it). */
|
||||
counts?: Record<string, number>;
|
||||
}
|
||||
let {
|
||||
nodes,
|
||||
@@ -69,7 +74,8 @@
|
||||
onDelete,
|
||||
onCreateChild,
|
||||
readonly = false,
|
||||
selectedPath
|
||||
selectedPath,
|
||||
counts
|
||||
}: Props = $props();
|
||||
|
||||
// Auto-expanded folders, persisted to localStorage so the tree state
|
||||
@@ -114,7 +120,7 @@
|
||||
with the px-2 of Views/Heaps rows; +12px per nested level.
|
||||
-->
|
||||
<div
|
||||
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
||||
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
@@ -137,7 +143,7 @@
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<button
|
||||
class="flex flex-1 items-center truncate text-left"
|
||||
class="flex min-w-0 flex-1 items-center truncate text-left"
|
||||
class:px-1={hasChildren || depth > 0}
|
||||
onclick={() => onPick(node.path)}
|
||||
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
||||
@@ -145,11 +151,23 @@
|
||||
>
|
||||
<span class="truncate">{node.name}</span>
|
||||
</button>
|
||||
{#if counts && counts[node.path] !== undefined}
|
||||
{@const n = counts[node.path]}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{n >= 1000 ? '1000+' : n}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !readonly}
|
||||
<!-- Hover-revealed kebab. Reserves zero width when idle so the
|
||||
row stays compact; expands on hover and stays visible while
|
||||
the menu is open. Suppressed in readonly mode (picker). -->
|
||||
<div class="mr-1">
|
||||
<!-- Hover-revealed kebab. `display: none` until row hover
|
||||
(or while the menu is open via has-[[data-state=open]])
|
||||
so the count holds the row's right edge by default
|
||||
and the kebab pushes it left when it appears.
|
||||
Suppressed in readonly mode (picker). -->
|
||||
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||
<KebabMenu label="Folder actions">
|
||||
<Item
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||
@@ -187,6 +205,7 @@
|
||||
{onCreateChild}
|
||||
{readonly}
|
||||
{selectedPath}
|
||||
{counts}
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
<!--
|
||||
General app preferences. Distinct from the PhotoPrism-library admin dialog:
|
||||
this one owns settings that affect *this* SvelteKit shell (theme), not the
|
||||
server. Opened from the bottom of the left sidebar.
|
||||
General app preferences. The UI tab owns the SvelteKit shell's
|
||||
light/dark/system theme (mode-watcher) plus the per-user UI knobs
|
||||
PhotoPrism's /settings exposes. Search and Maps follow the same
|
||||
pattern — server prefs round-trip via /api/v1/settings.
|
||||
|
||||
The Library admin dialog and this one share the ['settings'] cache,
|
||||
so saves from either invalidate the other.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { Dialog, Tabs } from 'bits-ui';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
|
||||
import {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
type PpSettings
|
||||
} from '$lib/services/photoprism';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -14,11 +25,98 @@
|
||||
}
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let activeTab = $state<'ui' | 'search' | 'maps'>('ui');
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', label: 'Light', Icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', Icon: Moon },
|
||||
{ value: 'system', label: 'System', Icon: Monitor }
|
||||
] as const;
|
||||
|
||||
// PhotoPrism palette names from its built-in themes. Any value
|
||||
// outside this list is preserved verbatim (see `withCurrent`).
|
||||
const ppThemes = [
|
||||
'default',
|
||||
'abyss',
|
||||
'gemstone',
|
||||
'grayscale',
|
||||
'lavender',
|
||||
'legacy',
|
||||
'neon',
|
||||
'onyx',
|
||||
'raspberry',
|
||||
'shadow',
|
||||
'yellowstone'
|
||||
];
|
||||
|
||||
// IETF subtags PhotoPrism ships translations for. Extend without
|
||||
// fear — `withCurrent` keeps unknown values visible.
|
||||
const ppLanguages = [
|
||||
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
|
||||
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
|
||||
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
|
||||
];
|
||||
|
||||
const ppStartPages = [
|
||||
'default',
|
||||
'browse',
|
||||
'albums',
|
||||
'favorites',
|
||||
'calendar',
|
||||
'moments',
|
||||
'people',
|
||||
'places',
|
||||
'labels',
|
||||
'states',
|
||||
'library'
|
||||
];
|
||||
|
||||
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
|
||||
|
||||
// Returns `opts` with `current` prepended if it's set and not
|
||||
// already in the list — so e.g. an experimental theme name in the
|
||||
// server response shows up selected and editable instead of
|
||||
// silently being overwritten by the dropdown's default.
|
||||
function withCurrent(opts: string[], current?: string): string[] {
|
||||
if (!current) return opts;
|
||||
return opts.includes(current) ? opts : [current, ...opts];
|
||||
}
|
||||
|
||||
const settingsQuery = createQuery<PpSettings>(() => ({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
enabled: open
|
||||
}));
|
||||
|
||||
let draft = $state<PpSettings | null>(null);
|
||||
$effect(() => {
|
||||
if (settingsQuery.data && draft === null) {
|
||||
draft = structuredClone(settingsQuery.data);
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (!open) draft = null;
|
||||
});
|
||||
|
||||
const saveMut = createMutation(() => ({
|
||||
mutationFn: (patch: PpSettings) => saveSettings(patch),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(['settings'], next);
|
||||
draft = structuredClone(next);
|
||||
toast.success('Settings saved');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not save settings')
|
||||
}));
|
||||
|
||||
function resetDraft() {
|
||||
if (settingsQuery.data) draft = structuredClone(settingsQuery.data);
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
@@ -32,7 +130,7 @@
|
||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[440px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[560px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<SettingsIcon class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
@@ -41,8 +139,8 @@
|
||||
General settings
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||
Preferences for this app. Library-side settings live under
|
||||
Folders → ⚙.
|
||||
Preferences for this app and your PhotoPrism account.
|
||||
Library admin lives under Folders → ⚙.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close
|
||||
@@ -53,31 +151,219 @@
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<section class="space-y-2 text-[12px]">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Appearance
|
||||
</h3>
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded-md border border-border"
|
||||
role="group"
|
||||
aria-label="Theme"
|
||||
>
|
||||
{#each themeOptions as opt (opt.value)}
|
||||
{@const active = mode.current === opt.value}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => setMode(opt.value)}
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
|
||||
{#each ['ui', 'search', 'maps'] as const as t (t)}
|
||||
<Tabs.Trigger
|
||||
value={t}
|
||||
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
||||
>
|
||||
<opt.Icon class="h-3.5 w-3.5" />
|
||||
{opt.label}
|
||||
</button>
|
||||
{t}
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
<!-- UI — local app theme (mode-watcher) on top, then the
|
||||
PhotoPrism per-user UI knobs that go to /settings. -->
|
||||
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
App theme
|
||||
</h3>
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded-md border border-border"
|
||||
role="group"
|
||||
aria-label="Theme"
|
||||
>
|
||||
{#each themeOptions as opt (opt.value)}
|
||||
{@const active = mode.current === opt.value}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center justify-center gap-1.5 px-3 py-1.5 hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => setMode(opt.value)}
|
||||
>
|
||||
<opt.Icon class="h-3.5 w-3.5" />
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
Light/dark for this app. Persists locally; no Save needed.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{#if settingsQuery.isPending}
|
||||
<p class="px-1 text-muted-foreground">Loading PhotoPrism settings…</p>
|
||||
{:else if settingsQuery.isError}
|
||||
<p class="px-1 text-destructive">Could not load PhotoPrism settings.</p>
|
||||
{:else if draft}
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
PhotoPrism UI
|
||||
</h3>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Theme</span>
|
||||
<select bind:value={draft.ui!.theme} class={selectClass}>
|
||||
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Language</span>
|
||||
<select bind:value={draft.ui!.language} class={selectClass}>
|
||||
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Time zone</span>
|
||||
<!-- IANA tz list is ~400 entries, browser support varies; use
|
||||
a datalist so we get autocomplete without spamming a
|
||||
gigantic <select>. "Local" is PhotoPrism's special
|
||||
"follow system" sentinel. -->
|
||||
<input
|
||||
type="text"
|
||||
list="general-tz-list"
|
||||
placeholder="Local"
|
||||
bind:value={draft.ui!.timeZone}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Start page</span>
|
||||
<select bind:value={draft.ui!.startPage} class={selectClass}>
|
||||
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
|
||||
Always show scrollbars
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.ui!.zoom} />
|
||||
Allow image zoom
|
||||
</label>
|
||||
</section>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
{#if settingsQuery.isPending && activeTab !== 'ui'}
|
||||
<Tabs.Content value={activeTab} class="outline-none">
|
||||
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
|
||||
</Tabs.Content>
|
||||
{:else if settingsQuery.isError && activeTab !== 'ui'}
|
||||
<Tabs.Content value={activeTab} class="outline-none">
|
||||
<p class="px-1 text-[12px] text-destructive">
|
||||
Could not load settings.
|
||||
</p>
|
||||
</Tabs.Content>
|
||||
{:else if draft}
|
||||
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.listView} />
|
||||
Default to list view
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.showTitles} />
|
||||
Show titles
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
|
||||
Show captions
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">
|
||||
Batch size (-1 = server default)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={draft.search!.batchSize}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Style</span>
|
||||
<select bind:value={draft.maps!.style} class={selectClass}>
|
||||
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">
|
||||
Animation duration (ms, 0 = off)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={draft.maps!.animate}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
</Tabs.Content>
|
||||
{/if}
|
||||
</Tabs.Root>
|
||||
|
||||
<!-- Datalist for time-zone autocomplete. Falls back to the
|
||||
"Local" sentinel when the browser can't enumerate the
|
||||
IANA list (older Safari, etc.). -->
|
||||
<datalist id="general-tz-list">
|
||||
<option value="Local"></option>
|
||||
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
|
||||
</datalist>
|
||||
|
||||
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
|
||||
trip). The App theme group above persists itself, so we
|
||||
only show the action row when there's something to save. -->
|
||||
{#if draft}
|
||||
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||
onclick={resetDraft}
|
||||
disabled={saveMut.isPending}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={() => draft && saveMut.mutate(draft)}
|
||||
disabled={saveMut.isPending}
|
||||
>
|
||||
{#if saveMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<script lang="ts" module>
|
||||
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
|
||||
// 15.3 and below) return undefined here. The component handles that
|
||||
// by simply showing only the "Local" sentinel in the datalist.
|
||||
export function tzOptions(): string[] {
|
||||
const fn = (Intl as unknown as {
|
||||
supportedValuesOf?: (k: string) => string[];
|
||||
}).supportedValuesOf;
|
||||
if (typeof fn !== 'function') return [];
|
||||
try {
|
||||
return fn('timeZone');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
}));
|
||||
|
||||
function submit() {
|
||||
if (!heap || !pickedPath) return;
|
||||
// pickedPath === '' is the root selection; falsy check would
|
||||
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
|
||||
if (!heap || pickedPath === null) return;
|
||||
convertMut.mutate({
|
||||
uid: heap.UID,
|
||||
body: {
|
||||
@@ -154,6 +156,20 @@
|
||||
No folders. Create one from the sidebar first.
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Root row: lets the user drop the heap directly into
|
||||
originals/ without picking a subfolder. The empty
|
||||
string is the sidecar's "root" sentinel — matches
|
||||
resolveUnderRoot's special case in handlers_heap. -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
||||
class:bg-primary={pickedPath === ''}
|
||||
class:text-primary-foreground={pickedPath === ''}
|
||||
class:hover:bg-primary={pickedPath === ''}
|
||||
onclick={() => (pickedPath = '')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<FolderTree
|
||||
nodes={folderTree}
|
||||
onPick={(p) => (pickedPath = p)}
|
||||
@@ -213,7 +229,7 @@
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={submit}
|
||||
disabled={!pickedPath || convertMut.isPending}
|
||||
disabled={pickedPath === null || convertMut.isPending}
|
||||
>
|
||||
{#if convertMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
@@ -10,14 +11,21 @@
|
||||
deleteFolder,
|
||||
deleteHeap,
|
||||
duplicateHeap,
|
||||
getAllMarks,
|
||||
getConfig,
|
||||
getImportInfo,
|
||||
heapDownloadUrl,
|
||||
listFolderCounts,
|
||||
listFolders,
|
||||
listHeaps,
|
||||
logout,
|
||||
renameFolder,
|
||||
renameHeap,
|
||||
triggerDownload,
|
||||
type ImportInfo,
|
||||
type PhotoMarksMap,
|
||||
type PpAlbum,
|
||||
type PpClientConfig,
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import {
|
||||
@@ -36,6 +44,7 @@
|
||||
Copy,
|
||||
Download,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
LogOut,
|
||||
Moon,
|
||||
Pencil,
|
||||
@@ -58,10 +67,90 @@
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
// Import staging area (PhotoPrism's `/import` root). Polled at a leisurely
|
||||
// 60s — the inbox only changes when files are uploaded or the indexer
|
||||
// runs, neither of which happens often enough to justify a tighter cadence.
|
||||
const importQuery = createQuery<ImportInfo>(() => ({
|
||||
queryKey: ['import'],
|
||||
queryFn: getImportInfo,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
// View counts come from PhotoPrism's `/config` response, which carries a
|
||||
// precomputed counter for every common bucket (all/favorites/archived/
|
||||
// labels/places/…) updated incrementally on every mutation. Cheap to
|
||||
// refetch, and gives us a stable total — `/photos` only returns
|
||||
// per-page row counts via `X-Count`, never a total.
|
||||
//
|
||||
// The key sits under the `['photos', …]` prefix so it inherits the
|
||||
// existing `invalidateQueries({ queryKey: ['photos'] })` calls scattered
|
||||
// across mutations (favorite, archive, restore, delete, heap add) — the
|
||||
// counter map refreshes whenever the photo list does. Marks-derived
|
||||
// counts (ratings/colors) react through the shared `['marks']` cache.
|
||||
const configQuery = createQuery<PpClientConfig>(() => ({
|
||||
queryKey: ['photos', 'config'],
|
||||
queryFn: getConfig,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
const ratingsCount = $derived(countRatings(marksQuery.data));
|
||||
const colorsCount = $derived(countColors(marksQuery.data));
|
||||
|
||||
function countRatings(marks: PhotoMarksMap | undefined): number {
|
||||
if (!marks) return 0;
|
||||
let n = 0;
|
||||
for (const m of Object.values(marks)) {
|
||||
if ((m.rating ?? 0) > 0) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function countColors(marks: PhotoMarksMap | undefined): number {
|
||||
if (!marks) return 0;
|
||||
let n = 0;
|
||||
for (const m of Object.values(marks)) {
|
||||
if (m.color) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const folderTree = $derived(
|
||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||
);
|
||||
|
||||
// Per-folder photo counts. PhotoPrism's /folders/originals reports
|
||||
// FileCount: 0 for every folder, so we hit /photos?q=path:X per folder
|
||||
// in parallel. Key the query off the folder-path list so it refetches
|
||||
// when folders are added/renamed/deleted, and share the ['photos', …]
|
||||
// prefix so it invalidates alongside the other photo caches whenever a
|
||||
// mutation lands.
|
||||
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
||||
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
||||
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
||||
queryFn: () => listFolderCounts(folderPaths),
|
||||
enabled: isAuthenticated() && folderPaths.length > 0,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||
|
||||
// Root count = total photos minus the sum of every subfolder count.
|
||||
// `config.count.all` is PhotoPrism's authoritative library total
|
||||
// (kept in sync server-side); subtracting non-root photos gives an
|
||||
// exact root-only count without a separate API trip.
|
||||
const rootCount = $derived.by(() => {
|
||||
const total = configQuery.data?.count?.all ?? 0;
|
||||
const sub = Object.values(folderCounts).reduce((a, b) => a + b, 0);
|
||||
return Math.max(0, total - sub);
|
||||
});
|
||||
|
||||
const createMut = createMutation(() => ({
|
||||
mutationFn: (title: string) => createHeap(title),
|
||||
onSuccess: (h) => {
|
||||
@@ -109,6 +198,24 @@
|
||||
// admin dialog above — opened from the bottom-of-sidebar footer.
|
||||
let generalSettingsOpen = $state(false);
|
||||
|
||||
// Root-folder collapse state. Persisted to its own localStorage key so
|
||||
// it doesn't collide with FolderTree's per-subfolder openSet. Defaults
|
||||
// to open so first-time users see the full tree.
|
||||
const ROOT_OPEN_KEY = 'mule_root_expanded';
|
||||
let rootExpanded = $state(loadRootExpanded());
|
||||
function loadRootExpanded(): boolean {
|
||||
if (!browser) return true;
|
||||
const raw = localStorage.getItem(ROOT_OPEN_KEY);
|
||||
return raw === null ? true : raw === '1';
|
||||
}
|
||||
function toggleRoot() {
|
||||
rootExpanded = !rootExpanded;
|
||||
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
const rootActive = $derived(filters.folderPath === '/');
|
||||
const hasSubfolders = $derived((foldersQuery.data ?? []).length > 0);
|
||||
|
||||
async function onSignOut() {
|
||||
await logout();
|
||||
await goto('/login', { replaceState: true });
|
||||
@@ -221,24 +328,40 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// Single Views group — section-driven entries and route-driven entries
|
||||
// mixed in display order. `kind` discriminates which click handler runs
|
||||
// (sections go through `navigateTo` to seed filter state; routes are
|
||||
// plain links). Archive intentionally sits at the bottom to keep it out
|
||||
// of the way of the everyday-browse rows.
|
||||
// Two groups: Views (everyday browse) and Manage (curation flows that
|
||||
// decide a photo's fate — review, dedup, unhide, delete). `kind`
|
||||
// discriminates which click handler runs (sections go through
|
||||
// `navigateTo` to seed filter state; routes are plain links).
|
||||
//
|
||||
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
||||
// derived value on every render — the arrays themselves are constant.
|
||||
// `count.all` already excludes archived/review/hidden (PhotoPrism's
|
||||
// "everything visible in the main timeline" tally), so it matches what
|
||||
// the All photos view actually renders. `places` is the count of
|
||||
// geocoded locations — semantically what the Map view groups by.
|
||||
// Duplicates has no precomputed counter; we omit its badge.
|
||||
type ViewItem =
|
||||
| { kind: 'section'; id: Section; label: string }
|
||||
| { kind: 'route'; href: string; label: string };
|
||||
| { kind: 'section'; id: Section; label: string; getCount: () => number | undefined }
|
||||
| { kind: 'route'; href: string; label: string; getCount: () => number | undefined };
|
||||
|
||||
// "All photos" is not in this list: the root-folder row at the top
|
||||
// of the sidebar is the canonical entry into the library, so a
|
||||
// separate "everything regardless of folder" destination would just
|
||||
// duplicate it for users whose photos live under the root.
|
||||
const views: ViewItem[] = [
|
||||
{ kind: 'section', id: 'all-photos', label: 'All photos' },
|
||||
{ kind: 'section', id: 'favorites', label: 'Favorites' },
|
||||
{ kind: 'route', href: '/duplicates', label: 'Duplicates' },
|
||||
{ kind: 'route', href: '/map', label: 'Map' },
|
||||
{ kind: 'route', href: '/ratings', label: 'Ratings' },
|
||||
{ kind: 'route', href: '/colors', label: 'Colors' },
|
||||
{ kind: 'route', href: '/tags', label: 'Tags' },
|
||||
{ kind: 'section', id: 'archive', label: 'Archive' }
|
||||
{ kind: 'route', href: '/inbox', label: 'Inbox', getCount: () => importQuery.data?.files },
|
||||
{ kind: 'section', id: 'favorites', label: 'Favorites', getCount: () => configQuery.data?.count?.favorites },
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => configQuery.data?.count?.places },
|
||||
{ kind: 'route', href: '/ratings', label: 'Ratings', getCount: () => ratingsCount },
|
||||
{ kind: 'route', href: '/colors', label: 'Colors', getCount: () => colorsCount },
|
||||
{ kind: 'route', href: '/tags', label: 'Tags', getCount: () => configQuery.data?.count?.labels }
|
||||
];
|
||||
|
||||
const manageViews: ViewItem[] = [
|
||||
{ kind: 'section', id: 'review', label: 'Review', getCount: () => configQuery.data?.count?.review },
|
||||
{ kind: 'route', href: '/duplicates', label: 'Duplicates', getCount: () => undefined },
|
||||
{ kind: 'section', id: 'hidden', label: 'Hidden', getCount: () => configQuery.data?.count?.hidden },
|
||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => configQuery.data?.count?.archived }
|
||||
];
|
||||
|
||||
function isRouteActive(href: string): boolean {
|
||||
@@ -246,10 +369,176 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet viewRow(v: ViewItem)}
|
||||
{@const active = v.kind === 'section' ? isActive(v.id) : isRouteActive(v.href)}
|
||||
{@const count = v.getCount()}
|
||||
{#if v.kind === 'section'}
|
||||
<button
|
||||
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => navigateTo(v.id)}
|
||||
>
|
||||
<span class="truncate">{v.label}</span>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href={v.href}
|
||||
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
>
|
||||
<span class="truncate">{v.label}</span>
|
||||
{#if count !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<nav class="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
<!-- Views — section-driven entries + route-driven entries under a
|
||||
single uppercase eyebrow. Compact rows, no icons. -->
|
||||
<!-- Folders — top of the sidebar because the root folder is the
|
||||
default landing view (see filters store init), making it the
|
||||
primary navigation surface. Root-folder row + subfolder tree;
|
||||
hover-revealed actions on the header for library settings and
|
||||
new-top-level-folder. -->
|
||||
<div>
|
||||
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Library
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => (settingsOpen = true)}
|
||||
title="Library settings"
|
||||
aria-label="Library settings"
|
||||
>
|
||||
<Settings class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => onCreateFolder(null)}
|
||||
title="New top-level folder"
|
||||
aria-label="New top-level folder"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<!--
|
||||
Root-folder entry. Mirrors a subfolder row's hover/active state
|
||||
via the `/` sentinel; clicking the label filters the timeline to
|
||||
photos whose Path is empty (handled by applyFolderScope in
|
||||
+page.svelte). The chevron collapses/expands the subfolder tree
|
||||
below — same affordance as nested folder rows.
|
||||
-->
|
||||
<div
|
||||
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={rootActive}
|
||||
class:text-primary-foreground={rootActive}
|
||||
class:hover:bg-primary={rootActive}
|
||||
style="padding-left: 8px;"
|
||||
>
|
||||
{#if hasSubfolders}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
|
||||
class:text-muted-foreground={!rootActive}
|
||||
onclick={toggleRoot}
|
||||
title={rootExpanded ? 'Collapse' : 'Expand'}
|
||||
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
|
||||
>
|
||||
{rootExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center truncate text-left"
|
||||
class:px-1={hasSubfolders}
|
||||
onclick={() => pickFolder('/')}
|
||||
title="Photos directly under originals/"
|
||||
>
|
||||
<span class="truncate">/</span>
|
||||
</button>
|
||||
{#if configQuery.data}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{rootCount >= 1000 ? '1000+' : rootCount}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Root-row kebab. Only "New subfolder" applies — root itself
|
||||
can't be renamed or deleted, so those entries are omitted
|
||||
entirely rather than greyed out. Hidden until row hover (or
|
||||
menu open) so the count holds the right edge by default. -->
|
||||
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||
<KebabMenu label="Root folder actions">
|
||||
<Item
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||
onSelect={() => onCreateFolder(null)}
|
||||
>
|
||||
<FolderPlus class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
New subfolder
|
||||
</Item>
|
||||
</KebabMenu>
|
||||
</div>
|
||||
</div>
|
||||
{#if foldersQuery.isPending}
|
||||
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
||||
{:else if !hasSubfolders}
|
||||
<p class="mt-1 px-2 text-[11px] text-muted-foreground">No subfolders.</p>
|
||||
{:else if rootExpanded}
|
||||
<!--
|
||||
depth=1 visually nests the top-level subfolders one indent
|
||||
step under the root row above. Labels at depth=1 line up
|
||||
12px right of the root label, matching the same per-level
|
||||
step used for deeper folders.
|
||||
-->
|
||||
<FolderTree
|
||||
nodes={folderTree}
|
||||
depth={1}
|
||||
onPick={pickFolder}
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
counts={folderCounts}
|
||||
/>
|
||||
{/if}
|
||||
{#if filters.folderPath && filters.folderPath !== '/'}
|
||||
<button
|
||||
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onclick={() => {
|
||||
setFolderPath(null);
|
||||
void goto('/', { keepFocus: true, noScroll: true });
|
||||
}}
|
||||
title="Clear folder filter"
|
||||
>
|
||||
<span class="truncate">✕ {filters.folderPath}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Views — everyday browse entries (section + route mixed) under
|
||||
a single uppercase eyebrow. Compact rows, no icons. -->
|
||||
<div>
|
||||
<div class="px-3 pb-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
@@ -257,27 +546,21 @@
|
||||
</span>
|
||||
</div>
|
||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{#if v.kind === 'section'}
|
||||
<button
|
||||
class="flex h-[24px] w-full items-center rounded px-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={isActive(v.id)}
|
||||
class:text-primary-foreground={isActive(v.id)}
|
||||
class:hover:bg-primary={isActive(v.id)}
|
||||
onclick={() => navigateTo(v.id)}
|
||||
>
|
||||
<span class="truncate">{v.label}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<a
|
||||
href={v.href}
|
||||
class="flex h-[24px] items-center rounded px-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={isRouteActive(v.href)}
|
||||
class:text-primary-foreground={isRouteActive(v.href)}
|
||||
class:hover:bg-primary={isRouteActive(v.href)}
|
||||
>
|
||||
<span class="truncate">{v.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||
row shape as Views; grouped separately so the binary-decision
|
||||
destinations (Review/Archive) don't crowd the browse list. -->
|
||||
<div>
|
||||
<div class="px-3 pb-1">
|
||||
<span class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Manage
|
||||
</span>
|
||||
</div>
|
||||
{#each manageViews as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -306,28 +589,36 @@
|
||||
<ul>
|
||||
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||
{@const active = isActive('heap', heap.UID)}
|
||||
<!--
|
||||
Count + kebab share the right edge: count is the
|
||||
resting state, kebab swaps in on hover (or while the
|
||||
menu is open). Moving the count out of the inner
|
||||
button is what lets it reach the row's right edge
|
||||
the way Views rows do — and the inner button still
|
||||
owns the navigate-on-click area.
|
||||
-->
|
||||
<li
|
||||
class="group flex h-[24px] items-center rounded text-[12px] leading-tight hover:bg-accent"
|
||||
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
>
|
||||
<button
|
||||
class="flex flex-1 items-center gap-2 px-2 text-left"
|
||||
class="flex min-w-0 flex-1 items-center gap-2 truncate px-2 text-left"
|
||||
onclick={() => navigateTo('heap', heap.UID)}
|
||||
ondblclick={() => onRenameHeap(heap)}
|
||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||
>
|
||||
<span class="truncate">{heap.Title}</span>
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{heap.PhotoCount ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
<div class="mr-1">
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{heap.PhotoCount ?? 0}
|
||||
</span>
|
||||
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||
<KebabMenu label="Heap actions">
|
||||
<Item
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||
@@ -373,54 +664,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="group/header flex items-center gap-0.5 px-3 pb-1">
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Folders
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => (settingsOpen = true)}
|
||||
title="Library settings"
|
||||
aria-label="Library settings"
|
||||
>
|
||||
<Settings class="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-0.5 text-xs text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => onCreateFolder(null)}
|
||||
title="New top-level folder"
|
||||
aria-label="New top-level folder"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{#if foldersQuery.isPending}
|
||||
<p class="px-2 text-[11px] text-muted-foreground">Loading…</p>
|
||||
{:else if (foldersQuery.data ?? []).length === 0}
|
||||
<p class="px-2 text-[11px] text-muted-foreground">No subfolders.</p>
|
||||
{:else}
|
||||
<FolderTree
|
||||
nodes={folderTree}
|
||||
onPick={pickFolder}
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
/>
|
||||
{/if}
|
||||
{#if filters.folderPath}
|
||||
<button
|
||||
class="mt-1 flex h-[22px] w-full items-center rounded px-2 text-[11px] leading-tight text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onclick={() => {
|
||||
setFolderPath(null);
|
||||
void goto('/', { keepFocus: true, noScroll: true });
|
||||
}}
|
||||
title="Clear folder filter"
|
||||
>
|
||||
<span class="truncate">✕ {filters.folderPath}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!--
|
||||
@@ -429,7 +672,7 @@
|
||||
sign-out) that used to live in the top toolbar.
|
||||
-->
|
||||
<footer
|
||||
class="flex shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3 py-2"
|
||||
class="flex h-9 shrink-0 items-center gap-1 border-t border-border bg-card/50 px-3"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-[12px] text-foreground"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { getPhoto } from '$lib/services/photoprism';
|
||||
import {
|
||||
@@ -19,25 +18,14 @@
|
||||
enabled: Boolean(preview.uid)
|
||||
}));
|
||||
|
||||
// Track the last visible uid so we can return focus to the matching
|
||||
// timeline tile when the overlay closes — lets the user keep moving
|
||||
// with arrow keys without re-clicking.
|
||||
let lastShown: string | null = null;
|
||||
// Mirror the currently-shown photo into selection.focused. The
|
||||
// timeline's tile uses `selection.focused === photo.UID` to draw the
|
||||
// blue ring, so this keeps the selection in lockstep with whatever
|
||||
// the user is paging through in preview. The host page (+page.svelte)
|
||||
// owns the matching scroll-into-view on close so the tile actually
|
||||
// mounts (it can be windowed out if the user navigated far).
|
||||
$effect(() => {
|
||||
if (preview.uid !== null) {
|
||||
lastShown = preview.uid;
|
||||
setFocused(preview.uid);
|
||||
} else if (lastShown) {
|
||||
const target = lastShown;
|
||||
lastShown = null;
|
||||
// Wait for the overlay to unmount before grabbing focus, otherwise
|
||||
// the browser swallows it as the modal element is removed.
|
||||
void tick().then(() => {
|
||||
const tile = document.querySelector<HTMLElement>(`[data-uid="${target}"]`);
|
||||
tile?.focus({ preventScroll: false });
|
||||
tile?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
}
|
||||
if (preview.uid !== null) setFocused(preview.uid);
|
||||
});
|
||||
|
||||
// Keyboard handling lives at the document level so it works regardless
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
addToHeap,
|
||||
approvePhoto,
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchRestore,
|
||||
@@ -43,6 +44,16 @@
|
||||
selection.ids.size > 0 ? selection.ids.size : selection.focused ? 1 : 0
|
||||
);
|
||||
const isBulk = $derived(selection.ids.size > 0);
|
||||
// Review section uses a two-button decision flow (Keep / Archive) —
|
||||
// every other action is hidden so the choice can't be confused with
|
||||
// favoriting / heap-adding / restoring. The S keybinding is rerouted
|
||||
// to approve from gridKeyNav for the same reason.
|
||||
const isReview = $derived(filters.section === 'review');
|
||||
// Archive section is the parallel two-button flow: Keep (restore back
|
||||
// to the timeline) or Delete (permanent, no undo). X is repurposed
|
||||
// from "archive" to "delete" since the photo is already archived;
|
||||
// gridKeyNav mirrors the rerouting.
|
||||
const isArchive = $derived(filters.section === 'archive');
|
||||
|
||||
function clearAll() {
|
||||
clearSelection();
|
||||
@@ -59,6 +70,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function onApprove() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(async () => {
|
||||
// PhotoPrism's approve is one-way (Quality jumps to 3+); there's
|
||||
// no /unapprove route. We fan out per-photo because there's no
|
||||
// batch endpoint either. Errors are tallied rather than aborting
|
||||
// the loop so a single bad UID doesn't block the rest.
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
||||
} else {
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
}
|
||||
clearSelection();
|
||||
});
|
||||
}
|
||||
|
||||
async function onArchive() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
@@ -160,28 +189,90 @@
|
||||
</script>
|
||||
|
||||
{#if targetCount > 0}
|
||||
<!--
|
||||
Inline row at the bottom of the main content column (NOT fixed) so
|
||||
the sidebars stay reachable. Matches the Toolbar's h-9 / px-3 /
|
||||
bg-background/80 backdrop-blur visual so it reads as the timeline's
|
||||
own footer.
|
||||
-->
|
||||
<div
|
||||
class="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background/95 px-6 py-3 shadow-lg backdrop-blur"
|
||||
class="flex min-h-9 shrink-0 items-center gap-2 border-t border-border bg-background px-3 py-1"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl items-center gap-3">
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{#if isBulk}
|
||||
{targetCount} selected
|
||||
{:else}
|
||||
Focused photo
|
||||
{/if}
|
||||
</span>
|
||||
<span class="shrink-0 text-[11px] font-medium text-foreground">
|
||||
{#if isBulk}
|
||||
{targetCount} selected
|
||||
{:else}
|
||||
Focused photo
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<!--
|
||||
`overflow-x-auto` would clip the heap-picker dropdown — CSS
|
||||
forces `overflow-y: auto` whenever `overflow-x` is non-visible,
|
||||
so the dropdown's `bottom-full` placement is clipped to zero
|
||||
pixels above the 36px bar (it renders but is invisible). We
|
||||
use `flex-wrap` instead so very narrow viewports get a second
|
||||
row rather than a horizontal scroll, and the dropdown stays
|
||||
free to escape upward.
|
||||
-->
|
||||
<div class="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1">
|
||||
{#if isReview}
|
||||
<!-- Review pile = binary decision. Keep approves (Quality →
|
||||
3+, lands in the main timeline); Archive batches into
|
||||
the archive section. Everything else (heap, favorite,
|
||||
restore) is hidden so the choice reads as decisive. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onApprove}
|
||||
title="Keep — accept into timeline"
|
||||
>
|
||||
✓ Keep
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onArchive}
|
||||
title="Archive"
|
||||
>
|
||||
Archive
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||
</button>
|
||||
{:else if isArchive}
|
||||
<!-- Archive section = mirror of review: Keep restores back
|
||||
to the timeline; Delete is permanent and can't be
|
||||
undone. X is repurposed from archive→delete since the
|
||||
photo is already archived; the destructive styling
|
||||
reinforces the irreversibility. -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-primary/40 bg-primary/10 px-2 py-0.5 text-[11px] text-primary hover:bg-primary/20 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onRestore}
|
||||
title="Keep — restore to timeline"
|
||||
>
|
||||
✓ Keep
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">S</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-destructive/40 bg-destructive/5 px-2 py-0.5 text-[11px] text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onDelete}
|
||||
title="Permanently delete (no undo)"
|
||||
>
|
||||
Delete
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => (heapPickerOpen = !heapPickerOpen)}
|
||||
title="Add to heap (S then 1–9 picks a heap)"
|
||||
>
|
||||
+ Add to heap
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground"
|
||||
>S N</kbd
|
||||
>
|
||||
</button>
|
||||
@@ -220,70 +311,50 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onFavorite}
|
||||
title="Favorite"
|
||||
>
|
||||
♥ Favorite
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>F</kbd
|
||||
>
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">F</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onArchive}
|
||||
title="Archive"
|
||||
>
|
||||
Archive
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>X</kbd
|
||||
>
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onRestore}
|
||||
title="Restore"
|
||||
>
|
||||
Restore
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>U</kbd
|
||||
>
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">U</kbd>
|
||||
</button>
|
||||
{#if filters.section === 'archive'}
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={onDelete}
|
||||
title="Permanently delete (no undo)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || undoStack.entries.length === 0}
|
||||
onclick={onUndo}
|
||||
title="Undo last action"
|
||||
>
|
||||
Undo
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>⌘Z</kbd
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent"
|
||||
onclick={clearAll}
|
||||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||
>
|
||||
{isBulk ? 'Clear' : 'Dismiss'}
|
||||
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||
>Esc</kbd
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy || undoStack.entries.length === 0}
|
||||
onclick={onUndo}
|
||||
title="Undo last action"
|
||||
>
|
||||
Undo
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">⌘Z</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] hover:bg-accent"
|
||||
onclick={clearAll}
|
||||
title={isBulk ? 'Clear selection' : 'Clear focus'}
|
||||
>
|
||||
{isBulk ? 'Clear' : 'Dismiss'}
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">Esc</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a photo in the review pile. PhotoPrism's indexer leaves photos
|
||||
* with low quality scores in `review:true` purgatory; approving bumps the
|
||||
* score above the review threshold (Quality goes to 3+) so the photo
|
||||
* lands in the main timeline. No corresponding "unapprove" endpoint — the
|
||||
* review pile is one-way out.
|
||||
*/
|
||||
export async function approvePhoto(uid: string): Promise<void> {
|
||||
await http.post(`/photos/${uid}/approve`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the heart/favorite flag. PhotoPrism has dedicated like/unlike
|
||||
* routes that are atomic; preferred over PUT for this one field.
|
||||
@@ -269,6 +280,65 @@ export async function listFolders(): Promise<PpFolder[]> {
|
||||
return data.folders ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox / import staging area. PhotoPrism keeps uploaded-but-not-yet-indexed
|
||||
* files in a separate `/photoprism/import` root, exposed via
|
||||
* `/folders/import`. The endpoint returns the same `PpFolder[]` shape as
|
||||
* `/folders/originals`, but the photo counts come from `X-Files` and
|
||||
* `X-Folders` response headers since the body only lists subfolders.
|
||||
*/
|
||||
export interface ImportInfo {
|
||||
files: number;
|
||||
folders: number;
|
||||
subfolders: PpFolder[];
|
||||
}
|
||||
|
||||
export async function getImportInfo(): Promise<ImportInfo> {
|
||||
const res = await http.get<{ folders?: PpFolder[] }>('/folders/import', {
|
||||
params: { recursive: true, uncached: true, files: false }
|
||||
});
|
||||
const num = (h: unknown) => {
|
||||
const n = typeof h === 'string' ? parseInt(h, 10) : NaN;
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
return {
|
||||
files: num(res.headers['x-files'] ?? res.headers['X-Files']),
|
||||
folders: num(res.headers['x-folders'] ?? res.headers['X-Folders']),
|
||||
subfolders: res.data.folders ?? []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
||||
* endpoint reports `FileCount: 0` even when populated, and the `/photos`
|
||||
* response has no total-rows header — X-Count is the per-page row count.
|
||||
* So we fire one `/photos?q=path:X&count=1000` per folder and dedupe by
|
||||
* UID — `merged=false` returns one row per FILE, so a HEIC+JPG companion
|
||||
* pair counts twice if we trusted `data.length`. Capped at the server's
|
||||
* 1000-row ceiling; folders that overflow render as "1000+" in the UI.
|
||||
*
|
||||
* `path:X` is non-recursive in PhotoPrism's q-DSL: it matches direct
|
||||
* children only, so summing the per-path counts (no double-counting from
|
||||
* nested folders) is the right way to derive the root-folder photo
|
||||
* count.
|
||||
*
|
||||
* Returns a plain object keyed by the input paths to keep it JSON-friendly
|
||||
* for TanStack's structural sharing.
|
||||
*/
|
||||
export async function listFolderCounts(paths: string[]): Promise<Record<string, number>> {
|
||||
const entries = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const { data } = await http.get<PpPhoto[]>('/photos', {
|
||||
params: { count: 1000, offset: 0, merged: false, q: `path:${path}` }
|
||||
});
|
||||
const uids = new Set<string>();
|
||||
for (const p of data) uids.add(p.UID);
|
||||
return [path, uids.size] as const;
|
||||
})
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PpGeoFeature {
|
||||
@@ -610,8 +680,21 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
|
||||
// merges server-side, so it's safe to round-trip an incomplete object.
|
||||
|
||||
export interface PpSettings {
|
||||
ui?: { theme?: string; language?: string; scrollbar?: boolean; zoom?: boolean };
|
||||
search?: { batchSize?: number; listView?: boolean; showTitles?: boolean; showCaptions?: boolean };
|
||||
ui?: {
|
||||
theme?: string;
|
||||
language?: string;
|
||||
timeZone?: string;
|
||||
startPage?: string;
|
||||
scrollbar?: boolean;
|
||||
zoom?: boolean;
|
||||
};
|
||||
search?: {
|
||||
batchSize?: number;
|
||||
listView?: boolean;
|
||||
showTitles?: boolean;
|
||||
showCaptions?: boolean;
|
||||
};
|
||||
maps?: { animate?: number; style?: string };
|
||||
index?: { path?: string; convert?: boolean; rescan?: boolean; skipArchived?: boolean };
|
||||
import?: { path?: string; move?: boolean; dest?: string };
|
||||
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
export type Section =
|
||||
| 'all-photos'
|
||||
| 'favorites'
|
||||
| 'review'
|
||||
| 'archive'
|
||||
| 'hidden'
|
||||
| 'heap';
|
||||
|
||||
export interface FilterState {
|
||||
@@ -24,10 +26,14 @@ export interface FilterState {
|
||||
search: string;
|
||||
}
|
||||
|
||||
// Default landing = root folder (`/`). The Folders group sits at the top
|
||||
// of the sidebar; landing inside it gives users a stable starting point
|
||||
// instead of dumping them into the full library. Picking any other view
|
||||
// (favorites, a heap, "All photos") clears `folderPath` to `null`.
|
||||
export const filters = $state<FilterState>({
|
||||
section: 'all-photos',
|
||||
heapUid: null,
|
||||
folderPath: null,
|
||||
folderPath: '/',
|
||||
search: ''
|
||||
});
|
||||
|
||||
@@ -65,9 +71,21 @@ export function filtersToQ(f: FilterState = filters): string {
|
||||
case 'favorites':
|
||||
parts.push('favorite:true');
|
||||
break;
|
||||
case 'review':
|
||||
// PhotoPrism's review pile: photos the indexer flagged as
|
||||
// uncertain (low quality score). Cleared per-photo via the
|
||||
// `/approve` endpoint or by archiving.
|
||||
parts.push('review:true');
|
||||
break;
|
||||
case 'archive':
|
||||
parts.push('archived:true');
|
||||
break;
|
||||
case 'hidden':
|
||||
// Auto-hidden by the indexer (broken files, very low quality
|
||||
// score). Excluded from every other view — this section is the
|
||||
// only way to see them without a manual `q=hidden:true`.
|
||||
parts.push('hidden:true');
|
||||
break;
|
||||
case 'heap':
|
||||
if (f.heapUid) parts.push(`album:${f.heapUid}`);
|
||||
break;
|
||||
@@ -75,7 +93,13 @@ export function filtersToQ(f: FilterState = filters): string {
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (f.folderPath) parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
|
||||
// `/` is the root-folder sentinel. PhotoPrism's `path:` operator can't
|
||||
// express "exact root match" (path:"" / path:/ both fall back to "no
|
||||
// filter"), so we leave the server query unfiltered and let the
|
||||
// timeline post-filter to `Path === ''` client-side.
|
||||
if (f.folderPath && f.folderPath !== '/') {
|
||||
parts.push(`path:${quoteIfNeeded(f.folderPath)}`);
|
||||
}
|
||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -84,13 +108,24 @@ export function filtersToQ(f: FilterState = filters): string {
|
||||
export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
||||
const sectionRaw = params.get('section') as Section | null;
|
||||
const section: Section =
|
||||
sectionRaw && ['all-photos', 'favorites', 'archive', 'heap'].includes(sectionRaw)
|
||||
sectionRaw && ['all-photos', 'favorites', 'review', 'archive', 'hidden', 'heap'].includes(sectionRaw)
|
||||
? sectionRaw
|
||||
: 'all-photos';
|
||||
// Bare URL (no section/folder/heap/q params) lands on the root folder
|
||||
// — same default the store carries. Any explicit param means the user
|
||||
// asked for a specific view, so the folder filter clears unless
|
||||
// `folder=` is supplied on top.
|
||||
const bare =
|
||||
!params.has('section') &&
|
||||
!params.has('folder') &&
|
||||
!params.has('heap') &&
|
||||
!params.has('q');
|
||||
const folderRaw = params.get('folder');
|
||||
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
||||
return {
|
||||
section,
|
||||
heapUid: params.get('heap'),
|
||||
folderPath: params.get('folder'),
|
||||
folderPath,
|
||||
search: params.get('q') ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,12 +23,37 @@ export interface PpClientConfig {
|
||||
previewToken: string;
|
||||
downloadToken: string;
|
||||
flags?: string;
|
||||
/**
|
||||
* Precomputed library counters. PhotoPrism updates these incrementally
|
||||
* on every mutation, so they're cheap to read and accurate without a
|
||||
* separate aggregate query. `all` already nets out review/hidden, which
|
||||
* is what the timeline shows — prefer it over `photos + videos`.
|
||||
*/
|
||||
count?: {
|
||||
all?: number;
|
||||
photos?: number;
|
||||
videos?: number;
|
||||
live?: number;
|
||||
animated?: number;
|
||||
audio?: number;
|
||||
documents?: number;
|
||||
archived?: number;
|
||||
hidden?: number;
|
||||
favorites?: number;
|
||||
review?: number;
|
||||
private?: number;
|
||||
albums?: number;
|
||||
labels?: number;
|
||||
moments?: number;
|
||||
months?: number;
|
||||
states?: number;
|
||||
folders?: number;
|
||||
files?: number;
|
||||
people?: number;
|
||||
places?: number;
|
||||
labels?: number;
|
||||
cameras?: number;
|
||||
lenses?: number;
|
||||
countries?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +75,10 @@ export interface PpPhoto {
|
||||
Hash?: string;
|
||||
/** Auto-derived display name (no extension). */
|
||||
Name?: string;
|
||||
/** Originals-relative folder this photo lives in. Empty string for
|
||||
* photos directly under the originals root; otherwise the folder
|
||||
* path (e.g. `2024/lyon`). Populated on list responses. */
|
||||
Path?: string;
|
||||
/** PhotoPrism filename + extension, populated on list responses. */
|
||||
FileName?: string;
|
||||
/** User-editable original/preferred name. Persisted in DB + sidecar. */
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
setFocused,
|
||||
setOrder
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import { openPreview, preview } from '$lib/stores/preview.svelte';
|
||||
import {
|
||||
setRightSidebarWidth,
|
||||
setThumbnailSize,
|
||||
@@ -59,6 +59,7 @@
|
||||
const next = parseUrlParams(page.url.searchParams);
|
||||
if (next.section !== undefined) filters.section = next.section;
|
||||
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||||
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||||
if (next.search !== undefined) filters.search = next.search;
|
||||
});
|
||||
|
||||
@@ -90,13 +91,25 @@
|
||||
switch (filters.section) {
|
||||
case 'favorites':
|
||||
return 'Favorites';
|
||||
case 'review':
|
||||
return 'Review';
|
||||
case 'archive':
|
||||
return 'Archive';
|
||||
case 'hidden':
|
||||
return 'Hidden';
|
||||
case 'heap': {
|
||||
const heap = (heapsQuery.data ?? []).find((h) => h.UID === filters.heapUid);
|
||||
return heap ? `Heap · ${heap.Title}` : 'Heap';
|
||||
}
|
||||
default:
|
||||
// 'all-photos' is the internal "no section filter" state —
|
||||
// the visible context now comes from the folder filter
|
||||
// (root by default). Show the folder path so the title
|
||||
// reflects what's actually on screen; only the rare
|
||||
// `folderPath === null` case (e.g. right after deleting a
|
||||
// heap) still reads as "All photos".
|
||||
if (filters.folderPath === '/') return 'Folder · /';
|
||||
if (filters.folderPath) return `Folder · ${filters.folderPath}`;
|
||||
return 'All photos';
|
||||
}
|
||||
}
|
||||
@@ -133,8 +146,15 @@
|
||||
* pages can repeat a photo when its file-row span straddles the offset
|
||||
* boundary (a `merged=true` quirk); the Set keeps first occurrence and
|
||||
* preserves order. Downstream (`setOrder`, `rows`, preview, click
|
||||
* handlers) treat this as the single source of truth. */
|
||||
const photos = $derived<PpPhoto[]>(dedupedPhotos(photosQuery.data?.pages));
|
||||
* handlers) treat this as the single source of truth.
|
||||
*
|
||||
* When the user picks the root entry in the folder tree we filter
|
||||
* to `Path === ''` here — PhotoPrism's `path:` operator can't
|
||||
* express that match, so the query fetches the whole library and
|
||||
* we strip subfolder rows post-hoc. */
|
||||
const photos = $derived<PpPhoto[]>(
|
||||
applyFolderScope(dedupedPhotos(photosQuery.data?.pages), filters)
|
||||
);
|
||||
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||
if (!pages) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -148,6 +168,20 @@
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Root-folder scope is the only client-side filter we apply, and only
|
||||
// when the timeline is actually showing a folder view — never when the
|
||||
// user is in a heap, has a free-form search, or is on a non-default
|
||||
// section (favorites / archive / review / hidden). Those views are
|
||||
// scoped server-side via the q-DSL and must not be re-filtered here,
|
||||
// or labels / search will silently drop subfolder photos when the
|
||||
// store hasn't fully hydrated from the URL yet.
|
||||
function applyFolderScope(list: PpPhoto[], f: typeof filters): PpPhoto[] {
|
||||
if (f.folderPath !== '/') return list;
|
||||
if (f.section !== 'all-photos') return list;
|
||||
if (f.heapUid) return list;
|
||||
if (f.search) return list;
|
||||
return list.filter((p) => !p.Path);
|
||||
}
|
||||
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
||||
|
||||
$effect(() => {
|
||||
@@ -331,6 +365,25 @@
|
||||
if (el) scrollTileIntoView(el);
|
||||
}
|
||||
|
||||
// When the preview overlay closes, scroll the just-shown photo back
|
||||
// into the timeline window. PreviewOverlay already keeps
|
||||
// selection.focused in lockstep with preview.uid, so we just need to
|
||||
// make sure that tile is mounted (forcedExpand) and visible — the
|
||||
// blue ring renders itself once the inner button is in the DOM.
|
||||
let wasPreviewOpen = $state(false);
|
||||
$effect(() => {
|
||||
const open = preview.uid !== null;
|
||||
const closing = wasPreviewOpen && !open;
|
||||
wasPreviewOpen = open;
|
||||
if (!closing) return;
|
||||
const uid = selection.focused;
|
||||
if (!uid) return;
|
||||
untrack(() => {
|
||||
const i = photos.findIndex((p) => p.UID === uid);
|
||||
if (i >= 0) void scrollToIndex(i);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Visual rows for keyboard navigation ──────────────────────────────────
|
||||
// The CSS Grid lays each photo into a cell with column count derived from
|
||||
// `repeat(auto-fill, minmax(thumbnailSize, 1fr))`. Month headers span the
|
||||
@@ -557,17 +610,26 @@
|
||||
}
|
||||
|
||||
function onTileClick(e: MouseEvent, uid: string) {
|
||||
// Modifier clicks (shift / cmd / ctrl) are handled by gridKeyNav's
|
||||
// document-level click handler — let them bubble.
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||
if (selection.ids.size > 0) return;
|
||||
// Establish the "starting photo" so a subsequent shift-click extends
|
||||
// the range from this tile. Reset both focus and anchor — anchor on
|
||||
// its own would stick to an older toggle/selectOnly tile and the
|
||||
// shift-range would silently use the wrong starting point. Also
|
||||
// clear the sticky-column intent so the next arrow press anchors
|
||||
// off the clicked tile's actual column.
|
||||
// Plain click: select this tile only. Replaces the previous
|
||||
// "click opens preview" semantics — preview now lives on dblclick.
|
||||
// Reset both focus and anchor so a subsequent shift-click extends
|
||||
// the range from this tile, and clear sticky-column intent so
|
||||
// arrow nav re-anchors off this tile's actual column.
|
||||
selection.ids.clear();
|
||||
selection.ids.add(uid);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
intendedCol = null;
|
||||
}
|
||||
|
||||
function onTileDblclick(e: MouseEvent, uid: string) {
|
||||
// Modifier-modified dblclicks shouldn't open the preview either —
|
||||
// gridKeyNav already handled the underlying click.
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
openPreview(uid, photos.map((p) => p.UID));
|
||||
}
|
||||
|
||||
@@ -657,6 +719,12 @@
|
||||
</Toolbar>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!--
|
||||
Main column wraps the scrollable timeline and the action bar so
|
||||
the bar's width matches the timeline only — the right aside is a
|
||||
sibling at row level and stays full height when the bar appears.
|
||||
-->
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<main
|
||||
bind:this={scrollRoot}
|
||||
class="flex-1 overflow-y-auto outline-none focus:outline-none"
|
||||
@@ -684,6 +752,13 @@
|
||||
Archive is empty.
|
||||
{:else if filters.section === 'favorites'}
|
||||
No favorites yet. Heart a photo to add it here.
|
||||
{:else if filters.section === 'review'}
|
||||
Nothing left to review. Photos PhotoPrism's indexer wasn't sure about
|
||||
land here — use Keep to accept them into the timeline or Archive to
|
||||
set them aside.
|
||||
{:else if filters.section === 'hidden'}
|
||||
No hidden photos. PhotoPrism auto-hides files it can't index (broken
|
||||
files, very low quality); they only ever show up here.
|
||||
{:else if filters.section === 'heap'}
|
||||
This heap has no photos yet. Select some photos and use the bulk bar's
|
||||
"+ Add to heap" button.
|
||||
@@ -742,6 +817,7 @@
|
||||
data-tile
|
||||
data-uid={photo.UID}
|
||||
onclick={(e) => onTileClick(e, photo.UID)}
|
||||
ondblclick={(e) => onTileDblclick(e, photo.UID)}
|
||||
class:scale-90={sel}
|
||||
class:ring-2={sel}
|
||||
class:ring-blue-500={sel}
|
||||
@@ -804,6 +880,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
<BulkActionBar />
|
||||
</div>
|
||||
|
||||
{#if !view.rightSidebarCollapsed}
|
||||
<aside
|
||||
@@ -850,5 +928,3 @@
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
type PhotoMarksMap
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// PhotoPrism's Color is auto-derived from image content — the user-set
|
||||
@@ -110,7 +113,10 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if marksQuery.isPending || photosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading colors…</p>
|
||||
{:else if marksQuery.isError || photosQuery.isError}
|
||||
@@ -121,34 +127,11 @@
|
||||
sidebar to tag it.
|
||||
</p>
|
||||
{:else if selectedGroup}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
|
||||
>
|
||||
{#each selectedGroup.photos as photo (photo.UID)}
|
||||
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openPreview(
|
||||
photo.UID,
|
||||
selectedGroup.photos.map((p) => p.UID)
|
||||
)}
|
||||
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<PhotoGrid photos={selectedGroup.photos} />
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each groups as group (group.key)}
|
||||
{@const rep = group.photos[0]}
|
||||
@@ -178,3 +161,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
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
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { openPreview } from '$lib/stores/preview.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// PhotoPrism doesn't store ratings (it silently drops Rating on PUT) —
|
||||
@@ -102,7 +105,10 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if marksQuery.isPending || photosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading ratings…</p>
|
||||
{:else if marksQuery.isError || photosQuery.isError}
|
||||
@@ -113,34 +119,11 @@
|
||||
(or 1–5 in bulk mode) to rate it.
|
||||
</p>
|
||||
{:else if selectedGroup}
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));"
|
||||
>
|
||||
{#each selectedGroup.photos as photo (photo.UID)}
|
||||
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() =>
|
||||
openPreview(
|
||||
photo.UID,
|
||||
selectedGroup.photos.map((p) => p.UID)
|
||||
)}
|
||||
class="aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
|
||||
>
|
||||
<img
|
||||
src={thumbUrl(hash, 'tile_500')}
|
||||
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<PhotoGrid photos={selectedGroup.photos} />
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-3"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));"
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each groups as group (group.rating)}
|
||||
{@const rep = group.photos[0]}
|
||||
@@ -169,3 +152,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { listLabels, type PpLabel } from '$lib/services/photoprism';
|
||||
import { listLabels, listPhotos, type PpLabel } from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { view } from '$lib/stores/view.svelte';
|
||||
import { type PpPhoto } from '$lib/types/photoprism';
|
||||
import { gridKeyNav } from '$lib/actions/gridKeyNav';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// Mirror /colors: a label grid that drills into a photo grid in place,
|
||||
// instead of navigating away to the timeline. Stays inside /tags so
|
||||
// the user keeps their place when they back out.
|
||||
const labelsQuery = createQuery<PpLabel[]>(() => ({
|
||||
queryKey: ['labels'],
|
||||
queryFn: listLabels,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
async function openLabel(slug: string) {
|
||||
// Tags drive search — clicking jumps to the timeline with the label
|
||||
// term applied. Bookmarkable URL via the existing filter sync.
|
||||
await goto(`/?q=${encodeURIComponent(`label:${slug}`)}`);
|
||||
let selectedSlug = $state<string | null>(null);
|
||||
const selectedLabel = $derived(
|
||||
selectedSlug !== null
|
||||
? (labelsQuery.data ?? []).find(
|
||||
(l) => (l.CustomSlug ?? l.Slug) === selectedSlug
|
||||
) ?? null
|
||||
: null
|
||||
);
|
||||
|
||||
// Photo pool for the selected label. PhotoPrism's q-DSL filters
|
||||
// server-side; we cap at 1000 (the server's hard ceiling) to keep the
|
||||
// page reactive without paginating in place.
|
||||
const labelPhotosQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'label', selectedSlug ?? ''],
|
||||
queryFn: () =>
|
||||
listPhotos({
|
||||
q: `label:${selectedSlug}`,
|
||||
count: 1000,
|
||||
order: 'newest',
|
||||
merged: true
|
||||
}),
|
||||
enabled: isAuthenticated() && Boolean(selectedSlug)
|
||||
}));
|
||||
|
||||
function pickLabel(slug: string) {
|
||||
selectedSlug = slug;
|
||||
}
|
||||
function clearSelection() {
|
||||
selectedSlug = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,6 +54,24 @@
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Tags
|
||||
</span>
|
||||
{#if selectedLabel}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-xs hover:bg-accent"
|
||||
onclick={clearSelection}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span class="flex items-center gap-1.5 text-[11px] font-medium">
|
||||
{selectedLabel.Name}
|
||||
</span>
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{labelPhotosQuery.data?.length ?? selectedLabel.PhotoCount ?? 0} photo{(labelPhotosQuery
|
||||
.data?.length ?? 0) === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</span>
|
||||
{/if}
|
||||
{#snippet trailing()}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{labelsQuery.data?.length ?? 0} label{labelsQuery.data?.length === 1 ? '' : 's'}
|
||||
@@ -29,7 +79,10 @@
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<main
|
||||
class="min-h-0 flex-1 overflow-y-auto p-6 outline-none focus:outline-none"
|
||||
use:gridKeyNav={{}}
|
||||
>
|
||||
{#if labelsQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading labels…</p>
|
||||
{:else if labelsQuery.isError}
|
||||
@@ -39,13 +92,26 @@
|
||||
No labels yet. PhotoPrism's TensorFlow indexer generates these from photo content; if the
|
||||
indexer hasn't run on real photos yet, the list will be empty.
|
||||
</p>
|
||||
{:else if selectedLabel}
|
||||
{#if labelPhotosQuery.isPending}
|
||||
<p class="text-sm text-muted-foreground">Loading photos…</p>
|
||||
{:else if labelPhotosQuery.isError}
|
||||
<p class="text-sm text-destructive">Failed to load photos for this label.</p>
|
||||
{:else if (labelPhotosQuery.data ?? []).length === 0}
|
||||
<p class="text-sm text-muted-foreground">No photos tagged with this label.</p>
|
||||
{:else}
|
||||
<PhotoGrid photos={labelPhotosQuery.data ?? []} />
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="grid gap-3" style="grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));">
|
||||
<div
|
||||
class="grid gap-2"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
||||
>
|
||||
{#each labelsQuery.data ?? [] as label (label.UID)}
|
||||
<button
|
||||
type="button"
|
||||
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
||||
onclick={() => openLabel(label.CustomSlug ?? label.Slug)}
|
||||
onclick={() => pickLabel(label.CustomSlug ?? label.Slug)}
|
||||
>
|
||||
{#if label.Thumb}
|
||||
<img
|
||||
@@ -66,3 +132,5 @@
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<BulkActionBar />
|
||||
|
||||
Reference in New Issue
Block a user