Compare commits
18 Commits
feat/gpu-c
...
claude/str
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e164c48d0 | |||
| 259adb6a41 | |||
| ccf2c6b7c7 | |||
| a13e171295 | |||
| 73c36b4817 | |||
| 82f2a40269 | |||
| f6c0f7a507 | |||
| 1df16a6142 | |||
| 5da1022ed1 | |||
|
|
da63ad769a | ||
|
|
86e38e152d | ||
|
|
3757eb0170 | ||
|
|
cfd0c6aa81 | ||
| 243e5d3831 | |||
| 14a1b4e54e | |||
| 7df1c04c0f | |||
| 8f97590d9f | |||
| 4c08eba27a |
13
.env.example
13
.env.example
@@ -65,6 +65,19 @@ PP_GID=1000
|
||||
# OIDC_ROLE=user
|
||||
|
||||
|
||||
# ── USER LIBRARY ISOLATION ───────────────────────────────────────────────────
|
||||
# Maps PhotoPrism usernames to originals-relative subdirectories so each
|
||||
# user only sees their own photos. Format: comma-separated user:path pairs.
|
||||
# The sidecar reconciler applies this to auth_users.base_path on boot and
|
||||
# every 60s. Leave empty for single-user deployments.
|
||||
#
|
||||
# USER_BASEPATHS="alice:alice, bob:bob"
|
||||
|
||||
# Sidecar DB password — provisioned by mariadb/init/01-sidecar.sql on first
|
||||
# boot. Rotate before any non-local deployment.
|
||||
# SIDECAR_DB_PASSWORD=replace-at-m4-bringup
|
||||
|
||||
|
||||
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||
|
||||
PP_LOG_LEVEL=info
|
||||
|
||||
247
.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
Normal file
247
.hermes/plans/2026-06-06_120000-photos_users-label-isolation.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Plan: Populate `photos_users` to fix label isolation in PhotoPrism
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the label isolation leak where a user with `base_path` set (e.g. `muli`) sees photos from other users' directories (e.g. `dtoro`) in PhotoPrism's labels view.
|
||||
|
||||
## 2. Current Context
|
||||
|
||||
### The problem
|
||||
- PhotoPrism's `base_path` feature correctly scopes the main search (`/api/v1/photos`).
|
||||
- Label views (`/api/v1/labels`) do **not** respect `base_path` — labels show photo counts and thumbnails from the entire library.
|
||||
- User reports: "all photos on the main labels page are a mix of both" muli and dtoro.
|
||||
|
||||
### What we know
|
||||
- **53 files changed** in the sidecar (Go + GORM, gorm.io/gorm v1.31.1).
|
||||
- Sidecar has a working PhotoPrism DB connection via `PpDSN` (user: `photoprism`, schema: `photoprism.*`).
|
||||
- The `photos_users` table exists in PhotoPrism's MariaDB schema but is **empty** (0 rows).
|
||||
- Schema of `photos_users`:
|
||||
|
||||
```
|
||||
photos_users:
|
||||
uid varbinary(42) NOT NULL PRI (composite PK or single?)
|
||||
user_uid varbinary(42) NOT NULL PRI
|
||||
team_uid varbinary(42) YES MUL
|
||||
perm int(10) unsigned YES
|
||||
```
|
||||
|
||||
- Known user UIDs: `dtoro=utfetfdk0so2z9zl`, `muli=utg7jjbd8iwaghn6`
|
||||
- Known base paths: `dtoro→dtoro`, `muli→muli`
|
||||
- The `photo_path` column in `photos` stores paths like `muli/files/Photo Archive...` or `dtoro/Memories/...`
|
||||
- Currently: ~88K photos, ~52K files indexed.
|
||||
|
||||
### The sidecar's current reconciler (`users.go`)
|
||||
- Runs every 60s.
|
||||
- Only calls `UPDATE auth_users SET base_path = ? WHERE user_name = ?`.
|
||||
- Does **not** touch `photos_users`.
|
||||
|
||||
### Unknowns
|
||||
1. **Does PhotoPrism use `photos_users` for general label filtering?** The table appears designed for explicit sharing (e.g. share a specific photo with another user), not for base_path ACL. PhotoPrism may ignore `photos_users` in label queries.
|
||||
2. **Performance impact**: 88K photos × 2 users = up to 176K rows. Could slow label queries.
|
||||
3. **Side effects**: If `photos_users` controls sharing, adding auto-entries might break explicit share workflow.
|
||||
4. **`perm` values**: Unclear what `perm` value grants "view" access. Likely a bitmap (bit 0 = view).
|
||||
|
||||
## 3. Proposed Approach
|
||||
|
||||
### Phase 1: Investigate (prove the approach works before building)
|
||||
|
||||
**Step 1.1: Insert test rows into `photos_users` manually**
|
||||
|
||||
On the production DB, insert a few `photos_users` entries for muli mapping to some of muli's own photos, plus one entry mapping to a dtoro photo. Use a guessed `perm` value (e.g. `1` = view).
|
||||
|
||||
Then check:
|
||||
- Does muli see fewer photos now? (If `photos_users` works as an exclusive ACL, yes.)
|
||||
- Does the dtoro photo with a `photos_users` entry for muli show up for muli?
|
||||
- Does the label view change?
|
||||
|
||||
**Step 1.2: Test with `perm` variations**
|
||||
|
||||
If `perm=1` does nothing, try `perm=2`, `perm=7`, or `perm=15` (common Unix-ish bitmap patterns).
|
||||
|
||||
**Step 1.3: Examine PhotoPrism source**
|
||||
|
||||
Check PhotoPrism's search/label code to confirm whether `photos_users` is joined in label queries. This tells us definitively whether the approach is viable.
|
||||
|
||||
### Phase 2: Build (if Phase 1 confirms the approach works)
|
||||
|
||||
**Step 2.1: Add `photos_users` GORM model**
|
||||
|
||||
New struct in `db.go` or a new file `perms.go`:
|
||||
|
||||
```go
|
||||
type PhotoUser struct {
|
||||
PhotoUID string `gorm:"primaryKey;size:42;column:uid"`
|
||||
UserUID string `gorm:"primaryKey;size:42;column:user_uid"`
|
||||
TeamUID string `gorm:"size:42;column:team_uid"`
|
||||
Perm int `gorm:"column:perm"`
|
||||
}
|
||||
|
||||
func (PhotoUser) TableName() string { return "photos_users" }
|
||||
```
|
||||
|
||||
Note: GORM `AutoMigrate` is called on `mule_sidecar` schema, not `photoprism.*`. The `photos_users` table already exists in the `photoprism` schema — we only query/insert, never migrate.
|
||||
|
||||
**Step 2.2: Add `reconcilePhotoUsers` function**
|
||||
|
||||
New function in a new file `perms.go` alongside `users.go`. Signature:
|
||||
|
||||
```go
|
||||
func reconcilePhotoUsers(ppDSN, originalsRoot string, mapping map[string]string) error
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. For each `username:path` pair in `mapping`, look up the user's `user_uid` in `auth_users`.
|
||||
2. Query `photos` for all `photo_uid` where `photo_path LIKE 'path/%'`.
|
||||
3. Batch-insert entries into `photos_users` with a default `perm` value (to be determined in Phase 1).
|
||||
4. Use `INSERT IGNORE` or `ON DUPLICATE KEY UPDATE` for idempotency.
|
||||
5. Handle deletions: if a photo's path is changed (via rename), the old `photos_users` entry should be cleaned up.
|
||||
|
||||
**Step 2.3: Wire into reconciler loop**
|
||||
|
||||
Extend the existing `startUserBasepathReconciler` to call `reconcilePhotoUsers` after `reconcileUserBasepaths`.
|
||||
|
||||
```go
|
||||
func apply() {
|
||||
reconcileUserBasepaths(...)
|
||||
reconcilePhotoUsers(...)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2.4: Handle re-index edge cases**
|
||||
|
||||
- When new photos are indexed, they won't have `photos_users` entries until the next 60s tick.
|
||||
- Could add a webhook or a one-shot trigger after PhotoPrism's index completes.
|
||||
- Alternative: accept the 60s lag as a design trade-off (current base_path reconciler already has this lag).
|
||||
|
||||
### Phase 3: Validate (if Phase 1 confirms)
|
||||
|
||||
1. Build the binary: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
|
||||
2. Rebuild the Docker image and restart the sidecar.
|
||||
3. Check `photos_users` has expected rows.
|
||||
4. Log in as `muli` via Authentik SSO, browse labels — verify dtoro photos are gone.
|
||||
5. Log in as `dtoro` — verify still sees own photos.
|
||||
6. Verify no regression: search, album, folder views still work for both users.
|
||||
|
||||
## 4. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sidecar/perms.go` | **New file** — `PhotoUser` model, `reconcilePhotoUsers` function |
|
||||
| `sidecar/db.go` | Add `photos_users`-related constants/helpers (optional) |
|
||||
| `sidecar/users.go` | Extend `reconcileUserBasepaths` or add a phase to the existing reconciler |
|
||||
| `sidecar/main.go` | Wire the new reconciler phase (minor — call from existing ticker) |
|
||||
| `sidecar/Dockerfile` | Unchanged (Go build picks up new `.go` files automatically) |
|
||||
|
||||
## 5. Tests & Validation
|
||||
|
||||
1. **Build check**: `go build ./...` from `sidecar/`
|
||||
2. **Manual DB test** (Phase 1): Insert test `photos_users` rows via `docker exec pp-mariadb mysql ...`
|
||||
3. **Integration test**: After deploy, check `photos_users` row count matches expected photo count per user.
|
||||
4. **Label isolation check**: Browse labels as each user — confirm no cross-user leaks.
|
||||
|
||||
## 6. Source Code Analysis (Completed)
|
||||
|
||||
### How base_path scoping works in PhotoPrism
|
||||
|
||||
Found the critical function `ScopePhotosForSession` in `internal/entity/search/photos_scope.go`:
|
||||
|
||||
```go
|
||||
func ScopePhotosForSession(stmt *gorm.DB, sess *entity.Session) *gorm.DB {
|
||||
// Admin/library role → no scoping needed
|
||||
if sess == nil || acl.Rules.AllowAny(acl.ResourcePhotos, sess.GetUserRole(), acl.Permissions{acl.AccessAll, acl.AccessLibrary}) {
|
||||
return stmt
|
||||
}
|
||||
user := sess.GetUser()
|
||||
if basePath := user.GetBasePath(); basePath == "" {
|
||||
return stmt.Where(sharedAlbums + "photos.created_by = ? OR ...", ...)
|
||||
} else {
|
||||
return stmt.Where(sharedAlbums + "... OR photos.photo_path = ? OR photos.photo_path LIKE ?",
|
||||
..., basePath, basePath + "/%")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key: base_path filtering is done by adding `WHERE photos.photo_path LIKE 'muli/%'` to the SQL query. It is **NOT** done via `photos_users`.
|
||||
|
||||
### How endpoints use base_path
|
||||
|
||||
| Endpoint | Function | Applies base_path? |
|
||||
|----------|----------|-------------------|
|
||||
| `GET /api/v1/photos` | `SearchPhotos` → `UserPhotos` → `searchPhotos` → **`ScopePhotosForSession`** | ✅ Yes |
|
||||
| `GET /api/v1/labels` | `SearchLabels` → `search.Labels(frm)` — no session passed | ❌ **No** |
|
||||
| Review tab | Uses `GET /api/v1/photos?quality=3` → goes through `ScopePhotosForSession` | ✅ Should scope |
|
||||
| Archive tab | Uses `GET /api/v1/photos?archived=true` → goes through `ScopePhotosForSession` | ✅ Should scope |
|
||||
| Albums | TBD — depends on whether they use `ScopePhotosForSession` | ⚠️ Unknown |
|
||||
|
||||
### The `photos_users` table
|
||||
|
||||
Found in `internal/entity/photo_user.go`:
|
||||
|
||||
```go
|
||||
type PhotoUser struct {
|
||||
UID string // photo_uid
|
||||
UserUID string // user_uid
|
||||
TeamUID string // team_uid
|
||||
Perm uint // permission bitmap
|
||||
}
|
||||
```
|
||||
|
||||
This table is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. It is only used for **explicit sharing** (via `FirstOrCreatePhotoUser` called when sharing a specific photo with another user).
|
||||
|
||||
**Conclusion: Populating `photos_users` will NOT fix the label, review, or archive tab isolation.** PhotoPrism does not consult this table for any of these queries.
|
||||
|
||||
### Why review/archive might show cross-user photos
|
||||
|
||||
Since review and archive use `GET /api/v1/photos` which goes through `ScopePhotosForSession`, they **should** be scoped. The issue might be:
|
||||
1. **Pre-computed counts** in the sidebar tabs show total numbers across all users
|
||||
2. **Label thumbnails** and category summaries are computed from the `labels` table which is global
|
||||
3. The actual photo list in review/archive should be correctly scoped — the user may be seeing dtoro photos only in the summary/counts
|
||||
|
||||
### DB experiment results
|
||||
|
||||
Confirmed `photos_users` is empty (0 rows). Inserted 100 muli-photo entries + 1 dtoro-photo entry for muli with `perm=1`. Label API response unchanged — `photo_count` values remained the same (Dog: 733, Cat: 57), confirming labels ignore `photos_users`.
|
||||
|
||||
## 7. Updated Recommendation
|
||||
|
||||
**Abandon the `photos_users` approach.** It won't fix the problem because PhotoPrism never consults this table for labels, review, or archive queries.
|
||||
|
||||
### Real fix options
|
||||
|
||||
1. **Sidecar label filter** (recommended) — The sidecar already validates sessions via `resolveSession()` which returns the user's `BasePath`. Extend the sidecar to expose a **proxied `/api/v1/labels`** endpoint that:
|
||||
- Accepts the caller's `X-Auth-Token` (already validated by `requireSession`)
|
||||
- Forwards the request to PhotoPrism's `/api/v1/labels`
|
||||
- **Filters the response** to remove labels whose `Thumb` belongs to a photo outside the user's `base_path`
|
||||
- Recalculates `PhotoCount` for the user's scope (count photos under `base_path/%` for that label)
|
||||
- Also filter `Count` values in the sidebar summary response
|
||||
|
||||
**Why this works:** The sidecar already has DB access to PhotoPrism's schema (`PpDSN`) and validates sessions. It can query `photos` to count label intersections per base_path.
|
||||
|
||||
2. **Same approach for review/archive sidebar counts** — Intercept the relevant metadata/summary endpoints to scope counts by base_path.
|
||||
|
||||
3. **Accept the limitation** — Labels show cross-user thumbnails/counts but the actual photo list is scoped.
|
||||
|
||||
### Implementation sketch for option 1
|
||||
|
||||
```
|
||||
sidecar/
|
||||
├── proxy.go # New file
|
||||
│ ├── handleLabels(c) → GET /api/sidecar/labels → proxies to PP, filters by base_path
|
||||
│ ├── handleReviewCount(c) → GET /api/sidecar/review → returns scoped count
|
||||
│ └── handleArchiveCount(c) → GET /api/sidecar/archive → returns scoped count
|
||||
```
|
||||
|
||||
The SvelteKit frontend would call `/api/sidecar/labels` instead of `/api/v1/labels`.
|
||||
|
||||
### Clean up: remove test rows from photos_users
|
||||
|
||||
Since the approach won't work, remove the test rows inserted during Phase 1:
|
||||
|
||||
```sql
|
||||
DELETE FROM photoprism.photos_users WHERE user_uid = 'utg7jjbd8iwaghn6';
|
||||
```
|
||||
@@ -0,0 +1,301 @@
|
||||
# Plan: Fix user isolation in PhotoPrism — labels, review, and archive views
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the three views where a user with `base_path` set (e.g. `muli`) sees photos from other users (e.g. `dtoro`):
|
||||
|
||||
1. **Labels** — labels list + label drill-down show all library photos
|
||||
2. **Review** — photos needing review tab shows cross-user photos
|
||||
3. **Archive** — archived photos tab shows cross-user photos
|
||||
|
||||
## 2. Current Context & Source Analysis
|
||||
|
||||
### 2.1 How base_path scoping works
|
||||
|
||||
PhotoPrism's `ScopePhotosForSession` (in `internal/entity/search/photos_scope.go`) is the only function that enforces user isolation. It adds `WHERE photos.photo_path = '<basePath>' OR photos.photo_path LIKE '<basePath>/%'` to the SQL query.
|
||||
|
||||
This is called by `searchPhotos()` — used by the **`GET /api/v1/photos`** endpoint (search, timeline, folders).
|
||||
|
||||
### 2.2 How endpoints use base_path
|
||||
|
||||
| View | Endpoint | Function chain | Applies base_path? |
|
||||
|------|----------|----------------|-------------------|
|
||||
| Main timeline | `GET /api/v1/photos` | `SearchPhotos` → `searchPhotos` → `ScopePhotosForSession` | ✅ Yes |
|
||||
| Folders | `GET /api/v1/photos` with path filter | Same chain | ✅ Yes |
|
||||
| **Labels** | `GET /api/v1/labels` | `SearchLabels` → `search.Labels(frm)` — **no session** | ❌ **No** — queries `labels` table directly |
|
||||
| Label drill-down | `GET /api/v1/photos?label=X` | Uses `searchPhotos` → `ScopePhotosForSession` | ✅ Should scope (if label= param doesn't bypass) |
|
||||
| **Review tab** | `GET /api/v1/photos?q=review:true` | `searchPhotos` → `ScopePhotosForSession` | ✅ Should scope, BUT... |
|
||||
| **Archive tab** | `GET /api/v1/photos?q=archived:true` | Same | ✅ Should scope, BUT... |
|
||||
|
||||
### 2.3 The review/archive problem: ACL overrides
|
||||
|
||||
In `searchPhotosForm()` (internal/api/photos_search.go):
|
||||
|
||||
```go
|
||||
if acl.Rules.Deny(acl.ResourcePhotos, s.GetUserRole(), acl.ActionManage) {
|
||||
frm.Quality = 3
|
||||
}
|
||||
```
|
||||
|
||||
For role=user, `Deny(ActionManage)` → true → sets `frm.Quality = 3` (minimum quality).
|
||||
|
||||
Then in `searchPhotos()` (internal/entity/search/photos.go):
|
||||
|
||||
```go
|
||||
if acl.Rules.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) {
|
||||
frm.Archived = false
|
||||
frm.Review = false
|
||||
}
|
||||
```
|
||||
|
||||
For role=user, `Deny(ActionDelete)` → true → **overrides `review:true` and `archived:true` to false**.
|
||||
|
||||
So the review and archive filters are **completely ignored** for the `user` role. The frontend sends `review:true` but the server discards it. The result: the review/archive tabs show ALL photos scoped by base_path (no quality/review/archive filter), which means basically the same as the main timeline.
|
||||
|
||||
### 2.4 Label problem: no session scoping at all
|
||||
|
||||
`search.Labels()` queries the `labels` table directly with a `WHERE photo_count > 0` clause. There is no session parameter, no `ScopePhotosForSession`, and no base_path or user filtering whatsoever. Labels are **library-wide** in PhotoPrism.
|
||||
|
||||
The label drill-down (click into a label) uses `GET /api/v1/photos?label=X` which DOES go through `ScopePhotosForSession`, so the photo list itself should be scoped — but the label thumbnails, counts, and covers are global.
|
||||
|
||||
### 2.5 ACL GrantDefaults — missing RoleUser entry
|
||||
|
||||
```go
|
||||
var GrantDefaults = Roles{
|
||||
RoleAdmin: GrantFullAccess, // FullAccess = AccessLibrary + everything
|
||||
RoleGuest: GrantReactShared, // Only shared content
|
||||
RoleVisitor: GrantViewShared, // Only shared content
|
||||
RolePortal: GrantFullAccess,
|
||||
RoleClient: GrantFullAccess,
|
||||
// RoleUser and RoleViewer are NOT listed → fallback to RoleDefault (also missing) → denied
|
||||
}
|
||||
```
|
||||
|
||||
Because `RoleUser` is absent from `GrantDefaults`, the `Allow()` function falls back to `RoleDefault` which is also absent → returns `false` for all permissions. This means:
|
||||
- `ScopePhotosForSession` correctly enters the `base_path` branch (good — user is isolated)
|
||||
- BUT `ActionDelete` is denied → review/archive filters are forced off (bad — can't browse review/archive)
|
||||
|
||||
### 2.6 The `photos_users` table — ruled out
|
||||
|
||||
`internal/entity/photo_user.go` defines `PhotoUser` but it is **not referenced** in `ScopePhotosForSession`, `searchPhotos`, or any label/album search function. Populating it won't fix any of these issues.
|
||||
|
||||
## 3. Proposed Approach
|
||||
|
||||
### Phase 1: Sidecar proxy for labels (direct fix)
|
||||
|
||||
Extend the sidecar to expose a **scoped labels endpoint**:
|
||||
|
||||
```
|
||||
GET /api/sidecar/labels → proxies to GET /api/v1/labels → filters by base_path
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Sidecar receives the caller's `X-Auth-Token`
|
||||
2. `requireSession` middleware resolves the token → returns user's `BasePath`
|
||||
3. Sidecar makes the same `/api/v1/labels` request to PhotoPrism (using the caller's token)
|
||||
4. **Filter step**: for each label in the response, query the DB to count photos with that label AND `photo_path LIKE '<base_path>/%'`
|
||||
5. Return filtered labels with corrected `PhotoCount` and `Thumb`
|
||||
|
||||
**Implementation:**
|
||||
|
||||
New file `sidecar/handlers_labels.go`:
|
||||
|
||||
```go
|
||||
// handleLabels proxies to PP's /api/v1/labels, then post-filters
|
||||
// counts and thumbnails by the caller's base_path.
|
||||
func handleLabels(pp *ppClient, ppDSN string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
user := ctxUser(c) // resolved from session, includes BasePath
|
||||
|
||||
// 1. Get raw labels from PhotoPrism
|
||||
resp, _ := pp.call(c, "GET", "/api/v1/labels?"+c.Request.URL.RawQuery, token, nil)
|
||||
|
||||
// 2. Decode labels
|
||||
var labels []PpLabel
|
||||
json.Unmarshal(resp.Body, &labels)
|
||||
|
||||
// 3. For each label, recalculate count for this user's base_path
|
||||
for i, l := range labels {
|
||||
// Count photos with this label AND where photo_path matches base_path
|
||||
var count int
|
||||
db.Raw(`SELECT COUNT(*) FROM photos_labels pl
|
||||
JOIN photos p ON pl.photo_uid = p.photo_uid
|
||||
WHERE pl.label_uid = ? AND p.photo_path LIKE ?`,
|
||||
l.UID, user.BasePath+"/%").Scan(&count)
|
||||
labels[i].PhotoCount = count
|
||||
|
||||
// If count is 0, the thumb from the global label doesn't apply
|
||||
// Could also update thumb to a user-scoped one
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, labels)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend change:** Update the label query in `web/src/routes/tags/[category]/[[value]]/+page.svelte` to call `/api/sidecar/labels` instead of `/api/v1/labels`.
|
||||
|
||||
### Phase 2: Fix review/archive — skip the ACL override
|
||||
|
||||
Two options:
|
||||
|
||||
**Option A (Recommended): Sidecar proxy for photos search**
|
||||
|
||||
Extend the sidecar with:
|
||||
```
|
||||
GET /api/sidecar/photos → proxies to GET /api/v1/photos → adds path filter
|
||||
```
|
||||
|
||||
The sidecar intercepts the photos request and adds the `path:<basePath>` query parameter to PhotoPrism's API call. This forces PhotoPrism to add `WHERE photo_path LIKE '<base_path>/%'`.
|
||||
|
||||
For review/archive, the sidecar also adds `review:true` or `archived:true` BEFORE the ACL override happens (since the sidecar doesn't hit the ACL code).
|
||||
|
||||
**Option B: Custom frontend query**
|
||||
|
||||
The frontend explicitly adds `path:muli` to the query string for review/archive tabs:
|
||||
```
|
||||
GET /api/v1/photos?q=review:true path:muli&count=50
|
||||
```
|
||||
|
||||
The `path` filter is a standard PhotoPrism search operator that adds `WHERE photos.photo_path = '<path>'`. But this only matches the exact path, not `path/%` (subdirectories). The `path:` operator does `photo_path = ?` (exact match) per the code at line 668.
|
||||
|
||||
**Option A is better** because:
|
||||
- Works for all users without frontend changes
|
||||
- Can add the proper `LIKE` prefix match
|
||||
- Centralized logic in the sidecar
|
||||
|
||||
### Phase 3: Sidecar proxy for sidebar counts
|
||||
|
||||
The session response (or `GET /api/v1/config`) includes library-wide counts:
|
||||
```json
|
||||
"count": {
|
||||
"review": 248,
|
||||
"archived": 94,
|
||||
"all": 88203,
|
||||
"photos": 88000
|
||||
}
|
||||
```
|
||||
|
||||
These show the TOTAL across all users. The sidecar can proxy this and recalculate counts per base_path.
|
||||
|
||||
## 4. Step-by-step Plan
|
||||
|
||||
### Step 1: Sidecar — labels proxy
|
||||
|
||||
Files: `sidecar/handlers_labels.go` (new), `sidecar/main.go` (route wiring)
|
||||
|
||||
1. New types: `PpLabel` (mirrors PhotoPrism's label response shape)
|
||||
2. Handler function `handleLabels()` that:
|
||||
- Validates token via `requireSession`
|
||||
- Gets `BasePath` from session
|
||||
- Calls PhotoPrism's `/api/v1/labels`
|
||||
- For each label, queries photos_labels + photos to count user-scoped photos
|
||||
- Returns filtered labels
|
||||
3. Wire route: `auth.GET("/labels", handleLabels(...))` in `main.go`
|
||||
4. Frontend: change label fetch URL from `/api/v1/labels` to `/api/sidecar/labels`
|
||||
|
||||
### Step 2: Sidecar — photos proxy (review/archive fix)
|
||||
|
||||
Files: `sidecar/handlers_photos.go` (new), `sidecar/main.go` (route wiring)
|
||||
|
||||
1. Handler function `handlePhotos()` that:
|
||||
- Validates token
|
||||
- Gets `BasePath` from session
|
||||
- Parses the query string to detect `review:true` or `archived:true`
|
||||
- Forwards to PhotoPrism's `/api/v1/photos` with `path:<basePath>` added to query
|
||||
- For review/archive, also ensures `review/archived` filter is NOT stripped
|
||||
- Returns PhotoPrism's response
|
||||
|
||||
2. Two implementation variants:
|
||||
|
||||
**Variant A** (simpler): add `path:<basePath>` to the forwarded query. This only matches exact path, not subdirs (PhotoPrism's `path:` operator does exact match). Might miss photos in subdirectories.
|
||||
|
||||
**Variant B** (correct): Forward the query without path, then post-filter the response to remove photos whose `photo_path` doesn't match `basePath/%`. This is more robust.
|
||||
|
||||
### Step 3: Validation
|
||||
|
||||
1. Build sidecar: `cd sidecar && CGO_ENABLED=0 go build -o mule-sidecar .`
|
||||
2. Rebuild Docker image: `docker compose build sidecar`
|
||||
3. Restart sidecar: `docker compose up -d sidecar`
|
||||
4. Test labels as muli — verify only muli's labels appear
|
||||
5. Test review tab as muli — verify only muli's photos needing review appear
|
||||
6. Test archive tab as muli — verify only muli's archived photos appear
|
||||
7. Test same views as admin — verify dtoro still sees all
|
||||
|
||||
## 5. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sidecar/handlers_labels.go` | **New** — label proxy handler |
|
||||
| `sidecar/handlers_photos.go` | **New** — photos proxy handler (or merged into one proxy.go) |
|
||||
| `sidecar/handlers_folder.go` | Reference for existing handler patterns |
|
||||
| `sidecar/main.go` | Wire new routes under `auth` group |
|
||||
| `sidecar/pp.go` | May need new helper methods for label/photo API calls |
|
||||
| `sidecar/users.go` | No change |
|
||||
| `sidecar/db.go` | May add types for PpLabel, PpPhoto |
|
||||
| `web/src/routes/tags/[category]/[[value]]/+page.svelte` | Change label fetch URL |
|
||||
| `web/src/lib/stores/filters.svelte.ts` | Possibly change how review/archive queries are built |
|
||||
|
||||
## 6. Tests & Validation
|
||||
|
||||
**Build**: `cd sidecar && go build ./... && go vet ./...`
|
||||
|
||||
**Manual validation on LXC 120:**
|
||||
|
||||
```bash
|
||||
# Test labels endpoint
|
||||
curl -s "http://localhost:8000/api/sidecar/labels?count=5" \
|
||||
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);[print(l.get('Name','?'),l.get('PhotoCount')) for l in d[:5]]"
|
||||
|
||||
# Test photos endpoint with review
|
||||
curl -s "http://localhost:8000/api/sidecar/photos?q=review:true&count=5" \
|
||||
-H "X-Auth-Token: <muli-token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(f'{len(d)} photos')"
|
||||
|
||||
# Verify vs. admin token — counts should differ
|
||||
```
|
||||
|
||||
**Cross-user check:** Log in as `muli` and `dtoro` in separate browser sessions. Verify:
|
||||
- Labels show different counts per user
|
||||
- Review photos are scoped per user
|
||||
- Archive photos are scoped per user
|
||||
|
||||
## 7. Risks, Tradeoffs & Open Questions
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Sidecar proxying adds latency | Slower page loads | Labels are small payloads; single DB query per label is fast |
|
||||
| Frontend needs URL changes | Breaks if not updated | Do frontend change alongside sidecar deploy |
|
||||
| Photo count queries on every label request | DB load | Cache results for 30s in the sidecar |
|
||||
| PhotoPrism's label `PhotoCount` is stale | Mismatch with actual count | Acceptable — PhotoPrism's count is already cached |
|
||||
| Review/archive fix depends on how PhotoPrism handles `path:` operator | Photos in subdirs missed | Use Variant B (post-filter by path prefix) |
|
||||
|
||||
### Open Questions
|
||||
|
||||
- **Q1**: For review/archive — is the user seeing dtoro's photos in the *grid* or only the *sidebar counts*? Need to verify actual API response vs what the frontend renders.
|
||||
- **Q2**: What's the performance impact of running `SELECT COUNT(*) FROM photos_labels ... JOIN photos ...` for every label in the response? (Labels list is typically short, < 100)
|
||||
- **Q3**: Does the frontend cache the label response aggressively? Need to invalidate cache on user switch.
|
||||
- **Q4**: For the `path:` operator — does it do exact match or LIKE? From source: `WHERE photos.photo_path = ?` — exact match only.
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- **Sidecar proxy vs. frontend-only**: Proxy centralizes logic but adds network hop. Frontend-only is faster but more complex (every route needs path filtering).
|
||||
- **Label count accuracy**: Recalculated per-user counts will differ from the library-wide counts. This is intentional — labels are scoped now.
|
||||
- **Sidecar vs. patching PhotoPrism**: Sidecar approach is non-invasive (no fork/build of PP). PhotoPrism patch would be cleaner but requires maintaining a fork.
|
||||
|
||||
## 8. Recommendation
|
||||
|
||||
1. **Build the labels proxy** (Phase 1) — it directly solves the label isolation problem and can be done with existing sidecar infrastructure
|
||||
2. **Investigate review/archive leak** first — run the actual API query as muli to confirm whether the photos search is actually scoped. The code analysis says it should be, but the user reports otherwise. If confirmed as a real leak, implement the sidecar photos proxy (Phase 2)
|
||||
3. **Sidebar counts** (Phase 3) — lower priority, can be done after labels and review/archive are fixed
|
||||
|
||||
Before building, confirm with the user whether they see cross-user photos in the actual grid or only in the sidebar counts for review/archive.
|
||||
227
.hermes/plans/2026-06-06_210000-remaining-fixes.md
Normal file
227
.hermes/plans/2026-06-06_210000-remaining-fixes.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Plan: Fix remaining user isolation issues — 404 errors and folder tree
|
||||
|
||||
**Date:** 2026-06-06
|
||||
**Author:** Hermes Agent
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Fix the remaining issues after deploying the sidecar scoping proxy:
|
||||
|
||||
1. **404 on photo grid** — "Request failed with status code 404" in private window
|
||||
2. **Folder tree shows other users** — on first load, the library tree lists other users' folders; a refresh fixes it
|
||||
|
||||
## 2. Current Context
|
||||
|
||||
### What's deployed
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| Sidecar labels proxy (`/api/sidecar/labels`) | ✅ Working |
|
||||
| Sidecar counts proxy (`/api/sidecar/counts`) | ✅ Working |
|
||||
| Sidecar timeline proxy (`/api/sidecar/timeline`) | ✅ Working through Caddy |
|
||||
| Caddy fallback for `/api/v1/api/sidecar/*` | ✅ Working |
|
||||
| Frontend rebuild with `sidecar` axios instance | ✅ Built and deployed |
|
||||
|
||||
### Verified working via Caddy
|
||||
|
||||
```bash
|
||||
# Through public URL with valid admin token
|
||||
curl https://photos.hubris.network/api/sidecar/timeline?count=1 → HTTP 200
|
||||
curl https://photos.hubris.network/api/v1/photos?count=1 → HTTP 200
|
||||
```
|
||||
|
||||
Both endpoints return 200 when tested directly through Caddy with a valid token.
|
||||
|
||||
### Reported issues
|
||||
|
||||
1. **404 on photo grid** — even in private window (no cache interference)
|
||||
2. **Folder tree shows other users' folders** on first load, fixed by refresh
|
||||
|
||||
## 3. Root Cause Analysis
|
||||
|
||||
### Issue 1: 404 on photo grid
|
||||
|
||||
The `sidecar` axios instance (`baseURL: ''`) is missing the **response interceptor** that:
|
||||
- Handles 401 → clears session → redirects to login
|
||||
- Re-throws with meaningful error message
|
||||
|
||||
The `http` instance (for `/api/v1` endpoints) has this interceptor. Without it on `sidecar`:
|
||||
- If the sidecar returns a non-2xx (401, 502 from upstream PP failure, etc.), axios throws a raw error
|
||||
- The TanStack Query error boundary catches it and shows "Request failed with status code <status>"
|
||||
- Very likely the sidecar is returning 401 on some calls (token expired / session not yet established) and the error message might show 404 because Caddy's catch-all returns 404 when a matcher doesn't find a route
|
||||
|
||||
**Hypothesis:** During OIDC login flow, the frontend may make some sidecar calls BEFORE the session is fully established (token loaded into `session.accessToken`). The `sidecar` interceptor checks `session.accessToken` but it might be null. Then the request to `/api/sidecar/timeline` has no auth header → sidecar returns 401 → no response interceptor → raw error.
|
||||
|
||||
**Fix:** Add the same 401 → login redirect interceptor to the `sidecar` instance.
|
||||
|
||||
### Issue 2: Folder tree shows other users
|
||||
|
||||
`listFolders()` calls `http.get('/folders/originals')` which hits PhotoPrism directly. PhotoPrism returns **all folders across the library** regardless of user. The frontend then filters by `userBasePath()` on the result:
|
||||
|
||||
```typescript
|
||||
const bp = userBasePath();
|
||||
if (bp === '') return folders; // On first load, bp might be empty!
|
||||
return folders.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||
```
|
||||
|
||||
On first load, `userBasePath()` returns `""` because:
|
||||
1. The session data is loaded asynchronously
|
||||
2. `session.user.BasePath` might not yet be populated when `listFolders` fires
|
||||
3. The TanStack Query cache from a previous session might still have old data
|
||||
|
||||
After a refresh, the session is fully loaded, and `userBasePath()` returns the correct value.
|
||||
|
||||
A secondary issue: the `http` interceptor's 401 handler clears the session on 401. If the session expires during the app's lifetime, all subsequent requests fail with 401.
|
||||
|
||||
## 4. Proposed Approach
|
||||
|
||||
### Phase 1: Fix 404 — add response interceptor to sidecar
|
||||
|
||||
**File:** `web/src/lib/services/photoprism.ts`
|
||||
|
||||
Add the same 401 → login redirect interceptor to `sidecar` as already exists on `http`:
|
||||
|
||||
```typescript
|
||||
sidecar.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401 && browser) {
|
||||
clearSession();
|
||||
const url = err.config?.url ?? '';
|
||||
if (!url.endsWith('/session')) {
|
||||
void goto('/login', { replaceState: true });
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Phase 2: Fix folder tree — sidecar folder proxy
|
||||
|
||||
**File:** `sidecar/handlers_folders.go` (new)
|
||||
|
||||
Add a sidecar endpoint that proxies `/folders/originals` and post-filters by BasePath:
|
||||
|
||||
```
|
||||
GET /api/sidecar/folders → proxies to GET /api/v1/folders/originals
|
||||
→ removes folders not under user's base_path
|
||||
→ returns filtered list
|
||||
```
|
||||
|
||||
This avoids the timing issue entirely by filtering on the server side.
|
||||
|
||||
**Alternative (simpler):** Fix the frontend timing issue by ensuring `listFolders` doesn't fire until the session is ready.
|
||||
|
||||
### Phase 3: Change folder tree in frontend
|
||||
|
||||
**File:** `web/src/lib/services/photoprism.ts`
|
||||
|
||||
Change `listFolders()` to use `sidecar` instance and call `/api/sidecar/folders`:
|
||||
|
||||
```typescript
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||
'/api/sidecar/folders',
|
||||
{ params: { recursive: true, uncached: true, files: false } }
|
||||
);
|
||||
const bp = userBasePath();
|
||||
const folders = data.folders ?? [];
|
||||
if (bp === '') return folders;
|
||||
return folders
|
||||
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||
.map((f) => ({ ...f, Path: toUserPath(f.Path) }));
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Step-by-step Plan
|
||||
|
||||
### Step 1: Add sidecar response interceptor
|
||||
|
||||
1. Edit `web/src/lib/services/photoprism.ts`
|
||||
2. Add the 401-handling response interceptor to the `sidecar` instance
|
||||
3. The interceptor mirrors the existing `http` response interceptor exactly
|
||||
|
||||
### Step 2: Rebuild frontend
|
||||
|
||||
```bash
|
||||
cd /opt/mule-image/web && npm run build
|
||||
```
|
||||
|
||||
### Step 3: (Optional) Add sidecar folder proxy
|
||||
|
||||
1. New file `sidecar/handlers_folders_proxy.go`
|
||||
2. Handler similar to `handlePhotos` — proxies to `/api/v1/folders/originals`, post-filters by `Path` prefix
|
||||
3. Wire route in `main.go`: `auth.GET("/folders", handleFoldersProxy(pp))`
|
||||
4. Build Docker image, restart sidecar
|
||||
|
||||
### Step 4: Update listFolders to use sidecar
|
||||
|
||||
1. Change `listFolders()` to use `sidecar` instance
|
||||
2. Call `/api/sidecar/folders` instead of `/folders/originals`
|
||||
|
||||
### Step 5: Rebuild + validate
|
||||
|
||||
```bash
|
||||
# Rebuild frontend
|
||||
cd /opt/mule-image/web && npm run build
|
||||
|
||||
# Test through Caddy
|
||||
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1" \
|
||||
-H "X-Auth-Token: <token>" | head -c 200
|
||||
|
||||
# Verify folders
|
||||
curl -s "https://photos.hubris.network/api/sidecar/folders" \
|
||||
-H "X-Auth-Token: <token>" | python3 -c "import sys,json;d=json.load(sys.stdin);print(json.dumps(d[:3],indent=2))"
|
||||
```
|
||||
|
||||
### Step 6: Commit
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix: add sidecar response interceptor + folder proxy" && git push
|
||||
```
|
||||
|
||||
## 6. Files Likely to Change
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `web/src/lib/services/photoprism.ts` | Add response interceptor to sidecar instance; change listFolders URL |
|
||||
| `sidecar/handlers_folders_proxy.go` | **New** — folder proxy handler |
|
||||
| `sidecar/main.go` | Wire folder proxy route |
|
||||
|
||||
## 7. Tests & Validation
|
||||
|
||||
**Manual:**
|
||||
1. Open private window → navigate to photos.hubris.network
|
||||
2. Log in as muli via Authentik OIDC
|
||||
3. Verify photo grid loads without 404
|
||||
4. Verify folder tree shows only muli's folders
|
||||
5. Switch to dtoro account → verify folders/timeline scoped to dtoro
|
||||
|
||||
**API tests:**
|
||||
```bash
|
||||
# Sidecar timeline (no token → 401 redirect)
|
||||
curl -s "https://photos.hubris.network/api/sidecar/timeline?count=1"
|
||||
|
||||
# Sidecar folders
|
||||
curl -s "https://photos.hubris.network/api/sidecar/folders"
|
||||
```
|
||||
|
||||
## 8. Risks & Open Questions
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Sidecar returns 401 during OIDC login flow before session is ready | 404 showing instead of graceful redirect | Add response interceptor in Phase 1 |
|
||||
| Folder proxy adds latency | Slower folder tree loading | Minimal — single proxy call, same as PP direct |
|
||||
| `userBasePath()` timing issue in listFolders persists even with sidecar | Folder tree still shows wrong folders on first load | Sidecar filter is server-side → no timing dependency |
|
||||
|
||||
### Open Questions
|
||||
|
||||
- **Q1**: Are there other API calls that bypass the `sidecar` instance and might also be unscoped? (e.g., `listSubjects`, `listGeo`, etc.)
|
||||
- **Q2**: Does the sidecar need a folder proxy, or is the timing fix sufficient? The timing fix (delaying `listFolders` until session is ready) is simpler but fragile.
|
||||
- **Q3**: Could the 404 be from Caddy's catch-all returning 404 when the sidecar isn't reachable? The Caddy fallback timeout for the sidecar might need tuning.
|
||||
@@ -42,7 +42,7 @@ services:
|
||||
# silently no-op on Debian/Ubuntu and macOS Docker Desktop.
|
||||
- ./mariadb/init:/docker-entrypoint-initdb.d:ro,Z
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
test: ["CMD", "/usr/bin/mariadb-admin", "ping", "-h", "127.0.0.1", "--silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// 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.
|
||||
// operation. The resolved username is available via ctxUserName.
|
||||
func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Auth-Token")
|
||||
@@ -19,11 +19,15 @@ func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
|
||||
return
|
||||
}
|
||||
if !pp.validateSession(c.Request.Context(), token) {
|
||||
user := pp.resolveSession(c.Request.Context(), token)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return
|
||||
}
|
||||
c.Set("token", token)
|
||||
c.Set("userName", user.UserName)
|
||||
c.Set("userUID", user.UserUID)
|
||||
c.Set("basePath", user.BasePath)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -42,3 +46,42 @@ func ctxToken(c *gin.Context) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxUserName returns the PhotoPrism username resolved by requireSession.
|
||||
func ctxUserName(c *gin.Context) string {
|
||||
v, ok := c.Get("userName")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxUserUID returns the PhotoPrism user UID resolved by requireSession.
|
||||
func ctxUserUID(c *gin.Context) string {
|
||||
v, ok := c.Get("userUID")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ctxBasePath returns the PhotoPrism user BasePath resolved by requireSession.
|
||||
func ctxBasePath(c *gin.Context) string {
|
||||
v, ok := c.Get("basePath")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// endpoints — rating + four-colour label. Composite primary key
|
||||
// (photo_uid, user_name) so each user has independent marks. Both payload
|
||||
// fields are nullable so the sparse "no rating / no colour" state
|
||||
// round-trips cleanly.
|
||||
type Mark struct {
|
||||
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
|
||||
UserName string `gorm:"primaryKey;size:128;column:user_name" 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"`
|
||||
|
||||
70
sidecar/handlers_folders_proxy.go
Normal file
70
sidecar/handlers_folders_proxy.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleFoldersProxy proxies PhotoPrism's /api/v1/folders/originals and
|
||||
// post-filters by the caller's BasePath so the folder tree only shows
|
||||
// folders under the user's library root.
|
||||
//
|
||||
// Route: GET /api/sidecar/folders (behind requireSession)
|
||||
func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
// Forward query params to PhotoPrism.
|
||||
query := c.Request.URL.RawQuery
|
||||
if query == "" {
|
||||
query = "recursive=true&uncached=true&files=false"
|
||||
}
|
||||
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/folders/originals?"+query, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream folders request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode the response — PhotoPrism returns { folders: [...] }.
|
||||
var payload struct {
|
||||
Folders []map[string]any `json:"folders"`
|
||||
}
|
||||
if err := json.Unmarshal(resp.Body, &payload); err != nil {
|
||||
c.Data(resp.Status, "application/json", resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
// If the user has no BasePath (admin/empty), return as-is.
|
||||
if basePath == "" {
|
||||
c.JSON(http.StatusOK, payload)
|
||||
return
|
||||
}
|
||||
|
||||
prefix := basePath + "/"
|
||||
|
||||
// Post-filter folders by Path field only — frontend handles BasePath
|
||||
// prefix stripping via toUserPath().
|
||||
filtered := make([]map[string]any, 0, len(payload.Folders))
|
||||
for _, f := range payload.Folders {
|
||||
rawPath, ok := f["Path"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pathStr, ok := rawPath.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Keep only folders under the user's base path.
|
||||
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
||||
}
|
||||
}
|
||||
171
sidecar/handlers_labels.go
Normal file
171
sidecar/handlers_labels.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PpLabel mirrors the shape PhotoPrism's /api/v1/labels endpoint returns.
|
||||
// We decode enough to filter + recalculate PhotoCount; fields the client
|
||||
// doesn't render are skipped for token efficiency.
|
||||
type PpLabel struct {
|
||||
UID string `json:"UID"`
|
||||
Name string `json:"Name"`
|
||||
Slug string `json:"Slug"`
|
||||
CustomSlug string `json:"CustomSlug"`
|
||||
Priority int `json:"Priority"`
|
||||
Favorite bool `json:"Favorite"`
|
||||
PhotoCount int `json:"PhotoCount"`
|
||||
Thumb string `json:"Thumb"`
|
||||
CreatedAt string `json:"CreatedAt"`
|
||||
UpdatedAt string `json:"UpdatedAt"`
|
||||
}
|
||||
|
||||
// handleLabels proxies PhotoPrism's /api/v1/labels and then post-filters
|
||||
// each label's PhotoCount (and removes labels with zero count) so they
|
||||
// reflect only photos under the caller's BasePath.
|
||||
//
|
||||
// Route: GET /api/sidecar/labels (behind requireSession)
|
||||
func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
// Forward the query string (count, offset, q, all, …) to PhotoPrism.
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
// Call PhotoPrism's labels endpoint using the caller's token.
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/labels?"+query, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream labels request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode labels.
|
||||
var labels []PpLabel
|
||||
if err := json.Unmarshal(resp.Body, &labels); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse labels"})
|
||||
return
|
||||
}
|
||||
|
||||
// If the user has no BasePath (admin/empty), return labels as-is.
|
||||
if basePath == "" || ppDb == nil {
|
||||
c.JSON(http.StatusOK, labels)
|
||||
return
|
||||
}
|
||||
|
||||
// One query: count + a representative scoped thumb for every label
|
||||
// the user can see. Replaces N per-label queries with a single JOIN.
|
||||
prefix := basePath + "/%"
|
||||
|
||||
type labelStat struct {
|
||||
LabelUID string `gorm:"column:label_uid"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
ThumbHash string `gorm:"column:thumb_hash"`
|
||||
}
|
||||
var stats []labelStat
|
||||
if err := ppDb.Raw(`
|
||||
SELECT lb.label_uid AS label_uid,
|
||||
COUNT(DISTINCT p.id) AS cnt,
|
||||
COALESCE(MIN(f.file_hash), '') AS thumb_hash
|
||||
FROM photos_labels pl
|
||||
JOIN photos p ON pl.photo_id = p.id
|
||||
JOIN labels lb ON pl.label_id = lb.id
|
||||
LEFT JOIN files f ON f.photo_uid = p.photo_uid
|
||||
AND f.file_primary = 1
|
||||
AND f.file_missing = 0
|
||||
WHERE (p.photo_path = ? OR p.photo_path LIKE ?)
|
||||
AND p.deleted_at IS NULL
|
||||
GROUP BY lb.label_uid
|
||||
HAVING cnt > 0
|
||||
`, basePath, prefix).Scan(&stats).Error; err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "label stats query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
cntMap := make(map[string]int64, len(stats))
|
||||
thumbMap := make(map[string]string, len(stats))
|
||||
for _, s := range stats {
|
||||
cntMap[s.LabelUID] = s.Cnt
|
||||
thumbMap[s.LabelUID] = s.ThumbHash
|
||||
}
|
||||
|
||||
filtered := make([]PpLabel, 0, len(stats))
|
||||
for _, l := range labels {
|
||||
cnt, ok := cntMap[l.UID]
|
||||
if !ok || cnt == 0 {
|
||||
continue
|
||||
}
|
||||
l.PhotoCount = int(cnt)
|
||||
if th := thumbMap[l.UID]; th != "" {
|
||||
l.Thumb = th
|
||||
}
|
||||
filtered = append(filtered, l)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// Now also handle the session/config count scoping.
|
||||
|
||||
// PpCounts mirrors PhotoPrism's session config.count block that drives
|
||||
// the sidebar badges (review, archive, all, etc.).
|
||||
type PpCounts struct {
|
||||
All int `json:"all"`
|
||||
Photos int `json:"photos"`
|
||||
Media int `json:"media"`
|
||||
Videos int `json:"videos"`
|
||||
Review int `json:"review"`
|
||||
Archived int `json:"archived"`
|
||||
Hidden int `json:"hidden"`
|
||||
Private int `json:"private"`
|
||||
Favorites int `json:"favorites"`
|
||||
}
|
||||
|
||||
// handleScopedCounts returns user-scoped counts for review/archive/all
|
||||
// so the sidebar badges match what the user actually sees.
|
||||
//
|
||||
// Route: GET /api/sidecar/counts (behind requireSession)
|
||||
func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
basePath := ctxBasePath(c)
|
||||
if basePath == "" || ppDb == nil {
|
||||
// Admin or no DB — can't scope, return empty.
|
||||
c.JSON(http.StatusOK, PpCounts{})
|
||||
return
|
||||
}
|
||||
|
||||
prefix := basePath + "/%"
|
||||
pathCond := "(p.photo_path = ? OR p.photo_path LIKE ?)"
|
||||
args := []any{basePath, prefix}
|
||||
|
||||
var counts PpCounts
|
||||
|
||||
// All non-deleted photos in this user's scope.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND `+pathCond, args...).Scan(&counts.All)
|
||||
|
||||
// Photos needing review (quality < 3).
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_quality < 3 AND `+pathCond, args...).Scan(&counts.Review)
|
||||
|
||||
// Archived (soft-deleted) photos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NOT NULL AND `+pathCond, args...).Scan(&counts.Archived)
|
||||
|
||||
// Private photos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_private = 1 AND `+pathCond, args...).Scan(&counts.Private)
|
||||
|
||||
// Photos (type image).
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('image','raw','live','animated') AND `+pathCond, args...).Scan(&counts.Photos)
|
||||
|
||||
// Videos.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_type IN ('video','hdr','burst','live') AND `+pathCond, args...).Scan(&counts.Videos)
|
||||
|
||||
// Favorites.
|
||||
ppDb.Raw(`SELECT COUNT(*) FROM photos p WHERE p.deleted_at IS NULL AND p.photo_favorite = 1 AND `+pathCond, args...).Scan(&counts.Favorites)
|
||||
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,18 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// validColors is the four-color palette mule-image always shipped. The
|
||||
// empty string is the explicit "clear color" sentinel.
|
||||
// validColors is the color palette the web client offers (COLOR_SWATCHES in
|
||||
// web/src/lib/utils/tagGroups.ts) — keep the two in sync. The empty string is
|
||||
// the explicit "clear color" sentinel.
|
||||
var validColors = map[string]struct{}{
|
||||
"red": {},
|
||||
"orange": {},
|
||||
"yellow": {},
|
||||
"green": {},
|
||||
"teal": {},
|
||||
"blue": {},
|
||||
"purple": {},
|
||||
"pink": {},
|
||||
}
|
||||
|
||||
// markPatch is the request body for all three mutating mark endpoints.
|
||||
@@ -70,12 +75,12 @@ func (p *markPatch) apply(m *Mark) bool {
|
||||
return m.Rating != nil || (m.Color != nil && *m.Color != "")
|
||||
}
|
||||
|
||||
// allMarksJSON renders the entire `marks` table as the wire shape
|
||||
// allMarksJSON renders the current user's marks 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) {
|
||||
func allMarksJSON(db *gorm.DB, userName string) (map[string]map[string]any, error) {
|
||||
var rows []Mark
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
if err := db.Where("user_name = ?", userName).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]map[string]any, len(rows))
|
||||
@@ -87,7 +92,7 @@ func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
|
||||
|
||||
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
marks, err := allMarksJSON(db)
|
||||
marks, err := allMarksJSON(db, ctxUserName(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -100,7 +105,7 @@ 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
|
||||
err := db.Where("photo_uid = ? AND user_name = ?", uid, ctxUserName(c)).First(&m).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{})
|
||||
return
|
||||
@@ -115,18 +120,18 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// 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) {
|
||||
func upsert(db *gorm.DB, uid, userName string, patch *markPatch) (map[string]any, error) {
|
||||
var m Mark
|
||||
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
||||
err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).First(&m).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
m.PhotoUID = uid
|
||||
m.UserName = userName
|
||||
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 {
|
||||
if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
@@ -149,7 +154,7 @@ func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out, err := upsert(db, uid, &patch)
|
||||
out, err := upsert(db, uid, ctxUserName(c), &patch)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -178,6 +183,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userName := ctxUserName(c)
|
||||
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.
|
||||
@@ -186,7 +192,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
out, err := upsert(tx, uid, &body.Patch)
|
||||
out, err := upsert(tx, uid, userName, &body.Patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
141
sidecar/handlers_photos.go
Normal file
141
sidecar/handlers_photos.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handlePhotos proxies PhotoPrism's /api/v1/photos and then post-filters
|
||||
// the response so only photos under the caller's BasePath are returned.
|
||||
// This fixes the review/archive tab cross-user leak.
|
||||
//
|
||||
// Route: GET /api/sidecar/timeline (behind requireSession)
|
||||
func handlePhotos(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
// Forward the raw query string to PhotoPrism.
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos?"+query, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decode as a generic array so we can inspect Path without
|
||||
// committing to a rigid struct (PhotoPrism's photo response
|
||||
// varies between list/detail/search endpoints).
|
||||
var photos []map[string]any
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
// If it's not an array (e.g. error, single object), pass through.
|
||||
c.Data(resp.Status, "application/json", resp.Body)
|
||||
return
|
||||
}
|
||||
|
||||
// If the user has no BasePath (admin/empty), return as-is.
|
||||
if basePath == "" {
|
||||
// Forward PhotoPrism's X-Count header for countPhotos().
|
||||
if count := resp.Header.Get("X-Count"); count != "" {
|
||||
c.Header("X-Count", count)
|
||||
}
|
||||
c.JSON(http.StatusOK, photos)
|
||||
return
|
||||
}
|
||||
|
||||
prefix := basePath + "/"
|
||||
|
||||
// Post-filter by FileName field (originals-relative path).
|
||||
filtered := make([]map[string]any, 0, len(photos))
|
||||
for _, ph := range photos {
|
||||
rawPath, ok := ph["FileName"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pathStr, ok := rawPath.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Match exact basePath or basePath/...
|
||||
if pathStr == basePath || strings.HasPrefix(pathStr, prefix) {
|
||||
filtered = append(filtered, ph)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward X-Count header adjusted to the filtered count.
|
||||
c.Header("X-Count", itoa(len(filtered)))
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// handleNotes pages PhotoPrism's photo list to completion and returns only
|
||||
// photos carrying a non-empty Caption (mule-image's "Note"), scoped to the
|
||||
// caller's BasePath. Paging server-side is what makes this correct: the
|
||||
// client can't tell when the *BasePath-filtered* list is exhausted (a full
|
||||
// upstream page can filter down to a short — or empty — slice), but here we
|
||||
// can key the loop off the raw upstream page length.
|
||||
//
|
||||
// Route: GET /api/sidecar/notes (behind requireSession)
|
||||
func handleNotes(pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
basePath := ctxBasePath(c)
|
||||
prefix := basePath + "/"
|
||||
|
||||
const pageSize = 1000
|
||||
out := make([]map[string]any, 0, 64)
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
for offset := 0; ; offset += pageSize {
|
||||
path := fmt.Sprintf("/api/v1/photos?count=%d&offset=%d&merged=true&order=newest", pageSize, offset)
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, path, token, nil)
|
||||
if err != nil || !resp.OK {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream photos request failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var photos []map[string]any
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected photos response"})
|
||||
return
|
||||
}
|
||||
rawLen := len(photos)
|
||||
|
||||
for _, ph := range photos {
|
||||
// BasePath scope — same rule as handlePhotos.
|
||||
if basePath != "" {
|
||||
pathStr, _ := ph["FileName"].(string)
|
||||
if pathStr != basePath && !strings.HasPrefix(pathStr, prefix) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Non-empty caption only.
|
||||
caption, _ := ph["Caption"].(string)
|
||||
if strings.TrimSpace(caption) == "" {
|
||||
continue
|
||||
}
|
||||
// Dedupe by UID — `merged` can still repeat a photo at a page seam.
|
||||
uid, _ := ph["UID"].(string)
|
||||
if uid != "" {
|
||||
if _, ok := seen[uid]; ok {
|
||||
continue
|
||||
}
|
||||
seen[uid] = struct{}{}
|
||||
}
|
||||
out = append(out, ph)
|
||||
}
|
||||
|
||||
// A short upstream page means PhotoPrism has no more rows.
|
||||
if rawLen < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -46,6 +47,18 @@ func main() {
|
||||
// BasePath wired without an admin restart.
|
||||
startUserBasepathReconciler(cfg)
|
||||
|
||||
// Open a second DB handle pointed at PhotoPrism's own schema for
|
||||
// handlers that need to query auth_users, photos, labels, etc.
|
||||
// May be nil if PpDSN is empty (no PP_DB_PASSWORD set).
|
||||
var ppDb *gorm.DB
|
||||
if cfg.PpDSN != "" {
|
||||
if d, err := openDB(cfg.PpDSN); err == nil {
|
||||
ppDb = d
|
||||
} else {
|
||||
slog.Warn("pp db open failed — scoped labels/counts unavailable", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
// Keep `%2F` literal in path params so callers can pass URL-encoded
|
||||
@@ -66,25 +79,43 @@ func main() {
|
||||
|
||||
// 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 := 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("/files/:uid/rename", handleRename(cfg, pp))
|
||||
|
||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||
auth.POST("/folders/counts", handleFolderCounts(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.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
}
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
|
||||
// User-scoped proxies — require PpDSN connection.
|
||||
if ppDb != nil {
|
||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||
}
|
||||
|
||||
// User-scoped photos — post-filters by BasePath so review/archive
|
||||
// tabs only show photos the user owns.
|
||||
auth.GET("/timeline", handlePhotos(pp))
|
||||
|
||||
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
||||
// the /notes view isn't capped to the newest slice.
|
||||
auth.GET("/notes", handleNotes(pp))
|
||||
|
||||
// User-scoped folders — post-filters the folder tree by BasePath
|
||||
// so the sidebar shows only folders under the user's library root.
|
||||
auth.GET("/folders", handleFoldersProxy(pp))
|
||||
}
|
||||
|
||||
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||
srv := &http.Server{
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -85,6 +86,44 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ppSessionUser is the subset of PhotoPrism's session response we need.
|
||||
type ppSessionUser struct {
|
||||
UserUID string `json:"UID"`
|
||||
UserName string `json:"Name"`
|
||||
BasePath string `json:"BasePath"`
|
||||
}
|
||||
|
||||
type ppSessionResponse struct {
|
||||
User ppSessionUser `json:"user"`
|
||||
}
|
||||
|
||||
// resolveSession validates the token AND returns the authenticated user.
|
||||
// Returns nil when the token is invalid or the response can't be parsed.
|
||||
func (c *ppClient) resolveSession(ctx context.Context, token string) *ppSessionUser {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
r, err := c.call(ctx, http.MethodGet, "/api/v1/session", token, nil)
|
||||
if err != nil {
|
||||
slog.Warn("resolveSession: call failed", "err", err)
|
||||
return nil
|
||||
}
|
||||
if !r.OK {
|
||||
slog.Warn("resolveSession: not OK", "status", r.Status, "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
var resp ppSessionResponse
|
||||
if err := json.Unmarshal(r.Body, &resp); err != nil {
|
||||
slog.Warn("resolveSession: unmarshal failed", "err", err, "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
if resp.User.UserName == "" {
|
||||
slog.Warn("resolveSession: empty username", "body", string(r.Body[:min(len(r.Body), 200)]))
|
||||
return nil
|
||||
}
|
||||
return &resp.User
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { startBulk, doneBulk, failBulk, setDetail } from '$lib/stores/bulkAction.svelte';
|
||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
||||
|
||||
/**
|
||||
@@ -180,34 +181,25 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
target = !(first?.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.
|
||||
const opLabel = target ? 'Archiving' : 'Restoring';
|
||||
const doneLabel = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||
const tid = toast.loading(`${opLabel} ${ids.length}…`);
|
||||
startBulk(`${opLabel}…`, ids);
|
||||
try {
|
||||
if (target) await batchArchive(ids);
|
||||
else await batchRestore(ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid });
|
||||
return;
|
||||
}
|
||||
// Move focus forward before the photos query refetches, so the
|
||||
// user can keep X-ing through the timeline without their cursor
|
||||
// snapping back to photo[0]. Walks past every uid we just
|
||||
// archived/restored — relevant when the cull targets came from a
|
||||
// multi-selection rather than the single focused tile.
|
||||
doneBulk(doneLabel, ids);
|
||||
focusAfter(ids);
|
||||
// Drop the now-stale selection set. The archived UIDs are about
|
||||
// to leave the timeline on refetch, but the SvelteSet membership
|
||||
// keeps the selection ring on them until then — confusing for
|
||||
// the user and a footgun if they Ctrl-click to add more and end
|
||||
// up re-archiving the same photos. The BulkActionBar button path
|
||||
// clears for the same reason; mirror it here.
|
||||
clearSelection();
|
||||
invalidatePhotos(ids);
|
||||
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
|
||||
toast.success(label);
|
||||
pushUndo(label, async () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
toast.success(doneLabel, { id: tid });
|
||||
pushUndo(doneLabel, async () => {
|
||||
if (target) await batchRestore(ids);
|
||||
else await batchArchive(ids);
|
||||
invalidatePhotos(ids);
|
||||
@@ -232,16 +224,21 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
? 'Permanently delete this photo? This cannot be undone.'
|
||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||
if (!confirm(msg)) return;
|
||||
const tid = toast.loading(`Deleting ${ids.length}…`);
|
||||
startBulk('Deleting…', ids);
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
||||
return;
|
||||
}
|
||||
doneBulk(`Deleted ${ids.length}`, ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
invalidatePhotos(ids);
|
||||
toast.success(`Deleted ${ids.length}`);
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
/** Approve cull targets — clears them out of the review pile by
|
||||
@@ -257,21 +254,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id));
|
||||
// Approve moves photos out of the review pile, so the same
|
||||
// stale-selection trap as archive/delete applies — advance focus
|
||||
// past the approved set and drop the now-irrelevant selection
|
||||
// before invalidate refetches the (smaller) view.
|
||||
const tid = toast.loading(`Keeping ${ids.length}…`);
|
||||
startBulk('Keeping…', ids);
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
|
||||
onProgress: (_done, _total, completedId) => {
|
||||
const p = cachedPhoto(completedId);
|
||||
if (p) setDetail(p.FileName ?? completedId);
|
||||
}
|
||||
});
|
||||
if (errors.length) {
|
||||
failBulk(ids);
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||
id: tid,
|
||||
description: errors[0].message
|
||||
});
|
||||
} else {
|
||||
doneBulk(`Kept ${ids.length}`, ids);
|
||||
toast.success(`Kept ${ids.length}`, { id: tid });
|
||||
}
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
invalidatePhotos(ids);
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
}
|
||||
|
||||
// ── S chord (add-to-heap) ────────────────────────────────────────────
|
||||
@@ -297,25 +300,28 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`);
|
||||
startBulk(`Adding to ${heap.Title}…`, ids);
|
||||
try {
|
||||
const { added } = await addToHeap(heap.UID, ids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
// PhotoPrism returns 200 even when nothing was added — distinguish
|
||||
// "really added N" from "skipped all N" so the toast tells the
|
||||
// truth.
|
||||
if (added.length === 0) {
|
||||
failBulk(ids);
|
||||
toast.error(`Nothing added to ${heap.Title}`, {
|
||||
id: tid,
|
||||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
doneBulk(`Added ${added.length} → ${heap.Title}`, ids);
|
||||
if (added.length < ids.length) {
|
||||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||
id: tid,
|
||||
description: 'The rest were already in this heap.'
|
||||
});
|
||||
} else {
|
||||
toast.success(`Added ${added.length} → ${heap.Title}`);
|
||||
toast.success(`Added ${added.length} → ${heap.Title}`, { id: tid });
|
||||
}
|
||||
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||
await removeFromHeap(heap.UID, added);
|
||||
@@ -323,7 +329,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,8 @@
|
||||
<!--
|
||||
Compact status pill that appears in the header while PhotoPrism's
|
||||
indexer is doing work. Driven by the indexer store, which subscribes
|
||||
to PhotoPrism's WS channel. Renders nothing when idle so it never
|
||||
steals header real estate from the user.
|
||||
|
||||
The `detail` (current path/file) is exposed via `title` rather than
|
||||
rendered inline — the pill stays narrow even on slow flashes through
|
||||
a deep library, and hover surfaces the detail for users who care.
|
||||
-->
|
||||
<!-- PhotoPrism indexer status pill. Driven by the indexer store, which
|
||||
subscribes to PhotoPrism's WS channel. Delegates rendering to StatusPill. -->
|
||||
<script lang="ts">
|
||||
import { indexer } from '$lib/stores/indexer.svelte';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
// PhotoPrism's `fileName` arrives as the full relative path
|
||||
// (`subdir/IMG_0554.HEIC.jpg`). The basename is enough for inline
|
||||
// recognition; the full path stays in the `title` for users who hover.
|
||||
const basename = $derived.by(() => {
|
||||
const d = indexer.detail;
|
||||
if (!d) return '';
|
||||
const i = d.lastIndexOf('/');
|
||||
return i >= 0 ? d.slice(i + 1) : d;
|
||||
});
|
||||
import StatusPill from './StatusPill.svelte';
|
||||
</script>
|
||||
|
||||
{#if indexer.active || indexer.label}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
|
||||
title={indexer.detail ?? indexer.label}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if indexer.active}
|
||||
<Loader2 class="h-3 w-3 animate-spin text-primary" />
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{indexer.label}</span>
|
||||
{#if basename}
|
||||
<!-- Fixed-width slot so the pill stops shrinking/growing as
|
||||
PhotoPrism rattles through files of different name lengths.
|
||||
`w-[24ch]` locks the column; `truncate` ellipsises anything
|
||||
longer. The full path remains in the parent's `title`. -->
|
||||
<span
|
||||
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{basename}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<StatusPill active={indexer.active} label={indexer.label} detail={indexer.detail} />
|
||||
|
||||
@@ -7,27 +7,21 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
aggregateKeywords,
|
||||
countPhotos,
|
||||
createFolder,
|
||||
createHeap,
|
||||
deleteFolder,
|
||||
deleteHeap,
|
||||
duplicateHeap,
|
||||
getConfig,
|
||||
heapDownloadUrl,
|
||||
listFolderCounts,
|
||||
listFolders,
|
||||
listHeaps,
|
||||
listPhotosWithNotes,
|
||||
logout,
|
||||
renameFolder,
|
||||
renameHeap,
|
||||
scanCrossFolderDuplicates,
|
||||
triggerDownload,
|
||||
type CrossFolderScanResult,
|
||||
type PhotoWithNote,
|
||||
type PpAlbum,
|
||||
type PpClientConfig,
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import {
|
||||
@@ -83,87 +77,14 @@
|
||||
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||
queryKey: ['folders'],
|
||||
queryFn: listFolders,
|
||||
enabled: isAuthenticated()
|
||||
enabled: isAuthenticated(),
|
||||
gcTime: 0
|
||||
}));
|
||||
|
||||
// View counts come from PhotoPrism's `/config` response, which carries a
|
||||
// precomputed counter for every common bucket (all/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 (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()
|
||||
}));
|
||||
|
||||
// PhotoPrism's /api/v1/config.count returns library-wide aggregates
|
||||
// to any authenticated session regardless of role — the timeline
|
||||
// itself IS scoped per-user, but the precomputed counters aren't.
|
||||
// `isAdminUser` controls the cheap path: an admin without a
|
||||
// BasePath gets the precomputed totals from /config directly. Every
|
||||
// other case (non-admin, or admin scoped to a subfolder) goes
|
||||
// through `countPhotos()` which appends a `path:<base>*` filter so
|
||||
// the badge matches what the user can actually see.
|
||||
const isAdminUser = $derived(session.user?.Role === 'admin');
|
||||
const wantScoped = $derived(!isAdminUser || userBasePath() !== '');
|
||||
|
||||
// Builds a DSL clause that mirrors PhotoPrism's ACL scoping. An
|
||||
// admin with `BasePath === ""` gets a no-op clause and the global
|
||||
// query; everyone else gets a `path:` clause anchored to their
|
||||
// BasePath so unrelated folders never contribute to the badge.
|
||||
// Non-admins with no BasePath have nothing they can see, so we
|
||||
// short-circuit to a query that returns zero (`uid:none`).
|
||||
function scoped(filter: string): string {
|
||||
const bp = userBasePath();
|
||||
if (isAdminUser && bp === '') return filter;
|
||||
if (!isAdminUser && bp === '') return 'uid:none';
|
||||
return `${filter} path:"${bp}*"`.trim();
|
||||
}
|
||||
|
||||
function scopedCountQuery(key: string, filter: string) {
|
||||
return createQuery<number>(() => ({
|
||||
queryKey: ['photos', 'scoped-count', key, userBasePath(), isAdminUser],
|
||||
queryFn: () => countPhotos(scoped(filter)),
|
||||
enabled: isAuthenticated() && wantScoped,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
}
|
||||
|
||||
// One query per badge. Admins with no BasePath skip this
|
||||
// (enabled:false via `wantScoped`) and the configQuery numbers are
|
||||
// used directly — same chrome as before that fix, no extra
|
||||
// round-trip. Review and Hidden have no aggregate badge (pure
|
||||
// toggles in the sidebar now, like Tags), so they don't appear here.
|
||||
const archivedCountQuery = scopedCountQuery('archived', 'archived:true');
|
||||
|
||||
function bucketCount(
|
||||
key: 'archived',
|
||||
query: { data: number | undefined; isPending: boolean }
|
||||
): number | undefined {
|
||||
if (wantScoped) {
|
||||
if (query.isPending) return undefined;
|
||||
return query.data;
|
||||
}
|
||||
// Admin + no BasePath: use the precomputed PhotoPrism counters
|
||||
// (no extra round-trip).
|
||||
const c = configQuery.data?.count;
|
||||
if (!c) return undefined;
|
||||
return c[key];
|
||||
}
|
||||
|
||||
// Duplicates counts for the sidebar badge. Stacks is a cheap
|
||||
// PhotoPrism query so we always fetch it; cross-folder is an
|
||||
// O(disk) scan, so the sidebar only *observes* its cache
|
||||
// (enabled:false) and the duplicates page itself is what populates
|
||||
// it on first visit. Both share queryKeys with the /duplicates
|
||||
// view so cache is reused.
|
||||
// Stacks + cross-folder duplicate caches are warmed here so the
|
||||
// /duplicates view (and its review tab strip) hits a warm cache. The
|
||||
// sidebar only observes these — cross-folder is an O(disk) scan, so it
|
||||
// stays enabled:false and the duplicates page populates it on first visit.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
@@ -177,95 +98,12 @@
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
// Notes-view badge. Cheap (one list round-trip, no fan-out) so we
|
||||
// fetch eagerly — sharing the queryKey with /notes means the page hits
|
||||
// the warm cache, and the ['photos', …] prefix lets existing mutation
|
||||
// invalidations keep both in sync.
|
||||
const notesQuery = createQuery<PhotoWithNote[]>(() => ({
|
||||
queryKey: ['photos', 'with-notes'],
|
||||
queryFn: listPhotosWithNotes,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
|
||||
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 the sidecar /folders/counts
|
||||
// endpoint resolves them in one round-trip (see listFolderCounts).
|
||||
// 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.
|
||||
//
|
||||
// `countsReady` gates the query until just after the sidebar's first
|
||||
// paint. Even though the sidecar response is small, the per-folder
|
||||
// fan-out it does to PhotoPrism still takes a few hundred ms cold;
|
||||
// blocking it on idle means the folder list paints immediately and
|
||||
// the count badges fade in instead of holding back the whole tree.
|
||||
const folderPaths = $derived((foldersQuery.data ?? []).map((f) => f.Path));
|
||||
let countsReady = $state(false);
|
||||
if (browser) {
|
||||
const kick = () => (countsReady = true);
|
||||
// requestIdleCallback isn't in Safari yet; fall back to a short
|
||||
// timeout so the deferral is still bounded.
|
||||
const ric = (window as Window & { requestIdleCallback?: (cb: () => void) => number })
|
||||
.requestIdleCallback;
|
||||
if (typeof ric === 'function') ric(kick);
|
||||
else setTimeout(kick, 200);
|
||||
}
|
||||
const folderCountsQuery = createQuery<Record<string, number>>(() => ({
|
||||
queryKey: ['photos', 'folder-counts', [...folderPaths].sort()],
|
||||
queryFn: () => listFolderCounts(folderPaths),
|
||||
enabled: isAuthenticated() && folderPaths.length > 0 && countsReady,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const folderCounts = $derived(folderCountsQuery.data ?? {});
|
||||
|
||||
// Root entry shows "the user's library" using the same filter the
|
||||
// timeline applies at folderPath=='/' — empty q, which PhotoPrism
|
||||
// resolves to the visible listing (no archived / hidden / review).
|
||||
// Earlier this used /config's `count.all`, but that aggregate
|
||||
// includes those buckets and didn't match what the user can actually
|
||||
// click "select all" on; the discrepancy was confusing
|
||||
// (LeftSidebar said 357, the action bar said ~329).
|
||||
//
|
||||
// `scopedRootCountQuery` retains the sidecar fan-out for users with
|
||||
// a BasePath — `listFolderCounts(['''])` resolves `''` through
|
||||
// `toOriginalsPath` to the user's BasePath and recurses, so it picks
|
||||
// up the same subset PhotoPrism would. Empty BasePath admins use the
|
||||
// PhotoPrism count-via-X-Count path so both surfaces agree.
|
||||
const scopedRootCountQuery = createQuery<Record<string, number>>(() => ({
|
||||
queryKey: ['photos', 'root-count', userBasePath()],
|
||||
queryFn: () => listFolderCounts(['']),
|
||||
enabled: isAuthenticated() && userBasePath() !== '',
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const visibleRootCountQuery = createQuery<number>(() => ({
|
||||
queryKey: ['photos', 'visible-root-count', userBasePath()],
|
||||
// `merged: true` so the count matches the timeline's photo entries
|
||||
// (one per logical photo) rather than its file-row total. Without
|
||||
// it, sidecar/companion files inflate the badge — e.g. a HEIC + JPG
|
||||
// pair counts twice — and "select all" in the timeline never
|
||||
// reaches the badge's number.
|
||||
queryFn: () => countPhotos(scoped(''), { merged: true }),
|
||||
enabled: isAuthenticated() && userBasePath() === '' && isAdminUser,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
const rootCount = $derived(
|
||||
userBasePath() === ''
|
||||
? isAdminUser
|
||||
? (visibleRootCountQuery.data ?? 0)
|
||||
: 0
|
||||
: (scopedRootCountQuery.data?.[''] ?? 0)
|
||||
);
|
||||
|
||||
// Archive nav entry uses this derived value rather than peeking at
|
||||
// configQuery directly so the scoped path is invisible to the
|
||||
// manageViews[] declarations.
|
||||
const archivedBadge = $derived(bucketCount('archived', archivedCountQuery));
|
||||
// Gates admin-only entry points lower in the sidebar.
|
||||
const isAdminUser = $derived(session.user?.Role === 'admin');
|
||||
|
||||
const createMut = createMutation(() => ({
|
||||
mutationFn: (title: string) => createHeap(title),
|
||||
@@ -575,7 +413,7 @@
|
||||
// tab subitems). This list carries the flat Manage entries that
|
||||
// follow it.
|
||||
const manageViews: ViewItem[] = [
|
||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => archivedBadge }
|
||||
{ kind: 'section', id: 'archive', label: 'Archive', getCount: () => undefined }
|
||||
];
|
||||
|
||||
function isRouteActive(href: string): boolean {
|
||||
@@ -696,11 +534,6 @@
|
||||
peers, so labels share a common left edge across the sidebar. -->
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<!--
|
||||
Count badge lives INSIDE the button so the entire row (label
|
||||
+ badge) is one hit target — the badge is the most visually
|
||||
prominent element on the row and was previously a dead zone.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||
@@ -708,15 +541,6 @@
|
||||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||
>
|
||||
<span class="truncate">{rootLabel}</span>
|
||||
{#if configQuery.data}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center 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}
|
||||
</button>
|
||||
<!-- Root-row kebab. Only "New subfolder" applies — root itself
|
||||
can't be renamed or deleted, so those entries are omitted
|
||||
@@ -736,6 +560,8 @@
|
||||
</div>
|
||||
{#if foldersQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading folders…" />
|
||||
{:else if foldersQuery.isError}
|
||||
<EmptyState size="compact" tone="destructive" icon={FolderOpen} title="Failed to load folders" description="Try reloading the page." />
|
||||
{:else if !hasSubfolders}
|
||||
<EmptyState size="compact" icon={FolderOpen} title="No subfolders" />
|
||||
{:else if rootExpanded}
|
||||
@@ -752,7 +578,6 @@
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
counts={folderCounts}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -799,16 +624,9 @@
|
||||
class="flex min-w-0 flex-1 items-center pl-6 text-left"
|
||||
onclick={() => navigateTo('heap', heap.UID)}
|
||||
ondblclick={() => onRenameHeap(heap)}
|
||||
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
|
||||
title={heap.Title}
|
||||
>
|
||||
<span class="truncate">{heap.Title}</span>
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] 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="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
|
||||
<KebabMenu label="Heap actions">
|
||||
@@ -895,11 +713,9 @@
|
||||
Notes lives alongside the tag categories — same indent and row
|
||||
chrome — but routes to /notes rather than /tags/*. Tucked at
|
||||
the top of the expandable so it's the first thing the user
|
||||
sees when opening Tags. Count badge renders once the shared
|
||||
['photos', 'with-notes'] query has resolved.
|
||||
sees when opening Tags.
|
||||
-->
|
||||
{@const notesActive = isNotesActive()}
|
||||
{@const notesCount = notesQuery.data?.length}
|
||||
<a
|
||||
href="/notes"
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
@@ -909,15 +725,6 @@
|
||||
style="padding-left: 36px;"
|
||||
>
|
||||
<span class="truncate">Notes</span>
|
||||
{#if notesCount !== undefined}
|
||||
<span
|
||||
class="ml-auto flex h-4 min-w-[24px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {notesActive
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{notesCount}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{#each TAG_CATEGORIES as cat (cat)}
|
||||
{@const active = isTagCategoryActive(cat)}
|
||||
|
||||
46
web/src/lib/components/layout/StatusPill.svelte
Normal file
46
web/src/lib/components/layout/StatusPill.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<!--
|
||||
Generic status pill used in the header for both the PhotoPrism indexer
|
||||
and bulk-action progress. Renders nothing when idle so it never steals
|
||||
header real estate.
|
||||
|
||||
`detail` is a full path or filename; only the basename is shown inline
|
||||
(fixed-width slot to stop the pill from resizing on every file). The
|
||||
full string is exposed via `title` for hover.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
let { active, label, detail }: Props = $props();
|
||||
|
||||
const basename = $derived.by(() => {
|
||||
if (!detail) return '';
|
||||
const i = detail.lastIndexOf('/');
|
||||
return i >= 0 ? detail.slice(i + 1) : detail;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if active || label}
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-full border border-border bg-background/80 px-2.5 py-1 text-xs text-foreground shadow-sm backdrop-blur"
|
||||
title={detail ?? label}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if active}
|
||||
<Loader2 class="h-3 w-3 animate-spin text-primary" />
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{label}</span>
|
||||
{#if basename}
|
||||
<span
|
||||
class="w-[24ch] truncate text-left font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{basename}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -13,6 +13,7 @@
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { batchArchive } from '$lib/services/photoprism';
|
||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
import { type ReviewGroup } from '$lib/services/adapters/review';
|
||||
|
||||
@@ -28,14 +29,20 @@
|
||||
if (busy || group.photos.length === 0) return;
|
||||
if (!confirm(`Archive all ${group.photos.length} photos in "${group.meta.title}"?`))
|
||||
return;
|
||||
const uids = group.photos.map((p) => p.UID);
|
||||
const tid = toast.loading(`Archiving ${uids.length}…`);
|
||||
busy = true;
|
||||
startBulk(`Archiving…`, uids);
|
||||
try {
|
||||
await batchArchive(group.photos.map((p) => p.UID));
|
||||
toast.success(`Archived ${group.photos.length}`);
|
||||
await batchArchive(uids);
|
||||
doneBulk(`Archived ${uids.length}`, uids);
|
||||
toast.success(`Archived ${uids.length}`, { id: tid });
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive all failed');
|
||||
failBulk(uids);
|
||||
toast.error(err instanceof Error ? err.message : 'Archive all failed', { id: tid });
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { patchTargets } from '$lib/services/bulk';
|
||||
import { patchTargets, invalidateFacets } from '$lib/services/bulk';
|
||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
|
||||
const qc = useQueryClient();
|
||||
@@ -35,10 +36,19 @@
|
||||
let colorDraft = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// `label` drives the per-photo tile overlay (pending → done / error) via the
|
||||
// shared bulkAction store, so metadata applies show the same progress state
|
||||
// as the archive/keep actions in BulkActionBar.
|
||||
async function withBusy<T>(fn: () => Promise<T>, label?: string): Promise<T> {
|
||||
busy = true;
|
||||
if (label) startBulk(`${label}…`, ids);
|
||||
try {
|
||||
return await fn();
|
||||
const result = await fn();
|
||||
if (label) doneBulk(label, ids);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (label) failBulk(ids);
|
||||
throw e;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -47,13 +57,16 @@
|
||||
async function applyNote() {
|
||||
if (busy) return;
|
||||
const value = noteDraft;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
{ Caption: value, CaptionSrc: 'manual' },
|
||||
value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`,
|
||||
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
|
||||
)
|
||||
const label = value ? `Note → ${ids.length}` : `Cleared note on ${ids.length}`;
|
||||
await withBusy(
|
||||
() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
{ Caption: value, CaptionSrc: 'manual' },
|
||||
label,
|
||||
(p) => ({ Caption: p.Caption ?? '', CaptionSrc: 'manual' })
|
||||
),
|
||||
label
|
||||
);
|
||||
noteDraft = '';
|
||||
}
|
||||
@@ -64,27 +77,28 @@
|
||||
// Date-only input — stamp midnight UTC and let PhotoPrism's backwrite
|
||||
// fill the local timezone field downstream.
|
||||
const iso = `${dateDraft}T00:00:00Z`;
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
buildTakenAtPatch(iso),
|
||||
`Date → ${ids.length}`,
|
||||
(p) =>
|
||||
p.TakenAt
|
||||
? buildTakenAtPatch(p.TakenAt)
|
||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||
)
|
||||
const label = `Date → ${ids.length}`;
|
||||
await withBusy(
|
||||
() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
buildTakenAtPatch(iso),
|
||||
label,
|
||||
(p) =>
|
||||
p.TakenAt
|
||||
? buildTakenAtPatch(p.TakenAt)
|
||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||
),
|
||||
label
|
||||
);
|
||||
dateDraft = '';
|
||||
}
|
||||
|
||||
async function applyMarks(patch: PhotoMark, label: string) {
|
||||
if (busy) return;
|
||||
const tid = toast.loading(`${label}…`);
|
||||
startBulk(`${label}…`, ids);
|
||||
await withBusy(async () => {
|
||||
// Optimistic: patch every selected photo's mark in the local
|
||||
// cache before round-tripping. Sidecar bulk endpoint is
|
||||
// authoritative; on failure we just invalidate so the next
|
||||
// list query overrides.
|
||||
qc.setQueryData<PhotoMarksMap>(['marks'], (prev) => {
|
||||
const map = { ...(prev ?? {}) };
|
||||
for (const id of ids) {
|
||||
@@ -98,9 +112,14 @@
|
||||
});
|
||||
try {
|
||||
await bulkSetMarks(ids, patch);
|
||||
toast.success(`${label} · ${ids.length}`);
|
||||
doneBulk(label, ids);
|
||||
// Refresh the Colors / Ratings facet panels — they sit on
|
||||
// `['marks']` + `['photos','marks-pool']`, not the optimistic write above.
|
||||
invalidateFacets();
|
||||
toast.success(`${label} · ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed', { id: tid });
|
||||
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||
}
|
||||
});
|
||||
@@ -125,23 +144,26 @@
|
||||
const kw = keywordDraft.trim().replace(/,/g, '');
|
||||
if (!kw) return;
|
||||
keywordDraft = '';
|
||||
await withBusy(() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
(p) => {
|
||||
const cur = (p.Details?.Keywords ?? '')
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
if (cur.includes(kw)) return {};
|
||||
const next = [...cur, kw].join(', ');
|
||||
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
|
||||
},
|
||||
`Tagged "${kw}" → ${ids.length}`,
|
||||
(p) => ({
|
||||
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
|
||||
})
|
||||
)
|
||||
const label = `Tagged "${kw}" → ${ids.length}`;
|
||||
await withBusy(
|
||||
() =>
|
||||
patchTargets(
|
||||
ids,
|
||||
(p) => {
|
||||
const cur = (p.Details?.Keywords ?? '')
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
if (cur.includes(kw)) return {};
|
||||
const next = [...cur, kw].join(', ');
|
||||
return { Details: { Keywords: next, KeywordsSrc: 'manual' } };
|
||||
},
|
||||
label,
|
||||
(p) => ({
|
||||
Details: { Keywords: p.Details?.Keywords ?? '', KeywordsSrc: 'manual' }
|
||||
})
|
||||
),
|
||||
label
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
type PhotoMarksMap,
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { invalidateFacets } from '$lib/services/bulk';
|
||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
@@ -91,12 +93,20 @@
|
||||
const fresh = qc.getQueryData<PpPhoto>(['photo', photo.UID]) ?? photo;
|
||||
return updatePhoto(fresh, patch);
|
||||
},
|
||||
onMutate: () => {
|
||||
startBulk('Saving…', [photo.UID]);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(['photo', data.UID], data);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
// Keep the keyword / notes facet panels in sync with the edit.
|
||||
invalidateFacets();
|
||||
doneBulk('Saved', [photo.UID]);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed')
|
||||
onError: (err) => {
|
||||
failBulk([photo.UID]);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
}
|
||||
}));
|
||||
|
||||
function commit(patch: UpdatePhotoBody) {
|
||||
@@ -234,12 +244,17 @@
|
||||
if (!optimistic.rating) delete optimistic.rating;
|
||||
if (!optimistic.color) delete optimistic.color;
|
||||
patchMarksCache(photo.UID, optimistic);
|
||||
startBulk('Saving…', [photo.UID]);
|
||||
try {
|
||||
const saved = await setMark(photo.UID, patch);
|
||||
patchMarksCache(photo.UID, saved);
|
||||
// Refresh the Colors / Ratings facet panels off the sidecar truth.
|
||||
invalidateFacets();
|
||||
doneBulk('Saved', [photo.UID]);
|
||||
} catch (err) {
|
||||
// Rollback on failure.
|
||||
patchMarksCache(photo.UID, prev);
|
||||
failBulk([photo.UID]);
|
||||
toast.error(err instanceof Error ? err.message : 'Save failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
aggregateKeywords,
|
||||
getAllMarks,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
listPhotosByUids,
|
||||
listSubjects,
|
||||
type AggregatedKeyword,
|
||||
type PhotoMarksMap,
|
||||
@@ -72,12 +72,17 @@
|
||||
}));
|
||||
|
||||
// Same marks-pool query the drill page uses — colors/ratings need a
|
||||
// representative photo per bucket for the count rollup. Cheap once
|
||||
// cached; the drill page kicks the same key.
|
||||
// representative photo per bucket for the count rollup. Resolved from the
|
||||
// marked UIDs (complete set, any age) so the rollup counts every marked
|
||||
// photo, not just those in the newest-N timeline slice.
|
||||
const markedUids = $derived(Object.keys(marksQuery.data ?? {}));
|
||||
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'marks-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && (category === 'ratings' || category === 'colors')
|
||||
queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
|
||||
queryFn: () => listPhotosByUids(markedUids),
|
||||
enabled:
|
||||
isAuthenticated() &&
|
||||
(category === 'ratings' || category === 'colors') &&
|
||||
markedUids.length > 0
|
||||
}));
|
||||
|
||||
// PhotoPrism returns labels in arbitrary order; sort by photo count
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
startBulk,
|
||||
setDetail,
|
||||
doneBulk,
|
||||
failBulk,
|
||||
markRemoved,
|
||||
clearRemoved
|
||||
} from '$lib/stores/bulkAction.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
|
||||
@@ -113,61 +121,93 @@
|
||||
setFocused(null);
|
||||
}
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
interface BulkConfig {
|
||||
ids: string[];
|
||||
label: string;
|
||||
doneLabel: string;
|
||||
}
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
|
||||
busy = true;
|
||||
if (bulk) startBulk(`${bulk.label}…`, bulk.ids);
|
||||
try {
|
||||
return await fn();
|
||||
const result = await fn();
|
||||
if (bulk) {
|
||||
doneBulk(bulk.doneLabel, bulk.ids);
|
||||
await delay(1000);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (bulk) failBulk(bulk.ids);
|
||||
throw e;
|
||||
} finally {
|
||||
busy = false;
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
const settled = Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['photos'] }),
|
||||
qc.invalidateQueries({ queryKey: ['marks'] }),
|
||||
qc.invalidateQueries({ queryKey: ['review-groups'] })
|
||||
]);
|
||||
// Clear the optimistic-removal overlay only once the refetch has
|
||||
// landed, so tiles never flash back in before the fresh (archived-
|
||||
// filtered) page replaces the old one.
|
||||
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
|
||||
}
|
||||
}
|
||||
|
||||
async function onApprove() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
const tid = toast.loading(`Keeping ${ids.length}…`);
|
||||
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));
|
||||
const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), {
|
||||
onProgress: (_done, _total, completedId) => {
|
||||
const p = cachedPhoto(completedId);
|
||||
setDetail(p?.FileName ?? completedId);
|
||||
}
|
||||
});
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`);
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid });
|
||||
} else {
|
||||
toast.success(`Kept ${ids.length}`);
|
||||
toast.success(`Kept ${ids.length}`, { id: tid });
|
||||
}
|
||||
// Approved photos leave the review section — hide them immediately.
|
||||
markRemoved(ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
});
|
||||
}, { ids, label: 'Keeping', doneLabel: `Kept ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onAcceptDateAndKeep() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
await withBusy(() => acceptDateAndKeep(ids));
|
||||
await withBusy(() => acceptDateAndKeep(ids), {
|
||||
ids,
|
||||
label: 'Updating',
|
||||
doneLabel: `Updated ${ids.length}`
|
||||
});
|
||||
}
|
||||
|
||||
async function onArchive() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
const tid = toast.loading(`Archiving ${ids.length}…`);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchArchive(ids);
|
||||
markRemoved(ids);
|
||||
pushUndo(`Archived ${ids.length}`, async () => {
|
||||
await batchRestore(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
// Advance focus to the photo immediately after the archived
|
||||
// set before the multi-selection is dropped — lets the user
|
||||
// keep stepping through the timeline with X.
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
toast.success(`Archived ${ids.length}`);
|
||||
toast.success(`Archived ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
@@ -178,61 +218,68 @@
|
||||
? 'Permanently delete this photo? This cannot be undone.'
|
||||
: `Permanently delete ${ids.length} photos? This cannot be undone.`;
|
||||
if (!confirm(msg)) return;
|
||||
const tid = toast.loading(`Deleting ${ids.length}…`);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
markRemoved(ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
toast.success(`Deleted ${ids.length}`);
|
||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onRestore() {
|
||||
const ids = snapshotIds();
|
||||
if (ids.length === 0) return;
|
||||
const tid = toast.loading(`Restoring ${ids.length}…`);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchRestore(ids);
|
||||
markRemoved(ids);
|
||||
pushUndo(`Restored ${ids.length}`, async () => {
|
||||
await batchArchive(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
});
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
toast.success(`Restored ${ids.length}`);
|
||||
toast.success(`Restored ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Restore failed');
|
||||
toast.error(err instanceof Error ? err.message : 'Restore failed', { id: tid });
|
||||
}
|
||||
});
|
||||
}, { ids, label: 'Restoring', doneLabel: `Restored ${ids.length}` });
|
||||
}
|
||||
|
||||
async function onAddToHeap(heap: PpAlbum) {
|
||||
const ids = snapshotIds();
|
||||
if (!ids.length) return;
|
||||
heapPickerOpen = false;
|
||||
const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`);
|
||||
startBulk(`Adding to ${heap.Title}…`, ids);
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
const { added } = await addToHeap(heap.UID, ids);
|
||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||
// PhotoPrism returns 200 even when nothing was added (UIDs
|
||||
// already present or unknown to the index) — surface the
|
||||
// real delta so the user isn't fooled by a green toast over
|
||||
// a no-op.
|
||||
if (added.length === 0) {
|
||||
failBulk(ids);
|
||||
toast.error(`Nothing added to ${heap.Title}`, {
|
||||
id: tid,
|
||||
description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).`
|
||||
});
|
||||
return;
|
||||
}
|
||||
doneBulk(`Added ${added.length} → ${heap.Title}`, ids);
|
||||
await delay(400);
|
||||
if (added.length < ids.length) {
|
||||
toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, {
|
||||
id: tid,
|
||||
description: 'The rest were already in this heap.'
|
||||
});
|
||||
} else {
|
||||
toast.success(`Added ${added.length} → ${heap.Title}`);
|
||||
toast.success(`Added ${added.length} → ${heap.Title}`, { id: tid });
|
||||
}
|
||||
pushUndo(`Added ${added.length} to ${heap.Title}`, async () => {
|
||||
await removeFromHeap(heap.UID, added);
|
||||
@@ -240,7 +287,8 @@
|
||||
});
|
||||
clearSelection();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed');
|
||||
failBulk(ids);
|
||||
toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
|
||||
import { view } from "$lib/stores/view.svelte";
|
||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { Loader2, Check, X } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
photo: PpPhoto;
|
||||
@@ -51,7 +54,7 @@
|
||||
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onMouseEnter() {
|
||||
if (!video || selected) return;
|
||||
if (!video || selected || bulkState) return;
|
||||
if (hoverTimer) clearTimeout(hoverTimer);
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverPlaying = true;
|
||||
@@ -73,6 +76,7 @@
|
||||
const tilePx = $derived(view.thumbnailSize);
|
||||
const src1x = $derived(thumbSrc(hash, tilePx));
|
||||
const srcset = $derived(thumbSrcSet(hash, tilePx));
|
||||
const bulkState = $derived(bulkPhotoStates.get(photo.UID));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@@ -150,6 +154,23 @@
|
||||
{#if selected}
|
||||
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
|
||||
{/if}
|
||||
{#if bulkState === 'pending'}
|
||||
<div class="pointer-events-none absolute inset-0 bg-black/50"></div>
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-white/80 drop-shadow" />
|
||||
</div>
|
||||
{:else if bulkState === 'done'}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-emerald-500/70"
|
||||
>
|
||||
<Check class="h-7 w-7 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{:else if bulkState === 'error'}
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/60">
|
||||
<X class="h-7 w-7 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{/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"
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface BatchResult<T> {
|
||||
|
||||
export interface BatchOptions {
|
||||
concurrency?: number;
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
onProgress?: (done: number, total: number, completedId: string) => void;
|
||||
}
|
||||
|
||||
export async function batchEdit<T>(
|
||||
@@ -38,7 +38,7 @@ export async function batchEdit<T>(
|
||||
errors.push({ id, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
done++;
|
||||
opts.onProgress?.(done, ids.length);
|
||||
opts.onProgress?.(done, ids.length, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,31 @@ export function invalidatePhotos(uids: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the sidebar facet sections after a metadata mutation. The Colors /
|
||||
* Ratings panels read `['marks']` + `['photos','marks-pool']`; Notes reads
|
||||
* `['photos','with-notes']`; keywords / labels / people read their own keys.
|
||||
* Optimistic cache writes keep the active tile in sync, but the facet panels
|
||||
* sit on separate queries that otherwise stay stale until their staleTime
|
||||
* expires — so call this on the success path of any marks/keyword/note apply.
|
||||
*/
|
||||
export function invalidateFacets(): void {
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos', 'marks-pool'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos', 'with-notes'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos', 'keywords'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['labels'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['subjects'] });
|
||||
}
|
||||
|
||||
export function invalidateAllPhotoCaches(): void {
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['labels'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a patch to every uid. The patch can be a static body or a per-photo
|
||||
* function (used by keyword merges which need to read each photo's current
|
||||
@@ -57,20 +82,22 @@ export async function patchTargets(
|
||||
)
|
||||
: null;
|
||||
|
||||
const tid = toast.loading(`${label} · ${ids.length}…`);
|
||||
|
||||
const { updated, errors } = await batchEdit(ids, async (id) => {
|
||||
const p = await freshPhoto(id);
|
||||
const body = typeof patch === 'function' ? patch(p) : patch;
|
||||
// An empty body is a no-op signal — e.g. "keyword already present".
|
||||
if (Object.keys(body).length === 0) return p;
|
||||
return updatePhoto(p, body);
|
||||
});
|
||||
|
||||
invalidatePhotos(ids);
|
||||
invalidateFacets();
|
||||
|
||||
if (errors.length) {
|
||||
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`);
|
||||
toast.error(`${label} · ${updated.length} ok, ${errors.length} failed`, { id: tid });
|
||||
} else {
|
||||
toast.success(`${label} · ${ids.length}`);
|
||||
toast.success(`${label} · ${ids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
if (inverses) {
|
||||
|
||||
@@ -66,21 +66,20 @@ export function cachedPhoto(uid: string): PpPhoto | undefined {
|
||||
*/
|
||||
export async function dismissPhotos(uids: string[]): Promise<void> {
|
||||
if (uids.length === 0) return;
|
||||
const tid = toast.loading(`Dismissing ${uids.length}…`);
|
||||
const { updated, errors } = await batchEdit(uids, (id) => approvePhoto(id));
|
||||
// Advance focus past the dismissed set before the timeline refetches
|
||||
// so the cursor doesn't snap back to photo[0]; clear the now-stale
|
||||
// selection ring for the same reason.
|
||||
focusAfter(uids);
|
||||
clearSelection();
|
||||
invalidatePhotos(uids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
if (errors.length) {
|
||||
toast.error(`Dismissed ${updated.length}; ${errors.length} failed`, {
|
||||
id: tid,
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`Dismissed ${uids.length}`);
|
||||
toast.success(`Dismissed ${uids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +92,7 @@ export async function dismissPhotos(uids: string[]): Promise<void> {
|
||||
*/
|
||||
export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
||||
if (uids.length === 0) return;
|
||||
const tid = toast.loading(`Updating & keeping ${uids.length}…`);
|
||||
const { updated, errors } = await batchEdit(uids, async (id) => {
|
||||
const p = cachedPhoto(id);
|
||||
if (p) {
|
||||
@@ -113,11 +113,12 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
if (errors.length) {
|
||||
toast.error(`Kept ${updated.length}; ${errors.length} failed`, {
|
||||
id: tid,
|
||||
description: errors[0].message
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success(`Kept ${uids.length}`);
|
||||
toast.success(`Kept ${uids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,10 +126,11 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
||||
*/
|
||||
export async function archivePhotos(uids: string[]): Promise<void> {
|
||||
if (uids.length === 0) return;
|
||||
const tid = toast.loading(`Archiving ${uids.length}…`);
|
||||
try {
|
||||
await batchArchive(uids);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
|
||||
return;
|
||||
}
|
||||
pushUndo(`Archived ${uids.length}`, async () => {
|
||||
@@ -140,5 +142,5 @@ export async function archivePhotos(uids: string[]): Promise<void> {
|
||||
clearSelection();
|
||||
invalidatePhotos(uids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
toast.success(`Archived ${uids.length}`);
|
||||
toast.success(`Archived ${uids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
@@ -28,6 +28,14 @@ const http: AxiosInstance = axios.create({
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
/** Axios instance for sidecar endpoints — no baseURL prefix so paths
|
||||
* like `/api/sidecar/timeline` resolve directly through Caddy's
|
||||
* `/api/sidecar/*` rule instead of becoming `/api/v1/api/sidecar/*`. */
|
||||
const sidecar: AxiosInstance = axios.create({
|
||||
baseURL: '',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
if (session.accessToken) {
|
||||
config.headers = config.headers ?? {};
|
||||
@@ -36,6 +44,14 @@ http.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
sidecar.interceptors.request.use((config) => {
|
||||
if (session.accessToken) {
|
||||
config.headers = config.headers ?? {};
|
||||
(config.headers as Record<string, string>)['X-Auth-Token'] = session.accessToken;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err: AxiosError) => {
|
||||
@@ -51,6 +67,20 @@ http.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
sidecar.interceptors.response.use(
|
||||
(r) => r,
|
||||
(err: AxiosError) => {
|
||||
if (err.response?.status === 401 && browser) {
|
||||
clearSession();
|
||||
const url = err.config?.url ?? '';
|
||||
if (!url.endsWith('/session')) {
|
||||
void goto('/login', { replaceState: true });
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function login(username: string, password: string): Promise<PpSessionResponse> {
|
||||
@@ -144,7 +174,7 @@ export interface ListPhotosParams {
|
||||
}
|
||||
|
||||
export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto[]> {
|
||||
const { data } = await http.get<PpPhoto[]>('/photos', {
|
||||
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
||||
params: {
|
||||
count: 60,
|
||||
offset: 0,
|
||||
@@ -156,6 +186,26 @@ export async function listPhotos(params: ListPhotosParams = {}): Promise<PpPhoto
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve photos for an explicit set of UIDs. Used by the Colors / Ratings
|
||||
* facets, whose member set comes from the mule-sidecar marks store and is NOT
|
||||
* bounded to the newest N photos — a marked photo anywhere in the library must
|
||||
* resolve. Fetches per-UID (concurrency-bounded) via the same `/photos/:uid`
|
||||
* endpoint the metadata panel uses, so it can't drift from PhotoPrism's search
|
||||
* DSL. Missing UIDs (deleted since marked) are skipped.
|
||||
*/
|
||||
export async function listPhotosByUids(uids: string[]): Promise<PpPhoto[]> {
|
||||
if (uids.length === 0) return [];
|
||||
const out: PpPhoto[] = [];
|
||||
const concurrency = 8;
|
||||
for (let i = 0; i < uids.length; i += concurrency) {
|
||||
const slice = uids.slice(i, i + concurrency);
|
||||
const fetched = await Promise.all(slice.map((uid) => getPhoto(uid).catch(() => null)));
|
||||
for (const p of fetched) if (p) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of photos *anchored at* a specific TakenAt — `before`
|
||||
* older photos preceded by `after` newer ones, merged newest-first.
|
||||
@@ -260,7 +310,7 @@ export async function listPhotosAround(p: AroundParams): Promise<PpPhoto[]> {
|
||||
*/
|
||||
export async function countPhotos(q: string, opts: { merged?: boolean } = {}): Promise<number> {
|
||||
const merged = opts.merged ?? false;
|
||||
const resp = await http.get<PpPhoto[]>('/photos', {
|
||||
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
||||
params: { count: 10000, offset: 0, merged, q }
|
||||
});
|
||||
// PhotoPrism's `X-Count` header counts SQL file rows (one row per
|
||||
@@ -454,11 +504,14 @@ export interface PpFolder {
|
||||
* BasePath is empty (today's admin default) this is a no-op.
|
||||
*/
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
const { data } = await http.get<{ folders?: PpFolder[] }>(
|
||||
'/folders/originals',
|
||||
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||
'/api/sidecar/folders',
|
||||
{ params: { recursive: true, uncached: true, files: false } }
|
||||
);
|
||||
const bp = userBasePath();
|
||||
// Sidecar already filters by BasePath; the frontend still applies the
|
||||
// filter + path rewrite as a safety net for admin (bp="") and for any
|
||||
// folders that might have slipped through.
|
||||
const folders = data.folders ?? [];
|
||||
if (bp === '') return folders;
|
||||
return folders
|
||||
@@ -496,7 +549,7 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
|
||||
// on the way out, then re-key the response back to user-relative on
|
||||
// the way in so callers' map keys line up with their input array.
|
||||
const serverPaths = paths.map((p) => toOriginalsPath(p));
|
||||
const data = (await sidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
|
||||
const data = (await callSidecar('POST', '/folders/counts', { paths: serverPaths })) as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
@@ -586,14 +639,13 @@ export interface PhotoWithNote {
|
||||
}
|
||||
|
||||
export async function listPhotosWithNotes(): Promise<PhotoWithNote[]> {
|
||||
const list = await listPhotos({ count: 1000, order: 'newest', merged: true });
|
||||
// The sidecar pages PhotoPrism to completion server-side and returns only
|
||||
// captioned, BasePath-scoped photos — paging client-side would stop early
|
||||
// because each page is BasePath-filtered before we see it (a full upstream
|
||||
// page can arrive short), silently hiding notes past the first slice.
|
||||
const { data } = await sidecar.get<PpPhoto[]>('/api/sidecar/notes');
|
||||
const out: PhotoWithNote[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const p of list) {
|
||||
// `merged: true` can repeat a photo across file-rows; dedupe by UID
|
||||
// so the same tile doesn't render twice.
|
||||
if (seen.has(p.UID)) continue;
|
||||
seen.add(p.UID);
|
||||
for (const p of data) {
|
||||
const note = p.Caption?.trim();
|
||||
if (!note) continue;
|
||||
out.push({ photo: p, note });
|
||||
@@ -635,6 +687,35 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
||||
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
async function hasPhotosMatching(q: string): Promise<boolean> {
|
||||
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
||||
params: { count: 1, offset: 0, q }
|
||||
});
|
||||
return Array.isArray(resp.data) && resp.data.length > 0;
|
||||
}
|
||||
|
||||
async function filterByUserPhotos<T>(
|
||||
items: T[],
|
||||
queryFor: (item: T) => string
|
||||
): Promise<T[]> {
|
||||
if (userBasePath() === '') return items;
|
||||
const CONCURRENCY = 8;
|
||||
const out: T[] = [];
|
||||
for (let i = 0; i < items.length; i += CONCURRENCY) {
|
||||
const batch = items.slice(i, i + CONCURRENCY);
|
||||
const checks = await Promise.all(
|
||||
batch.map(async (item) => ({
|
||||
item,
|
||||
has: await hasPhotosMatching(queryFor(item))
|
||||
}))
|
||||
);
|
||||
for (const { item, has } of checks) {
|
||||
if (has) out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function listLabels(): Promise<PpLabel[]> {
|
||||
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||
// low-confidence classifier hits, manually-removed labels). They're
|
||||
@@ -643,9 +724,16 @@ export async function listLabels(): Promise<PpLabel[]> {
|
||||
// them out and the tags page silently shows only ~40% of the user's
|
||||
// real tag set. `count` bumped to 1000 so a moderately tagged library
|
||||
// returns the full list in one round-trip.
|
||||
const { data } = await http.get<PpLabel[]>('/labels', {
|
||||
params: { count: 1000, order: 'count', all: true }
|
||||
//
|
||||
// Uses the sidecar proxy (/api/sidecar/labels) instead of PhotoPrism's
|
||||
// /api/v1/labels so PhotoCount reflects only photos under the user's
|
||||
// BasePath. The sidecar proxies the request through to PP then
|
||||
// post-filters each label's count.
|
||||
const { data } = await sidecar.get<PpLabel[]>('/api/sidecar/labels', {
|
||||
params: { count: 1000, order: 'count', all: true, perPage: 1000 }
|
||||
});
|
||||
// Sidecar already filters to the user's scope and sets correct counts +
|
||||
// thumbs in one DB query — no need to probe each label individually.
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -670,7 +758,7 @@ export async function listSubjects(): Promise<PpSubject[]> {
|
||||
const { data } = await http.get<PpSubject[]>('/subjects', {
|
||||
params: { count: 1000, order: 'count' }
|
||||
});
|
||||
return data ?? [];
|
||||
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
|
||||
}
|
||||
|
||||
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
||||
@@ -808,7 +896,7 @@ export interface RenameResult {
|
||||
newRelPath: string;
|
||||
}
|
||||
|
||||
async function sidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
|
||||
async function callSidecar(method: string, urlPath: string, body?: unknown): Promise<unknown> {
|
||||
const res = await fetch(`/api/sidecar${urlPath}`, {
|
||||
method,
|
||||
headers: {
|
||||
@@ -826,20 +914,20 @@ async function sidecar(method: string, urlPath: string, body?: unknown): Promise
|
||||
}
|
||||
|
||||
export async function createFolder(relPath: string): Promise<{ path: string }> {
|
||||
return sidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>;
|
||||
return callSidecar('POST', '/folders', { path: relPath }) as Promise<{ path: string }>;
|
||||
}
|
||||
|
||||
export async function renameFolder(
|
||||
relPath: string,
|
||||
newName: string
|
||||
): Promise<{ oldPath: string; newPath: string }> {
|
||||
return sidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, {
|
||||
return callSidecar('POST', `/folders/${encodeURIComponent(relPath)}/rename`, {
|
||||
newName
|
||||
}) as Promise<{ oldPath: string; newPath: string }>;
|
||||
}
|
||||
|
||||
export async function deleteFolder(relPath: string): Promise<{ path: string }> {
|
||||
return sidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{
|
||||
return callSidecar('DELETE', `/folders/${encodeURIComponent(relPath)}`) as Promise<{
|
||||
path: string;
|
||||
}>;
|
||||
}
|
||||
@@ -872,7 +960,7 @@ export interface CrossFolderScanResult {
|
||||
}
|
||||
|
||||
export async function scanCrossFolderDuplicates(): Promise<CrossFolderScanResult> {
|
||||
return sidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
|
||||
return callSidecar('GET', '/duplicates/scan') as Promise<CrossFolderScanResult>;
|
||||
}
|
||||
|
||||
export interface ArchiveDuplicatesResult {
|
||||
@@ -883,7 +971,7 @@ export interface ArchiveDuplicatesResult {
|
||||
export async function archiveDuplicatePaths(
|
||||
paths: string[]
|
||||
): Promise<ArchiveDuplicatesResult> {
|
||||
return sidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
||||
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
||||
}
|
||||
|
||||
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
|
||||
@@ -915,7 +1003,7 @@ export async function convertHeap(
|
||||
uid: string,
|
||||
body: HeapConvertBody
|
||||
): Promise<HeapConvertResult> {
|
||||
return sidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
|
||||
return callSidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
|
||||
}
|
||||
|
||||
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
||||
@@ -931,19 +1019,19 @@ export interface PhotoMark {
|
||||
export type PhotoMarksMap = Record<string, PhotoMark>;
|
||||
|
||||
export async function getAllMarks(): Promise<PhotoMarksMap> {
|
||||
const data = await sidecar('GET', '/photos/marks');
|
||||
const data = await callSidecar('GET', '/photos/marks');
|
||||
return (data ?? {}) as PhotoMarksMap;
|
||||
}
|
||||
|
||||
export async function setMark(photoUid: string, patch: PhotoMark): Promise<PhotoMark> {
|
||||
return sidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise<PhotoMark>;
|
||||
return callSidecar('PUT', `/photos/${photoUid}/marks`, patch) as Promise<PhotoMark>;
|
||||
}
|
||||
|
||||
export async function bulkSetMarks(
|
||||
ids: string[],
|
||||
patch: PhotoMark
|
||||
): Promise<{ count: number; marks: PhotoMarksMap }> {
|
||||
return sidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{
|
||||
return callSidecar('POST', '/photos/marks/bulk', { ids, patch }) as Promise<{
|
||||
count: number;
|
||||
marks: PhotoMarksMap;
|
||||
}>;
|
||||
|
||||
82
web/src/lib/stores/bulkAction.svelte.ts
Normal file
82
web/src/lib/stores/bulkAction.svelte.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Bulk-action status, written by BulkActionBar and read by the header
|
||||
* StatusPill and individual PhotoTile overlays.
|
||||
*
|
||||
* State lifecycle:
|
||||
* startBulk → pill spins, all target tiles go "pending"
|
||||
* setDetail → pill shows the filename currently being processed (fan-out ops)
|
||||
* doneBulk → pill shows completion label, tiles flash green, auto-clears after 3 s
|
||||
* failBulk → tiles flash red, auto-clears after 2 s
|
||||
*/
|
||||
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface BulkActionState {
|
||||
active: boolean;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
|
||||
// SvelteMap (not `$state(new Map())`) so a `.get(uid)` read in a PhotoTile
|
||||
// reliably re-runs when the entry flips — the plain-Map proxy form wasn't
|
||||
// re-rendering the timeline tiles' overlay.
|
||||
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error'>();
|
||||
|
||||
/**
|
||||
* UIDs hidden from the timeline grid the instant a removing action (archive /
|
||||
* delete / restore) succeeds, so tiles vanish without waiting on the ~1s
|
||||
* server-reconcile refetch. The caller clears each id once the refetch lands.
|
||||
* This is a pure UI overlay — it never touches the query cache, so it can't
|
||||
* corrupt the facet/drill caches the way a direct cache eviction did.
|
||||
*/
|
||||
export const removedIds = $state(new SvelteSet<string>());
|
||||
|
||||
export function markRemoved(ids: string[]): void {
|
||||
for (const id of ids) removedIds.add(id);
|
||||
}
|
||||
|
||||
export function clearRemoved(ids: string[]): void {
|
||||
for (const id of ids) removedIds.delete(id);
|
||||
}
|
||||
|
||||
let doneTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function startBulk(label: string, ids: string[]): void {
|
||||
if (doneTimer !== null) {
|
||||
clearTimeout(doneTimer);
|
||||
doneTimer = null;
|
||||
}
|
||||
bulkPhotoStates.clear();
|
||||
for (const id of ids) bulkPhotoStates.set(id, 'pending');
|
||||
bulkAction.active = true;
|
||||
bulkAction.label = label;
|
||||
bulkAction.detail = undefined;
|
||||
}
|
||||
|
||||
export function setDetail(path: string): void {
|
||||
bulkAction.detail = path;
|
||||
}
|
||||
|
||||
export function doneBulk(label: string, ids: string[]): void {
|
||||
for (const id of ids) bulkPhotoStates.set(id, 'done');
|
||||
bulkAction.active = false;
|
||||
bulkAction.label = label;
|
||||
bulkAction.detail = undefined;
|
||||
if (doneTimer !== null) clearTimeout(doneTimer);
|
||||
doneTimer = setTimeout(() => {
|
||||
bulkAction.label = '';
|
||||
bulkPhotoStates.clear();
|
||||
doneTimer = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function failBulk(ids: string[]): void {
|
||||
for (const id of ids) bulkPhotoStates.set(id, 'error');
|
||||
bulkAction.active = false;
|
||||
bulkAction.label = '';
|
||||
bulkAction.detail = undefined;
|
||||
setTimeout(() => {
|
||||
for (const id of ids) bulkPhotoStates.delete(id);
|
||||
}, 2000);
|
||||
}
|
||||
@@ -13,7 +13,9 @@
|
||||
import { resizable } from '$lib/actions/resizable';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { startIndexerWatch, stopIndexerWatch } from '$lib/stores/indexer.svelte';
|
||||
import { bulkAction } from '$lib/stores/bulkAction.svelte';
|
||||
import IndexerStatusPill from '$lib/components/layout/IndexerStatusPill.svelte';
|
||||
import StatusPill from '$lib/components/layout/StatusPill.svelte';
|
||||
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||
@@ -72,6 +74,7 @@
|
||||
<div class="flex h-screen flex-col overflow-hidden">
|
||||
<AnimatedMule>
|
||||
<IndexerStatusPill />
|
||||
<StatusPill active={bulkAction.active} label={bulkAction.label} detail={bulkAction.detail} />
|
||||
</AnimatedMule>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
{#if !view.leftSidebarCollapsed}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
setFocused,
|
||||
setOrder,
|
||||
} from "$lib/stores/selection.svelte";
|
||||
import { removedIds } from "$lib/stores/bulkAction.svelte";
|
||||
import {
|
||||
openPreview,
|
||||
setRightSidebarWidth,
|
||||
@@ -245,7 +246,12 @@
|
||||
const dedupedAll = $derived<PpPhoto[]>(
|
||||
dedupedPhotos(photosQuery.data?.pages),
|
||||
);
|
||||
const photos = $derived<PpPhoto[]>(applyFolderScope(dedupedAll, filters));
|
||||
// `removedIds` hides tiles the instant a removing action (archive / delete /
|
||||
// restore) succeeds, so the grid updates without waiting on the server-
|
||||
// reconcile refetch (see bulkAction store / BulkActionBar).
|
||||
const photos = $derived<PpPhoto[]>(
|
||||
applyFolderScope(dedupedAll, filters).filter((p) => !removedIds.has(p.UID)),
|
||||
);
|
||||
function dedupedPhotos(pages: PpPhoto[][] | undefined): PpPhoto[] {
|
||||
if (!pages) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -714,6 +720,7 @@
|
||||
return;
|
||||
}
|
||||
emptyingArchive = true;
|
||||
const tid = toast.loading("Emptying archive…");
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
@@ -729,9 +736,9 @@
|
||||
await batchDelete(uids);
|
||||
total += uids.length;
|
||||
}
|
||||
toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`);
|
||||
toast.success(total === 0 ? "Archive already empty" : `Deleted ${total}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Empty archive failed");
|
||||
toast.error(err instanceof Error ? err.message : "Empty archive failed", { id: tid });
|
||||
} finally {
|
||||
emptyingArchive = false;
|
||||
void qc.invalidateQueries({ queryKey: ["photos"] });
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
getPhoto,
|
||||
listLabels,
|
||||
listPhotos,
|
||||
listPhotosByUids,
|
||||
listSubjects,
|
||||
type PhotoMarksMap,
|
||||
type PpLabel,
|
||||
@@ -97,10 +98,14 @@
|
||||
enabled: isAuthenticated() && useLocal,
|
||||
staleTime: 60_000
|
||||
}));
|
||||
// Resolve the pool from the marked UIDs themselves (complete set, any age)
|
||||
// rather than the newest-N timeline slice, so an old marked photo still
|
||||
// lands in its color/rating bucket.
|
||||
const markedUids = $derived(Object.keys(marksQuery.data ?? {}));
|
||||
const marksPoolQuery = createQuery<PpPhoto[]>(() => ({
|
||||
queryKey: ['photos', 'marks-pool'],
|
||||
queryFn: () => listPhotos({ count: 1000, order: 'newest', merged: true }),
|
||||
enabled: isAuthenticated() && useLocal
|
||||
queryKey: ['photos', 'marks-pool', [...markedUids].sort()],
|
||||
queryFn: () => listPhotosByUids(markedUids),
|
||||
enabled: isAuthenticated() && useLocal && markedUids.length > 0
|
||||
}));
|
||||
|
||||
const ratingGroups = $derived(
|
||||
|
||||
Reference in New Issue
Block a user