Merge branch 'develop' into test-improvements

This commit is contained in:
Ömer Duran 2026-05-06 09:47:27 +02:00
commit bde4be3bf8
No known key found for this signature in database
GPG key ID: 2550B0D579890013
49 changed files with 4297 additions and 1912 deletions

View file

@ -36,3 +36,10 @@ Agents MAY run either inside the Development Environment container (recommended)
### CLI Binary Names
The CLI name is `photoprism` in production and public docs. Other binary names (`photoprism-plus`, `photoprism-pro`, `photoprism-portal`) are only used in development builds for side-by-side comparisons.
## Container Image Builds
- **Never mix Debian and Ubuntu `apt` repositories in the same image:**
- Don't add a Debian source to an Ubuntu base (or vice versa) to install a single missing package — the transitive deps drift, apt's solver pulls newer libraries from the foreign distro, and other build steps in the same `RUN` (e.g. `install-libheif.sh` running `apt-get install libavcodec-dev`) silently link against the wrong soname.
- Symptoms surface much later as `dlopen: libfoo.so.N: cannot open shared object file` at image runtime, with the binary referencing a soname that exists only in the foreign distro.
- If a package isn't available in the host distro's repos, prefer (a) a same-distro PPA / backports source, (b) a vendor-supplied .deb (e.g. Google Chrome from `dl.google.com`), or (c) a from-source build pinned to a known version.

View file

@ -27,6 +27,8 @@ Descriptions MUST conclude with a checklist of **Acceptance Criteria**:
> Agents MUST create, edit, close, reopen, relabel, or otherwise modify GitHub issues only when explicitly requested by the user.
The repo's issue templates use the new GitHub `type:` property (`Bug`, `Feature`) instead of `bug`/`idea` labels. `gh issue create` does not yet accept a `--type` flag, so when filing issues programmatically use `--label` only and tell the user to set the issue type via the web UI.
## Specifications & Documentation
- Document headings use a **Chicago-style title case**, with additional code- and path-aware normalization rules (see below). Always spell the product name as `PhotoPrism`.
@ -35,6 +37,7 @@ Descriptions MUST conclude with a checklist of **Acceptance Criteria**:
- Technical specifications in the nested `specs/` subrepository may not be present in every clone or environment. Do not add `Makefile` targets in the main project that depend on `specs/` paths.
- Auto-generated configuration and command references live under `specs/generated/`. Agents MUST NOT read, analyze, or modify anything in this directory.
- Nested Git repositories may appear to be ignored; if so, change directories before staging or committing updates.
- **Never reference `specs/` paths from public artifacts** — issue bodies, PR descriptions, package READMEs (`frontend/README.md`, `internal/*/README.md`, etc.), top-level `CODEMAP.md`/`GLOSSARY.md`, code comments outside `specs/`. External readers see a 404 and the private subrepo's existence is leaked. Hints in `AGENTS.md` and `CLAUDE.md` files are the documented exception. Quick check: `grep -n "specs/" <file>` should return no matches before saving any public-facing file.
> **Title Case** rules (Chicago-style headline capitalization, with code- and path-aware normalization):
> - Capitalize the first word, the first word after a colon, dash, or end punctuation, and all major words, including the second part of a hyphenated major word.

View file

@ -1,3 +1,10 @@
## Frontend Dependencies & Pins
- `frontend/README.md` is the canonical doc for pin rationale, the `overrides` layer, ESM-only upgrade blockers, and the orphan-audit pattern — read it before bumping any non-caret pin or adding/removing a top-level dep.
- **Pins are intentional.** When a version has no caret (e.g., `"axios": "1.16.0"`, `"vuetify": "3.12.2"`), check `frontend/README.md` and `git log -p -S "<pkg>" -- frontend/package.json` for the reason before changing it.
- npm is a workspace; run `npm install --ignore-scripts --no-audit --no-fund --no-update-notifier` from the **repo root** (not `frontend/`) so the root `package-lock.json` updates. After dep changes also run `make audit`, `make build-js`, `make test-js`, and `make notice`.
- Before adding a new dep or removing one as "unused", run `rg -nF "<pkg>" frontend ...` plus `npm ls <pkg> --all` to confirm there's no transitive consumer or peer-dep. Recent precedents: `postcss-url`, `@vitejs/plugin-react`, `cheerio`, `@testing-library/react`, `vite-tsconfig-paths` (all true orphans removed once consumer left).
## Frontend Test Coverage
- Test new JS functions (including helpers) and new Vue components whenever practical; update existing tests when behavior changes.

View file

@ -8,7 +8,8 @@
- REST API: https://docs.photoprism.dev/ (Swagger), https://docs.photoprism.app/developer-guide/api/ (Docs)
- Code Maps: `CODEMAP.md` (Backend/Go), `frontend/CODEMAP.md` (Frontend/JS)
- Terminology Glossary: `GLOSSARY.md` (single source for term definitions across specs/docs)
- Package-level `README.md` files under `internal/`, `pkg/`, and `frontend/src/` for detailed package documentation.
- Package-level `README.md` files under `internal/`, `pkg/`, `frontend/`, and `frontend/src/` for detailed package documentation.
- Frontend dependency pin rationale, override layer, and orphan-audit pattern: `frontend/README.md` (check before bumping any non-caret pin or adding/removing a top-level dep).
> Quick Tip: to inspect GitHub issue details without leaving the terminal, run `curl -s https://api.github.com/repos/photoprism/photoprism/issues/<id>`; if `gh` is set up, you MAY also run `gh issue view <id> -R photoprism/photoprism`.

View file

@ -2,7 +2,7 @@
name: Bug Report 🐞
about: Report a new and clearly identified bug that must be fixed directly in the application
title: 'SHORT DESCRIPTION OF THE PROBLEM YOU ARE REPORTING'
labels: bug
type: Bug
assignees: ''
---

View file

@ -1,8 +1,7 @@
name: Bug Report 🐞
description: Report a new and clearly identified bug that must be fixed directly in the application.
title: "Category: Short bug description (PLEASE CHANGE)"
labels:
- bug
type: Bug
assignees: []
body:
- type: markdown

View file

@ -2,7 +2,7 @@
name: Feature Request
about: Suggest a new feature or enhancement
title: 'Category: Short Description (PLEASE CHANGE)'
labels: idea
type: Feature
assignees: ''
---

View file

@ -1,8 +1,7 @@
name: Feature Request
description: Suggest a new feature or enhancement.
title: "Category: Short feature description (PLEASE CHANGE)"
labels:
- idea
type: Feature
assignees: []
body:
- type: markdown

View file

@ -239,6 +239,48 @@ debug = false
action = "search"
object = "*"
[[users]]
name = "mona"
givenname = "Mona"
objectClass = "manager"
displayName = "Mona Man"
sn = "Man"
userPrincipalName = "mona@example.com"
mail = "mona@example.com"
uidnumber = 5011
primarygroup = 5511
loginShell = "/bin/bash"
otherGroups = [5505]
passsha256 = "4314c1fe282face45336b1422a3285c5ff31a39c8e24425615fa53a43b718493" # photoprism
[[users.customattributes]]
photoprismRoleManager = ["true"]
photoprismNoLogin = ["false"]
photoprismWebdav = ["true"]
[[users.capabilities]]
action = "search"
object = "*"
[[users]]
name = "laura"
givenname = "Laura"
objectClass = "admin"
displayName = "Laura Scope"
sn = "Doe"
userPrincipalName = "laura@example.com"
mail = "laura@example.com"
uidnumber = 5001
primarygroup = 5501
loginShell = "/bin/bash"
otherGroups = [5501, 5508]
passsha256 = "4314c1fe282face45336b1422a3285c5ff31a39c8e24425615fa53a43b718493" # photoprism
[[users.customattributes]]
photoprismRoleAdmin = ["true"]
photoprismNoLogin = ["false"]
photoprismWebdav = ["true"]
[[users.capabilities]]
action = "search"
object = "*"
[[users]]
name = "max"
givenname = "Max"
@ -298,4 +340,8 @@ debug = false
[[groups]]
name = "PhotoPrism-viewer"
gidnumber = 5510
gidnumber = 5510
[[groups]]
name = "PhotoPrism-manager"
gidnumber = 5511

View file

@ -1,6 +1,6 @@
# PhotoPrism Repository Guidelines
**Last Updated:** April 9, 2026
**Last Updated:** May 5, 2026
## Purpose
@ -15,7 +15,8 @@ Entry point for agents and humans.
- Security: https://github.com/photoprism/photoprism/blob/develop/SECURITY.md
- REST API: https://docs.photoprism.dev/ and https://docs.photoprism.app/developer-guide/api/
- Code maps: [`CODEMAP.md`](CODEMAP.md), [`frontend/CODEMAP.md`](frontend/CODEMAP.md)
- Package docs: `README.md` files under `internal/`, `pkg/`, and `frontend/src/`
- Package docs: `README.md` files under `internal/`, `pkg/`, `frontend/`, and `frontend/src/`
- Frontend dependency pins, override layer, and orphan-audit pattern: [`frontend/README.md`](frontend/README.md) (read before bumping any non-caret pin or adding/removing a top-level dep)
- AI/Vision docs: [`internal/ai/face/README.md`](internal/ai/face/README.md), [`internal/ai/vision/README.md`](internal/ai/vision/README.md), [`internal/ai/vision/openai/README.md`](internal/ai/vision/openai/README.md), [`internal/ai/vision/ollama/README.md`](internal/ai/vision/ollama/README.md)
- Glossary: [`GLOSSARY.md`](GLOSSARY.md)
- When dependencies change, regenerate `NOTICE` files with `make notice`; do not edit `NOTICE` or `frontend/NOTICE` manually.
@ -148,3 +149,10 @@ Formatting and test entry points:
- Prefer focused test runs such as `go test ./path/to/pkg -run Name -count=1` while iterating.
- Use `mariadb -D photoprism` inside the dev shell when you need to inspect MariaDB state directly.
- Run `shellcheck <file>` on edited shell scripts, or use the corresponding `make` target.
### Container Image Builds
- **Never mix Debian and Ubuntu `apt` repositories in the same image:**
- Don't add a Debian source to an Ubuntu base (or vice versa) to install a single missing package — the transitive deps drift, apt's solver pulls newer libraries from the foreign distro, and other build steps in the same `RUN` (e.g. `install-libheif.sh` running `apt-get install libavcodec-dev`) silently link against the wrong soname.
- Symptoms surface much later as `dlopen: libfoo.so.N: cannot open shared object file` at image runtime, with the binary referencing a soname that exists only in the foreign distro.
- If a package isn't available in the host distro's repos, prefer (a) a same-distro PPA / backports source, (b) a vendor-supplied .deb (e.g. Google Chrome from `dl.google.com`), or (c) a from-source build pinned to a known version.

View file

@ -1,6 +1,6 @@
PhotoPrism — Backend CODEMAP
**Last Updated:** March 8, 2026
**Last Updated:** May 5, 2026
Purpose
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
@ -136,7 +136,6 @@ Cluster / Portal
- Registry/provisioner: `internal/service/cluster/registry/*`, `internal/service/cluster/provisioner/*`.
- Theme endpoint (server): GET `/api/v1/cluster/theme`; client/CLI installs theme only if missing or no `app.js`.
- Portal-only extensions: `portal/internal/portal` (Portal defaults, flags, provisioning options, `/i/*` proxy router).
- See specs cheat sheet: `specs/portal/README.md`.
Logging & Events
- Logger and event hub: `internal/event/*`; `event.Log` is the shared logger.
@ -289,7 +288,6 @@ Useful Make Targets (selection)
See Also
- AGENTS.md (repository rules and tips for agents)
- Developer Guide (Setup/Tests/API) — links in AGENTS.md → Sources of Truth
- Specs: `specs/dev/backend-testing.md`, `specs/dev/api-docs-swagger.md`, `specs/portal/README.md`
Go Internal Import Rule
- Keep temporary Go helpers inside `internal/...`; the Go toolchain blocks importing `internal/` packages from directories such as `/tmp`, so use a disposable path like `internal/tmp/` when you need scratch space.

View file

@ -1,5 +1,5 @@
# Ubuntu 25.10 (Questing Quokka)
FROM photoprism/develop:260427-questing
FROM photoprism/develop:260505-questing
# Harden npm usage by default (applies to npm ci / install in dev container)
ENV NPM_CONFIG_IGNORE_SCRIPTS=true

View file

@ -4,7 +4,7 @@
### Purpose & Scope
- This is the single source of truth for terminology used across `specs/` and related docs.
- This is the single source of truth for terminology used across PhotoPrism documentation.
- Define terms once here and reference this file instead of redefining the same terms in multiple documents.
- Keep technical/API contract names unchanged where required, even when user-facing wording differs.

View file

@ -221,7 +221,7 @@ install-onnx:
sudo scripts/dist/install-onnx.sh
install-darktable:
sudo scripts/dist/install-darktable.sh
acceptance-sqlite-restart:
acceptance-sqlite-restart: acceptance-sqlite-stop
cp -f storage/acceptance/backup.db storage/acceptance/index.db
cp -f storage/acceptance/config-sqlite/settingsBackup.yml storage/acceptance/config-sqlite/settings.yml
rm -rf storage/acceptance/sidecar/2020
@ -234,7 +234,7 @@ acceptance-sqlite-restart:
./photoprism --auth-mode="public" -c "./storage/acceptance/config-sqlite" start -d
acceptance-sqlite-stop:
./photoprism --auth-mode="public" -c "./storage/acceptance/config-sqlite" stop
acceptance-auth-sqlite-restart:
acceptance-auth-sqlite-restart: acceptance-auth-sqlite-stop
cp -f storage/acceptance/backup.db storage/acceptance/index.db
cp -f storage/acceptance/config-sqlite/settingsBackup.yml storage/acceptance/config-sqlite/settings.yml
./photoprism --auth-mode="password" -c "./storage/acceptance/config-sqlite" start -d
@ -423,19 +423,19 @@ build-static:
scripts/build.sh static $(BINARY_NAME)
build-libheif: build-libheif-amd64 build-libheif-arm64 build-libheif-armv7
build-libheif-amd64:
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:questing ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:plucky ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:noble ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:jammy ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:bookworm ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:questing ./scripts/dist/build-libheif.sh v1.21.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:plucky ./scripts/dist/build-libheif.sh v1.21.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:noble ./scripts/dist/build-libheif.sh v1.21.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:jammy ./scripts/dist/build-libheif.sh v1.21.2
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:bookworm ./scripts/dist/build-libheif.sh v1.21.2
build-libheif-arm64:
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:questing ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:plucky ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:noble ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:jammy ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:bookworm ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:questing ./scripts/dist/build-libheif.sh v1.21.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:plucky ./scripts/dist/build-libheif.sh v1.21.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:noble ./scripts/dist/build-libheif.sh v1.21.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:jammy ./scripts/dist/build-libheif.sh v1.21.2
docker run --rm -u $(UID) --platform=arm64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm64 -e SYSTEM_ARCH=arm64 photoprism/develop:bookworm ./scripts/dist/build-libheif.sh v1.21.2
build-libheif-armv7:
docker run --rm -u $(UID) --platform=arm --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm -e SYSTEM_ARCH=arm photoprism/develop:armv7 ./scripts/dist/build-libheif.sh v1.20.2
docker run --rm -u $(UID) --platform=arm --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=arm -e SYSTEM_ARCH=arm photoprism/develop:armv7 ./scripts/dist/build-libheif.sh v1.21.2
build-libheif-latest: build-libheif-amd64-latest build-libheif-arm64-latest build-libheif-armv7-latest
build-libheif-amd64-latest:
docker run --rm -u $(UID) --platform=amd64 --pull=always -v ".:/go/src/github.com/photoprism/photoprism" -e BUILD_ARCH=amd64 -e SYSTEM_ARCH=amd64 photoprism/develop:questing ./scripts/dist/build-libheif.sh
@ -608,6 +608,7 @@ docker-develop-all: docker-develop-latest docker-develop-other
docker-develop-latest: docker-develop-ubuntu
docker-develop-debian: docker-develop-bookworm docker-develop-bookworm-slim
docker-develop-ubuntu: docker-develop-questing docker-develop-questing-slim
docker-develop-legacy: docker-develop-jammy docker-develop-jammy-slim
docker-develop-other: docker-develop-debian docker-develop-bullseye docker-develop-bullseye-slim docker-develop-buster
docker-develop-bookworm:
docker pull --platform=amd64 debian:bookworm-slim

23
NOTICE
View file

@ -9,7 +9,7 @@ The following 3rd-party software packages may be used by or distributed with
PhotoPrism. Any information relevant to third-party vendors listed below are
collected using common, reasonable means.
Date generated: 2026-05-03
Date generated: 2026-05-05
================================================================================
@ -1907,8 +1907,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/go-sql-driver/mysql
Version: v1.9.3
License: MPL-2.0 (https://github.com/go-sql-driver/mysql/blob/v1.9.3/LICENSE)
Version: v1.10.0
License: MPL-2.0 (https://github.com/go-sql-driver/mysql/blob/v1.10.0/LICENSE)
Mozilla Public License Version 2.0
==================================
@ -2596,8 +2596,8 @@ License: Apache-2.0 (https://github.com/golang/geo/blob/41a1a8c7eb2a/LICENSE)
--------------------------------------------------------------------------------
Package: github.com/google/jsonschema-go/jsonschema
Version: v0.4.2
License: MIT (https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)
Version: v0.4.3
License: MIT (https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)
MIT License
@ -4723,8 +4723,8 @@ SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/modelcontextprotocol/go-sdk
Version: v1.5.0
License: Apache-2.0 (https://github.com/modelcontextprotocol/go-sdk/blob/v1.5.0/LICENSE)
Version: v1.6.0
License: Apache-2.0 (https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.0/LICENSE)
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
@ -6973,8 +6973,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/yalue/onnxruntime_go
Version: v1.28.0
License: MIT (https://github.com/yalue/onnxruntime_go/blob/v1.28.0/LICENSE)
Version: v1.30.0
License: MIT (https://github.com/yalue/onnxruntime_go/blob/v1.30.0/LICENSE)
Copyright (c) 2023 Nathan Otterness
@ -9620,8 +9620,6 @@ Package License Copyright
@eslint/js MIT n/a
@mdi/font Apache-2.0 Austin Andrews
@testing-library/jest-dom MIT Ernesto Garcia <gnapse@gmail.com> (http://gnapse.github.io)
@testing-library/react MIT Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com)
@vitejs/plugin-react MIT Evan You
@vitejs/plugin-vue MIT Evan You
@vitest/browser MIT n/a
@vitest/coverage-v8 MIT Anthony Fu <anthonyfu117@hotmail.com>
@ -9636,7 +9634,6 @@ babel-loader MIT Luis Couto <hello@luiscouto.pt>
babel-plugin-istanbul BSD-3-Clause Thai Pangsakulyanont @dtinth
babel-plugin-polyfill-corejs3 MIT n/a
browserslist MIT Andrey Sitnik <andrey@sitnik.es>
cheerio MIT Matt Mueller <mattmuelle@gmail.com>
core-js MIT Denis Pushkarev zloirock@zloirock.ru http://zloirock.ru
cross-env MIT Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com)
css-loader MIT Tobias Koppers @sokra
@ -9674,7 +9671,6 @@ postcss-import MIT Maxime Thirouin
postcss-loader MIT Andrey Sitnik <andrey@sitnik.ru>
postcss-preset-env MIT-0 n/a
postcss-reporter MIT David Clark david.dave.clark@gmail.com https://davidtheclark.com
postcss-url MIT Maxime Thirouin
prettier MIT James Long
pubsub-js MIT Morgan Roderick morgan@roderick.dk http://roderick.dk
regenerator-runtime MIT Ben Newman <bn@cs.stanford.edu>
@ -9688,7 +9684,6 @@ svg-url-loader MIT Hovhannes Babayan
tar BlueOak-1.0.0 Isaac Z. Schlueter
url-loader MIT Tobias Koppers @sokra
util MIT Joyent http://www.joyent.com
vite-tsconfig-paths MIT aleclarson
vitest MIT Anthony Fu <anthonyfu117@hotmail.com>
vue MIT Evan You
vue-3-sanitize MIT Vannsl, Vanessa Otto <mail@vannsl.io>

View file

@ -1,6 +1,14 @@
# Frontend Guidelines
**Last Updated:** April 9, 2026
**Last Updated:** May 5, 2026
## Dependencies & Pins
- [`frontend/README.md`](README.md) is the canonical doc for dependency pin rationale, the `overrides` layer, ESM-only upgrade blockers, and the orphan-audit pattern.
- **Pins are intentional.** When a version has no caret (e.g., `"axios": "1.16.0"`, `"vuetify": "3.12.2"`), check `frontend/README.md` and `git log -p -S "<pkg>" -- frontend/package.json` for the reason before changing it.
- npm is a workspace; run `npm install --ignore-scripts --no-audit --no-fund --no-update-notifier` from the **repo root** (not `frontend/`) so the root `package-lock.json` updates.
- After dep changes run `make audit`, `make build-js`, `make test-js`, and `make notice`.
- Before adding a new dep — and especially before declaring an existing one "unused" — verify with `rg -nF "<pkg>" frontend …` plus `npm ls <pkg> --all` that no consumer or peer-dep needs it.
## Frontend Linting & Test Entry Points

View file

@ -1,6 +1,6 @@
PhotoPrism — Frontend CODEMAP
**Last Updated:** March 8, 2026
**Last Updated:** May 5, 2026
Purpose
- Help agents and contributors navigate the Vue 3 + Vuetify 3 app quickly and make safe changes.
@ -35,6 +35,10 @@ Startup Templates & Splash Screen
Runtime & Plugins
- Vue 3 + Vuetify 3 (`createVuetify`) with MDI icons; themes from `src/options/themes.js`
- **Vuetify version pin:** `vuetify` is pinned to **`3.12.2` exactly** (no caret); see [`frontend/README.md`](README.md#currently-pinned-packages) for the canonical rationale. The TL;DR is that 3.12.3+ introduced a `VAutocomplete`/`VSelect`/`VCombobox` `onFocusout` handler that closes long dropdowns on open (#5538), unfixed in 3.12.5. The sibling-menu gate in `src/common/view.js` cooperates with the pin but is not a substitute for it.
- **Known caveats at 3.12.2:**
- Vuetify upstream issue #22828`v-select`'s `@blur` fires when the menu opens (introduced by the 3.12.2 screenreader navigation fix). PhotoPrism is not affected because we only bind `@blur` on `v-text-field`, `v-textarea`, and `v-combobox`; if you ever attach `@blur` to a `v-select`, expect spurious calls until that upstream bug is fixed.
- The `.v-field--focused` CSS class can linger on a previously-focused `v-autocomplete` input after the user clicks into another autocomplete (`document.activeElement` is correct, but Vuetify's internal `isFocused` is under-aggressive about clearing in 3.12.2). This is the inverse symptom of Vuetify #22697 — fixing it overshot in 3.12.3 and caused #5538. Functionally harmless in the photo edit dialog because the affected fields are not on screen together.
- Router: Vue Router 4, history base at `$config.frontendUri` (default `/library` for CE/Plus/Pro and `/portal/admin` for Portal)
- I18n: `vue3-gettext` via `common/gettext.js`; canonical extraction via root `make gettext-extract` (scans `frontend/src` plus available overlays in `plus/frontend`, `pro/frontend`, and `portal/frontend`), compile with `npm run gettext-compile`
- HTML sanitization: `vue-3-sanitize` + `vue-sanitize-directive`

View file

@ -10,8 +10,6 @@ Package License Copyright
@eslint/js MIT n/a
@mdi/font Apache-2.0 Austin Andrews
@testing-library/jest-dom MIT Ernesto Garcia <gnapse@gmail.com> (http://gnapse.github.io)
@testing-library/react MIT Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com)
@vitejs/plugin-react MIT Evan You
@vitejs/plugin-vue MIT Evan You
@vitest/browser MIT n/a
@vitest/coverage-v8 MIT Anthony Fu <anthonyfu117@hotmail.com>
@ -26,7 +24,6 @@ babel-loader MIT Luis Couto <hello@luiscouto.pt>
babel-plugin-istanbul BSD-3-Clause Thai Pangsakulyanont @dtinth
babel-plugin-polyfill-corejs3 MIT n/a
browserslist MIT Andrey Sitnik <andrey@sitnik.es>
cheerio MIT Matt Mueller <mattmuelle@gmail.com>
core-js MIT Denis Pushkarev zloirock@zloirock.ru http://zloirock.ru
cross-env MIT Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com)
css-loader MIT Tobias Koppers @sokra
@ -64,7 +61,6 @@ postcss-import MIT Maxime Thirouin
postcss-loader MIT Andrey Sitnik <andrey@sitnik.ru>
postcss-preset-env MIT-0 n/a
postcss-reporter MIT David Clark david.dave.clark@gmail.com https://davidtheclark.com
postcss-url MIT Maxime Thirouin
prettier MIT James Long
pubsub-js MIT Morgan Roderick morgan@roderick.dk http://roderick.dk
regenerator-runtime MIT Ben Newman <bn@cs.stanford.edu>
@ -78,7 +74,6 @@ svg-url-loader MIT Hovhannes Babayan
tar BlueOak-1.0.0 Isaac Z. Schlueter
url-loader MIT Tobias Koppers @sokra
util MIT Joyent http://www.joyent.com
vite-tsconfig-paths MIT aleclarson
vitest MIT Anthony Fu <anthonyfu117@hotmail.com>
vue MIT Evan You
vue-3-sanitize MIT Vannsl, Vanessa Otto <mail@vannsl.io>

102
frontend/README.md Normal file
View file

@ -0,0 +1,102 @@
# PhotoPrism Frontend
The Vue 3 + Vuetify 3 web UI for PhotoPrism. Built with webpack, tested with Vitest, and packaged into the Go binary as static assets.
Other frontend documentation lives next to this file:
- `frontend/AGENTS.md` — agent quickstart
- `frontend/CODEMAP.md` — module layout and responsibilities
- `frontend/src/common/README.md` — dialog/focus patterns and shared helpers
- `frontend/tests/README.md` — test layout
## Common Commands
| Task | Command |
|---------------------|-----------------------------------------------------|
| Production build | `make build-js` |
| Watch (development) | `make watch-js` |
| Vitest unit tests | `make test-js` (sets `TZ=UTC` and `BABEL_ENV=test`) |
| Vitest watch | `make vitest-watch` |
| Coverage | `make vitest-coverage` |
| Lint and format | `make fmt-js` |
| Audit dependencies | `make audit` |
| List outdated deps | `cd frontend && make dep-list` |
| Refresh NOTICE | `make notice` |
> Always invoke Vitest through `make test-js` or `npm run test`. Bare `npx vitest run` skips the `cross-env` wrapper that sets `TZ=UTC BUILD_ENV=development NODE_ENV=development BABEL_ENV=test`. Without those, ~50 component and TZ-sensitive tests fail spuriously.
## Dependency Pinning Policy
**Pins are intentional.** When a version is locked without a caret (e.g., `"axios": "1.16.0"`), it is intentional. Before adjusting any pin, check the table below, the inline `//` comments at the top of `package.json`, and the git log (`git log -p -- frontend/package.json | grep -B2 -A4 "<pkg>"`).
### Currently Pinned Packages
| Package | Pin | Reason |
|-----------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `vuetify` | `3.12.2` | 3.12.3+ added an `onFocusout` handler to `VAutocomplete`/`VSelect`/`VCombobox` that closes long autocomplete/select dropdowns on open (#5538). Still unfixed in 3.12.5; upstream development moved to v4. See the long `//vuetify` comment in `package.json` and `frontend/CODEMAP.md` for retest steps. |
| `axios` | `1.16.0` | High-risk package. Originally pinned to `1.14.0` after the March 2026 supply-chain compromise (malicious `1.14.1`/`0.30.4` from a hijacked maintainer account). Quarantine was unwound on 2026-04-27 once OSV-Scanner came back clean. Keep an exact pin (no caret) per industry guidance for high-risk packages. |
### Override Layer (Transitive Pins)
`frontend/package.json` and root `package.json` declare matching `overrides`. Mirroring them keeps the npm workspace lockfile resolution consistent.
| Override | Reason |
|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
| `"serialize-javascript": "^7.0.5"` | Closes the `workbox-build``@rollup/plugin-terser``serialize-javascript` RCE advisory (`GHSA-5c6j-r48x-rmvq`, `GHSA-qj8w-gfj5-8c6v`). |
When an upstream advisory is fully resolved, retire the override and rerun `make audit` plus a focused build/test pass before committing the cleanup.
## Major-Version Upgrades — Known Blockers
Some major upgrades are blocked by config-file module style. The configs referenced below are CommonJS today; an ESM migration is required before bumping these. Track each as its own change:
| Package | Latest | Blocker |
|-------------------------------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| `postcss-preset-env` 11.x | ESM | `frontend/postcss.config.js` is CommonJS (`module.exports = { plugins: [require("postcss-preset-env"), ...] }`). |
| `webpack-manifest-plugin` 6.x | ESM | `frontend/webpack.config.js` is CommonJS (`require("webpack-manifest-plugin")`). Webpack accepts ESM configs, but the migration is non-trivial. |
| `escape-string-regexp` 5.x | ESM | v5 is ESM-only; verify every consumer (including transitive build-time tooling) before bumping. |
| `vuetify` 4.x | — | See the `vuetify` row in [Currently Pinned Packages](#currently-pinned-packages); also a separate v3 → v4 migration project. |
| `vue-router` 5.x | — | Major release with breaking changes across `frontend/src/app/routes.js` and dynamic imports. Needs its own evaluation pass with TestCafe verification. |
## Auditing for Orphaned Dependencies
Past migrations (e.g., `easygettext``vue3-gettext`, mocha → Vitest) have occasionally left top-level deps behind that no longer have any consumer. Before adding a new dep — and ideally as a periodic sweep — verify each candidate has a real consumer:
```sh
rg -nF "<pkg>" frontend \
--glob '!node_modules/**' --glob '!package-lock.json' --glob '!NOTICE'
cd frontend && npm ls <pkg> --all
```
If neither command surfaces a real source-level import or transitive consumer, the dep is a removal candidate (recent precedents: `postcss-url`, `@vitejs/plugin-react`, `cheerio`, `@testing-library/react`, `vite-tsconfig-paths`).
## Adding a New Dependency
1. Confirm the package has an active maintainer, scoped name, and a 2FA-protected publisher.
2. Avoid packages that require `postinstall`/`install` scripts. Installs default to `--ignore-scripts`.
3. Add to `frontend/package.json`. From the **repo root** run `npm install --ignore-scripts --no-audit --no-fund --no-update-notifier` so the workspace lockfile updates.
4. `make audit` must report zero advisories.
5. Run `make build-js` and `make test-js`.
6. `make notice` to refresh `NOTICE` and `frontend/NOTICE`.
## Removing a Dependency
1. Confirm no source imports anywhere (use the `rg` command in [Known Unused or Legacy Dependencies](#known-unused-or-legacy-dependencies)).
2. Drop the line from `frontend/package.json`.
3. Run `npm install` from the repo root (refreshes the workspace lockfile).
4. `make audit`, `make build-js`, `make test-js`, `make notice`.
## Bumping a Dependency
1. Check the table in [Currently Pinned Packages](#currently-pinned-packages); pinned packages need extra care.
2. Check the table in [Major-Version Upgrades — Known Blockers](#major-version-upgrades--known-blockers) for ESM-only majors that would require a config rewrite.
3. Edit the version in `frontend/package.json`, then `npm install` from repo root.
4. Run `make audit && make build-js && make test-js`. For test runner or build tooling, also do an ad-hoc smoke test (e.g., `npm run build-analyze` for `webpack-bundle-analyzer`).
5. Refresh `make notice` if the package count or licenses changed.
6. Update [Currently Pinned Packages](#currently-pinned-packages) or this document if the rationale for an existing pin no longer applies.
## Sources of Truth
- `Makefile` and `frontend/Makefile` for build, test, and audit targets.
- `frontend/package.json` for dependency declarations, overrides, and pin rationale comments.
- Git log for the *why* behind any specific pin or removal — search with `git log -p -S "<pkg>" -- frontend/package.json`.

View file

@ -5,6 +5,7 @@
"license": "AGPL-3.0",
"version": "1",
"private": true,
"//vuetify": "Vuetify is pinned to 3.12.2 (no caret, exact version) — DO NOT bump to 3.12.3+ without revisiting issue #5538. The 3.12.3 release added an onFocusout handler to VAutocomplete/VSelect/VCombobox (PR fixing Vuetify #22697) that flips isFocused=false whenever relatedTarget is outside the textfield. That handler closes long autocomplete/select dropdowns on open, because Vuetify's virtual scroller unmounts the focused list item during scroll-to-selected-index, producing a transient blur with relatedTarget=null. The bug is unfixed in 3.12.5 and Vuetify development has moved to v4. When lifting this pin (e.g., a future v3.12.x patch or v4 migration), test the photo edit dialog's Country and Time Zone autocompletes in Chrome — the menu must stay open on click. KNOWN CAVEATS of 3.12.2: (1) Vuetify #22828 — v-select's @blur event fires when the menu opens (introduced by the 3.12.2 screenreader fix in commit f906336). PhotoPrism is not affected because we only attach @blur handlers to v-text-field, v-textarea, and v-combobox; if you ever add @blur to a v-select, expect spurious calls until that upstream bug is fixed. (2) The .v-field--focused CSS class can linger on a previously-focused v-autocomplete input after the user clicks into another autocomplete — document.activeElement is correct, but Vuetify's isFocused state is under-aggressive about clearing in 3.12.2. This is the inverse symptom of #22697 (which 3.12.3 over-corrected, causing #5538). Visually harmless in the photo edit dialog because the affected fields aren't on screen together. See also frontend/src/common/view.js (sibling-menu gate) and frontend/CODEMAP.md.",
"scripts": {
"acceptance-local": "testcafe chromium --selector-timeout 5000 -S -s tests/acceptance/screenshots tests/acceptance",
"build": "webpack --node-env=production",
@ -38,34 +39,31 @@
"@babel/cli": "^7.28.6",
"@babel/core": "^7.29.0",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/register": "^7.28.6",
"@babel/preset-env": "^7.29.3",
"@babel/register": "^7.29.3",
"@babel/runtime": "^7.29.2",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.33.0",
"@mdi/font": "^7.4.47",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^5.2.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@vue/compiler-sfc": "^3.5.18",
"@vue/language-server": "^3.2.7",
"@vue/test-utils": "^2.4.9",
"@vue/compiler-sfc": "^3.5.33",
"@vue/language-server": "^3.2.8",
"@vue/test-utils": "^2.4.10",
"@vvo/tzdb": "^6.198.0",
"axios": "1.15.2",
"axios": "1.16.0",
"axios-mock-adapter": "^2.1.0",
"babel-loader": "^10.1.1",
"babel-plugin-istanbul": "^7.0.1",
"babel-plugin-polyfill-corejs3": "^0.13.0",
"babel-plugin-polyfill-corejs3": "^0.14.2",
"browserslist": "^4.28.2",
"cheerio": "1.0.0-rc.12",
"core-js": "^3.49.0",
"cross-env": "^7.0.3",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"cssnano": "^7.1.7",
"cssnano": "^7.1.9",
"escape-string-regexp": "^4.0.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
@ -81,10 +79,10 @@
"file-loader": "^6.2.0",
"file-saver": "^2.0.5",
"floating-vue": "^5.2.2",
"globals": "^16.5.0",
"globals": "^17.6.0",
"hls.js": "^1.6.16",
"i": "^0.3.7",
"jsdom": "^26.1.0",
"jsdom": "^29.1.1",
"luxon": "^3.7.2",
"maplibre-gl": "^5.24.0",
"memoize-one": "^6.0.0",
@ -94,12 +92,11 @@
"passive-events-support": "^1.1.0",
"photoswipe": "^5.4.4",
"playwright": "^1.59.1",
"postcss": "^8.5.12",
"postcss": "^8.5.14",
"postcss-import": "^16.1.1",
"postcss-loader": "^8.2.1",
"postcss-preset-env": "^10.6.1",
"postcss-reporter": "^7.1.0",
"postcss-url": "^10.1.3",
"prettier": "^3.8.3",
"pubsub-js": "^1.9.5",
"regenerator-runtime": "^0.14.1",
@ -113,9 +110,8 @@
"tar": "^7.5.13",
"url-loader": "^4.1.1",
"util": "^0.12.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"vue": "^3.5.18",
"vue": "^3.5.33",
"vue-3-sanitize": "^0.1.4",
"vue-loader": "^17.4.2",
"vue-loader-plugin": "^1.3.0",
@ -124,16 +120,16 @@
"vue-sanitize-directive": "^0.2.1",
"vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0",
"vuetify": "^3.12.5",
"vuetify": "3.12.2",
"webpack": "^5.106.2",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
"webpack-hot-middleware": "^2.26.1",
"webpack-manifest-plugin": "^5.0.1",
"webpack-md5-hash": "^0.0.6",
"webpack-merge": "^6.0.1",
"webpack-plugin-vuetify": "^3.1.3",
"workbox-webpack-plugin": "^7.4.0"
"workbox-webpack-plugin": "^7.4.1"
},
"engines": {
"node": ">= 22.15.0",
@ -141,7 +137,6 @@
"yarn": "please use npm"
},
"overrides": {
"minimatch@~3.0": "^3.1.3",
"serialize-javascript": "^7.0.5"
}
}

View file

@ -724,6 +724,40 @@ export class View {
return;
}
// Sibling-menu gate: components like v-autocomplete, v-select, and v-combobox
// teleport their dropdown menus to <body>, so the menu's overlay element is
// a sibling of the dialog's overlay (both children of the same parent),
// NOT a descendant of the dialog. When the user opens such a menu, focus
// moves from the input (inside the dialog) to a list item (inside the
// sibling menu overlay) — the `root.contains(next)` check above does not
// cover that case, so without this gate the focus trap would yank focus
// back to the dialog and immediately close the menu.
//
// We only skip the trap when ALL of the following hold:
// - the dialog is itself wrapped in a Vuetify overlay (v-dialog)
// - relatedTarget points into a `.v-overlay__content` (the menu's content
// wrapper) — anything else is treated as focus genuinely leaving the
// dialog and gets re-trapped
// - that overlay is a `.v-menu` (excludes nested v-dialogs and other
// overlay types where re-trapping is still desired)
// - the menu overlay is a sibling of the dialog overlay (same parent),
// confirming both were teleported to the same root and that the menu
// belongs to the same modal stack
// - the menu is currently visible (`display !== "none"` — Vuetify uses
// `v-show` to hide closed menus while keeping them mounted)
// - relatedTarget really is inside that menu's content
//
// History note: Vuetify 3.12.3 added an `onFocusout` handler to
// VAutocomplete/VSelect/VCombobox that flips `isFocused=false` whenever
// relatedTarget is outside the textfield, which closed long autocomplete
// menus on open (issue #5538, Vuetify PR fixing #22697). PhotoPrism is
// pinned to Vuetify 3.12.2 to avoid that regression — see
// `frontend/package.json` and `frontend/CODEMAP.md`. If the pin is ever
// lifted to >=3.12.3, this gate alone is NOT sufficient: that bug fires
// before the user ever interacts with the menu. Vuetify 3.12.2 itself has
// an unrelated upstream caveat (issue #22828, v-select @blur firing on
// open); PhotoPrism is not affected because we don't bind @blur to
// v-select anywhere.
const dialogOverlay = root.closest(".v-overlay");
const menuOverlayContent = next instanceof HTMLElement ? next.closest(".v-overlay__content") : null;
@ -737,7 +771,6 @@ export class View {
menuOverlay.style.display !== "none" &&
menuOverlayContent.contains(next)
) {
// Allow focus to move into sibling menu overlays (e.g., combobox suggestions)
return;
}
}

View file

@ -10,6 +10,7 @@
<v-list-item v-if="editingField === 'title' || model.Title || isEditable" class="metadata__item">
<v-text-field
v-if="editingField === 'title'"
:ref="setInlineEditorRef"
v-model="photo.Title"
:placeholder="$pgettext('Photo', 'Title')"
:rules="[textRule]"
@ -41,6 +42,7 @@
<v-list-item v-if="editingField === 'caption' || model.Caption || isEditable" class="metadata__item">
<v-textarea
v-if="editingField === 'caption'"
:ref="setInlineEditorRef"
v-model="photo.Caption"
:placeholder="$gettext('Caption')"
variant="plain"
@ -236,52 +238,44 @@
<template v-if="!restrictedRole && (editingField === 'labels' || labels.length > 0 || isEditable)">
<v-divider class="my-4"></v-divider>
<v-list-item class="metadata__item">
<v-list-item class="metadata__item meta-labels">
<div class="text-subtitle-2">{{ $gettext("Labels") }}</div>
<template v-if="isEditable" #append>
<v-icon
v-if="editingField === 'labels'"
icon="mdi-check"
size="small"
class="meta-inline-confirm"
@mousedown.prevent
@click.stop="confirmLabels"
></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startChipEditing('labels')"></v-icon>
<p-sidebar-inline-toolbar :editing="editingField === 'labels'" @confirm="confirmLabels" @start="startChipEditing('labels')" />
</template>
</v-list-item>
<v-list-item v-if="labels.length > 0 || editingField === 'labels'" class="metadata__item metadata__chips">
<v-list-item v-if="labels.length > 0 || chipState.labels.additions.length > 0" class="metadata__item metadata__chips meta-labels">
<div class="d-flex flex-wrap ga-1">
<span
v-for="l in labels"
:key="l.Label.UID"
class="meta-chip meta-chip--primary"
:class="{ 'meta-chip--pending-remove': isLabelPendingRemoval(l) }"
@click.stop.prevent="editingField !== 'labels' ? navigateToLabel(l.Label) : toggleLabelRemoval(l)"
:class="{ 'meta-chip--pending-remove': isChipPendingRemoval('labels', l.Label.ID) }"
@click.stop.prevent="editingField !== 'labels' ? navigateToLabel(l.Label) : togglePendingChipRemoval('labels', l.Label.ID)"
>
{{ l.Label.Name }}
<v-icon
v-if="editingField === 'labels'"
:icon="isLabelPendingRemoval(l) ? 'mdi-undo' : 'mdi-close-circle'"
:icon="isChipPendingRemoval('labels', l.Label.ID) ? 'mdi-undo' : 'mdi-close-circle'"
size="x-small"
class="ml-1"
></v-icon>
</span>
<span
v-for="name in pendingLabelAdditions"
v-for="name in chipState.labels.additions"
:key="'add-' + name"
class="meta-chip meta-chip--pending-add"
@click.stop.prevent="removePendingLabelAdd(name)"
@click.stop.prevent="removePendingChipAdd('labels', name)"
>
{{ name }}
<v-icon icon="mdi-close-circle" size="x-small" class="ml-1"></v-icon>
</span>
</div>
</v-list-item>
<v-list-item v-else-if="isEditable" class="metadata__item">
<v-list-item v-else-if="isEditable && editingField !== 'labels'" class="metadata__item meta-labels">
<div class="meta-add-prompt" @click.stop="startChipEditing('labels')">{{ $gettext("Add label") }}</div>
</v-list-item>
<v-list-item v-if="editingField === 'labels'" class="metadata__item">
<v-list-item v-if="editingField === 'labels'" class="metadata__item meta-labels">
<v-combobox
:key="chipKey"
v-model="chipInput"
@ -307,52 +301,44 @@
<template v-if="!restrictedRole && (editingField === 'albums' || albums.length > 0 || isEditable)">
<v-divider class="my-4"></v-divider>
<v-list-item class="metadata__item">
<v-list-item class="metadata__item meta-albums">
<div class="text-subtitle-2">{{ $gettext("Albums") }}</div>
<template v-if="isEditable" #append>
<v-icon
v-if="editingField === 'albums'"
icon="mdi-check"
size="small"
class="meta-inline-confirm"
@mousedown.prevent
@click.stop="confirmAlbums"
></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startChipEditing('albums')"></v-icon>
<p-sidebar-inline-toolbar :editing="editingField === 'albums'" @confirm="confirmAlbums" @start="startChipEditing('albums')" />
</template>
</v-list-item>
<v-list-item v-if="albums.length > 0 || editingField === 'albums'" class="metadata__item metadata__chips">
<v-list-item v-if="albums.length > 0 || chipState.albums.additions.length > 0" class="metadata__item metadata__chips meta-albums">
<div class="d-flex flex-wrap ga-1">
<span
v-for="a in albums"
:key="a.UID"
class="meta-chip meta-chip--primary"
:class="{ 'meta-chip--pending-remove': isAlbumPendingRemoval(a) }"
@click.stop.prevent="editingField !== 'albums' ? navigateToAlbum(a) : toggleAlbumRemoval(a)"
:class="{ 'meta-chip--pending-remove': isChipPendingRemoval('albums', a.UID) }"
@click.stop.prevent="editingField !== 'albums' ? navigateToAlbum(a) : togglePendingChipRemoval('albums', a.UID)"
>
{{ a.Title }}
<v-icon
v-if="editingField === 'albums'"
:icon="isAlbumPendingRemoval(a) ? 'mdi-undo' : 'mdi-close-circle'"
:icon="isChipPendingRemoval('albums', a.UID) ? 'mdi-undo' : 'mdi-close-circle'"
size="x-small"
class="ml-1"
></v-icon>
</span>
<span
v-for="a in pendingAlbumAdditions"
v-for="a in chipState.albums.additions"
:key="'add-' + a.UID"
class="meta-chip meta-chip--pending-add"
@click.stop.prevent="removePendingAlbumAdd(a)"
@click.stop.prevent="removePendingChipAdd('albums', a.UID)"
>
{{ a.Title }}
<v-icon icon="mdi-close-circle" size="x-small" class="ml-1"></v-icon>
</span>
</div>
</v-list-item>
<v-list-item v-else-if="isEditable" class="metadata__item">
<v-list-item v-else-if="isEditable && editingField !== 'albums'" class="metadata__item meta-albums">
<div class="meta-add-prompt" @click.stop="startChipEditing('albums')">{{ $gettext("Add to album") }}</div>
</v-list-item>
<v-list-item v-if="editingField === 'albums'" class="metadata__item">
<v-list-item v-if="editingField === 'albums'" class="metadata__item meta-albums">
<v-autocomplete
:key="chipKey"
v-model="chipInput"
@ -376,33 +362,22 @@
</v-list-item>
</template>
<template
v-if="
!restrictedRole &&
(editingField === 'subject' ||
editingField === 'artist' ||
editingField === 'copyright' ||
editingField === 'license' ||
subject ||
artist ||
copyright ||
license ||
isEditable)
"
>
<template v-if="showDetailsSection">
<v-divider class="my-4"></v-divider>
<!-- Subject -->
<v-list-item
v-if="editingField === 'subject' || subject || isEditable"
v-tooltip="$gettext('Subject')"
prepend-icon="mdi-text-box-outline"
v-for="f in detailsFields"
v-show="shouldShowFieldRow(f)"
:key="f.key"
v-tooltip="f.label"
:prepend-icon="f.icon"
class="metadata__item"
:class="`meta-${f.key}`"
>
<v-textarea
v-if="editingField === 'subject'"
v-model="photo.Details.Subject"
:placeholder="$gettext('Subject')"
v-if="editingField === f.key"
:ref="setInlineEditorRef"
:model-value="f.read(photo)"
:placeholder="f.label"
:rules="[textRule]"
variant="plain"
density="compact"
@ -410,202 +385,53 @@
hide-details="auto"
autocomplete="off"
class="meta-inline-edit"
:class="`meta-inline-${f.key}`"
@update:model-value="(v) => f.write(photo, v)"
@keydown.escape.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<div v-else-if="subject" class="text-body-2 meta-scrollable">{{ subject }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing('subject')">{{ $gettext("Subject") }}</div>
<div v-else-if="f.read(photo)" class="text-body-2 meta-scrollable">{{ f.read(photo) }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing(f.key)">{{ f.label }}</div>
<template v-if="isEditable" #append>
<v-icon
v-if="editingField === 'subject'"
icon="mdi-check"
size="small"
class="meta-inline-confirm"
@mousedown.prevent
@click.stop="confirmField"
></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startEditing('subject')"></v-icon>
</template>
</v-list-item>
<!-- Artist -->
<v-list-item
v-if="editingField === 'artist' || artist || isEditable"
v-tooltip="$gettext('Artist')"
prepend-icon="mdi-palette"
class="metadata__item"
>
<v-textarea
v-if="editingField === 'artist'"
v-model="photo.Details.Artist"
:placeholder="$gettext('Artist')"
:rules="[textRule]"
variant="plain"
density="compact"
auto-grow
hide-details="auto"
autocomplete="off"
class="meta-inline-edit"
@keydown.escape.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<div v-else-if="artist" class="text-body-2 meta-scrollable">{{ artist }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing('artist')">{{ $gettext("Artist") }}</div>
<template v-if="isEditable" #append>
<v-icon
v-if="editingField === 'artist'"
icon="mdi-check"
size="small"
class="meta-inline-confirm"
@mousedown.prevent
@click.stop="confirmField"
></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startEditing('artist')"></v-icon>
</template>
</v-list-item>
<!-- Copyright -->
<v-list-item
v-if="editingField === 'copyright' || copyright || isEditable"
v-tooltip="$gettext('Copyright')"
prepend-icon="mdi-copyright"
class="metadata__item"
>
<v-textarea
v-if="editingField === 'copyright'"
v-model="photo.Details.Copyright"
:placeholder="$gettext('Copyright')"
:rules="[textRule]"
variant="plain"
density="compact"
auto-grow
hide-details="auto"
autocomplete="off"
class="meta-inline-edit"
@keydown.escape.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<div v-else-if="copyright" class="text-body-2 meta-scrollable">{{ copyright }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing('copyright')">{{ $gettext("Copyright") }}</div>
<template v-if="isEditable" #append>
<v-icon
v-if="editingField === 'copyright'"
icon="mdi-check"
size="small"
class="meta-inline-confirm"
@mousedown.prevent
@click.stop="confirmField"
></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startEditing('copyright')"></v-icon>
</template>
</v-list-item>
<!-- License -->
<v-list-item
v-if="editingField === 'license' || license || isEditable"
v-tooltip="$gettext('License')"
prepend-icon="mdi-license"
class="metadata__item"
>
<v-textarea
v-if="editingField === 'license'"
v-model="photo.Details.License"
:placeholder="$gettext('License')"
:rules="[textRule]"
variant="plain"
density="compact"
auto-grow
hide-details="auto"
autocomplete="off"
class="meta-inline-edit"
@keydown.escape.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<div v-else-if="license" class="text-body-2 meta-scrollable">{{ license }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing('license')">{{ $gettext("License") }}</div>
<template v-if="isEditable" #append>
<v-icon
v-if="editingField === 'license'"
icon="mdi-check"
size="small"
class="meta-inline-confirm"
@mousedown.prevent
@click.stop="confirmField"
></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startEditing('license')"></v-icon>
<p-sidebar-inline-toolbar :editing="editingField === f.key" @confirm="confirmField" @start="startEditing(f.key)" />
</template>
</v-list-item>
</template>
<template v-if="!restrictedRole && (editingField === 'keywords' || keywords || isEditable)">
<v-divider class="my-4"></v-divider>
<v-list-item class="metadata__item">
<div class="text-subtitle-2">{{ $gettext("Keywords") }}</div>
<template v-if="isEditable" #append>
<v-icon
v-if="editingField === 'keywords'"
icon="mdi-check"
size="small"
class="meta-inline-confirm"
@mousedown.prevent
@click.stop="confirmField"
></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startEditing('keywords')"></v-icon>
</template>
</v-list-item>
<v-list-item class="metadata__item">
<v-textarea
v-if="editingField === 'keywords'"
v-model="photo.Details.Keywords"
:placeholder="$gettext('Keywords')"
variant="plain"
density="compact"
auto-grow
hide-details="auto"
autocomplete="off"
class="meta-inline-edit"
@keydown.escape.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<div v-else-if="keywords" class="text-body-2 meta-keywords meta-scrollable">{{ keywords }}</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing('keywords')">{{ $gettext("Keywords") }}</div>
</v-list-item>
</template>
<template v-if="!restrictedRole && (editingField === 'notes' || notesHtml || isEditable)">
<v-divider class="my-4"></v-divider>
<v-list-item class="metadata__item">
<div class="text-subtitle-2">{{ $gettext("Notes") }}</div>
<template v-if="isEditable" #append>
<v-icon
v-if="editingField === 'notes'"
icon="mdi-check"
size="small"
class="meta-inline-confirm"
@mousedown.prevent
@click.stop="confirmField"
></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="startEditing('notes')"></v-icon>
</template>
</v-list-item>
<v-list-item class="metadata__item">
<v-textarea
v-if="editingField === 'notes'"
v-model="photo.Details.Notes"
:placeholder="$gettext('Notes')"
variant="plain"
density="compact"
auto-grow
hide-details="auto"
autocomplete="off"
class="meta-inline-edit"
@keydown.escape.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<!-- eslint-disable-next-line vue/no-v-html -- notesHtml is encode-then-sanitized via $util.sanitizeHtml($util.encodeHTML(raw)); see notesHtml() computed -->
<div v-else-if="notesHtml" class="text-body-2 meta-notes meta-scrollable" v-html="notesHtml"></div>
<div v-else class="meta-add-prompt" @click.stop="startEditing('notes')">{{ $gettext("Notes") }}</div>
</v-list-item>
<template v-for="f in textFields" :key="f.key">
<template v-if="!restrictedRole && shouldShowFieldRow(f)">
<v-divider class="my-4"></v-divider>
<v-list-item class="metadata__item" :class="`meta-${f.key}`">
<div class="text-subtitle-2">{{ f.label }}</div>
<template v-if="isEditable" #append>
<p-sidebar-inline-toolbar :editing="editingField === f.key" @confirm="confirmField" @start="startEditing(f.key)" />
</template>
</v-list-item>
<v-list-item class="metadata__item" :class="`meta-${f.key}`">
<v-textarea
v-if="editingField === f.key"
:ref="setInlineEditorRef"
:model-value="f.read(photo)"
:placeholder="f.label"
variant="plain"
density="compact"
auto-grow
hide-details="auto"
autocomplete="off"
class="meta-inline-edit"
:class="`meta-inline-${f.key}`"
@update:model-value="(v) => f.write(photo, v)"
@keydown.escape.prevent="cancelEditing"
@blur="onInlineFieldBlur"
></v-textarea>
<!-- eslint-disable-next-line vue/no-v-html -- f.htmlValue references a sanitized computed (e.g. notesHtml) encode-then-sanitize via $util.sanitizeHtml($util.encodeHTML(raw)). -->
<div v-else-if="f.display === 'html' && fieldHtml(f)" class="text-body-2 meta-scrollable" :class="`meta-${f.key}`" v-html="fieldHtml(f)"></div>
<div v-else-if="f.display !== 'html' && f.read(photo)" class="text-body-2 meta-scrollable" :class="`meta-${f.key}`">
{{ f.read(photo) }}
</div>
<div v-else class="meta-add-prompt" @click.stop="startEditing(f.key)">{{ f.label }}</div>
</v-list-item>
</template>
</template>
</v-list>
</div>
@ -651,6 +477,7 @@ import PDateTimeDialog from "component/sidebar/datetime-dialog.vue";
import PCameraDialog from "component/sidebar/camera-dialog.vue";
import PLocationDialog from "component/location/dialog.vue";
import PConfirmDialog from "component/confirm/dialog.vue";
import PSidebarInlineToolbar from "component/sidebar/inline-toolbar.vue";
export default {
name: "PSidebarInfo",
@ -660,6 +487,7 @@ export default {
PCameraDialog,
PLocationDialog,
PConfirmDialog,
PSidebarInlineToolbar,
},
props: {
// UID of the photo currently shown in the parent lightbox. Drives the
@ -695,10 +523,14 @@ export default {
chipKey: 0,
labelOptions: [],
albumOptions: [],
pendingLabelRemovals: [],
pendingLabelAdditions: [],
pendingAlbumRemovals: [],
pendingAlbumAdditions: [],
// Pending chip mutations staged during edit mode. Labels are keyed by
// Label.ID for removals and by typed name for additions; albums are
// keyed by Album.UID for removals and stored as full album objects
// for additions (the title is read off the object at confirm time).
chipState: {
labels: { additions: [], removals: [] },
albums: { additions: [], removals: [] },
},
markerDrafts: {},
markerNameRule: (v) => !v || v.length <= this.$config.get("clip") || this.$gettext("Text too long"),
markerMenuProps: {
@ -828,6 +660,104 @@ export default {
keywords() {
return this.photo?.Details?.Keywords || "";
},
// Single source of truth for inline-text fields. Each entry knows how to
// read/write its raw value, what label to render (tooltip, placeholder,
// add-prompt), and whether the display branch should treat the value as
// sanitized HTML (Caption, Notes) or plain text (everything else).
// detailsFields/textFields below select subsets for the two visual layouts.
fieldRegistry() {
return {
title: {
key: "title",
label: this.$pgettext("Photo", "Title"),
read: (p) => p?.Title,
write: (p, v) => {
if (p) p.Title = v;
},
display: "text",
},
caption: {
key: "caption",
label: this.$gettext("Caption"),
read: (p) => p?.Caption,
write: (p, v) => {
if (p) p.Caption = v;
},
display: "html",
htmlValue: "captionHtml",
},
subject: {
key: "subject",
label: this.$gettext("Subject"),
icon: "mdi-text-box-outline",
read: (p) => p?.Details?.Subject,
write: (p, v) => {
if (p?.Details) p.Details.Subject = v;
},
display: "text",
},
artist: {
key: "artist",
label: this.$gettext("Artist"),
icon: "mdi-palette",
read: (p) => p?.Details?.Artist,
write: (p, v) => {
if (p?.Details) p.Details.Artist = v;
},
display: "text",
},
copyright: {
key: "copyright",
label: this.$gettext("Copyright"),
icon: "mdi-copyright",
read: (p) => p?.Details?.Copyright,
write: (p, v) => {
if (p?.Details) p.Details.Copyright = v;
},
display: "text",
},
license: {
key: "license",
label: this.$gettext("License"),
icon: "mdi-license",
read: (p) => p?.Details?.License,
write: (p, v) => {
if (p?.Details) p.Details.License = v;
},
display: "text",
},
keywords: {
key: "keywords",
label: this.$gettext("Keywords"),
read: (p) => p?.Details?.Keywords,
write: (p, v) => {
if (p?.Details) p.Details.Keywords = v;
},
display: "text",
},
notes: {
key: "notes",
label: this.$gettext("Notes"),
read: (p) => p?.Details?.Notes,
write: (p, v) => {
if (p?.Details) p.Details.Notes = v;
},
display: "html",
htmlValue: "notesHtml",
},
};
},
detailsFields() {
return ["subject", "artist", "copyright", "license"].map((k) => this.fieldRegistry[k]);
},
textFields() {
return ["keywords", "notes"].map((k) => this.fieldRegistry[k]);
},
showDetailsSection() {
if (this.restrictedRole) return false;
if (this.isEditable) return true;
return this.detailsFields.some((f) => Boolean(f.read(this.photo)));
},
placeName() {
if (!this.photo) return "";
return this.photo.locationInfo() || "";
@ -899,55 +829,34 @@ export default {
this.$emit("close");
},
getFieldValue(field) {
switch (field) {
case "title":
return this.photo.Title;
case "caption":
return this.photo.Caption;
case "subject":
return this.photo.Details.Subject;
case "artist":
return this.photo.Details.Artist;
case "copyright":
return this.photo.Details.Copyright;
case "license":
return this.photo.Details.License;
case "keywords":
return this.photo.Details.Keywords;
case "notes":
return this.photo.Details.Notes;
default:
return "";
}
const f = this.fieldRegistry[field];
if (!f) return "";
const v = f.read(this.photo);
return v == null ? "" : v;
},
setFieldValue(field, value) {
if (!this.view?.photo) return;
switch (field) {
case "title":
this.view.photo.Title = value;
break;
case "caption":
this.view.photo.Caption = value;
break;
case "subject":
this.view.photo.Details.Subject = value;
break;
case "artist":
this.view.photo.Details.Artist = value;
break;
case "copyright":
this.view.photo.Details.Copyright = value;
break;
case "license":
this.view.photo.Details.License = value;
break;
case "keywords":
this.view.photo.Details.Keywords = value;
break;
case "notes":
this.view.photo.Details.Notes = value;
break;
}
const f = this.fieldRegistry[field];
if (!f || !this.view?.photo) return;
f.write(this.view.photo, value);
},
// Function ref shared by every inline editor. Vue invokes it with the
// mounted component on mount and null on unmount; since each editor is
// gated by a unique `editingField === '<key>'`, only one is mounted at
// a time, so the latest non-null call always identifies the active one.
setInlineEditorRef(el) {
if (el) this._inlineEditorEl = el;
else if (!this.editingField) this._inlineEditorEl = null;
},
fieldHtml(f) {
if (!f || f.display !== "html" || !f.htmlValue) return "";
return this[f.htmlValue] || "";
},
shouldShowFieldRow(f) {
if (!f) return false;
if (this.editingField === f.key) return true;
if (this.isEditable) return true;
if (f.display === "html") return Boolean(this.fieldHtml(f));
return Boolean(f.read(this.photo));
},
startEditing(field) {
if (this.editingField) {
@ -959,8 +868,8 @@ export default {
this._editStartedAt = Date.now();
this.$nextTick(() => {
const input = this.$el.querySelector(".meta-inline-edit input, .meta-inline-edit textarea");
if (input) input.focus();
const editor = this._inlineEditorEl;
if (editor && typeof editor.focus === "function") editor.focus();
});
},
onToggleMarkersVisible() {
@ -1123,8 +1032,7 @@ export default {
if (!d) continue;
if (this.unwrapMarkerName(d.current).trim() !== (d.original || "").trim()) return true;
}
if (this.pendingLabelAdditions.length || this.pendingLabelRemovals.length) return true;
if (this.pendingAlbumAdditions.length || this.pendingAlbumRemovals.length) return true;
if (Object.values(this.chipState).some((s) => s.additions.length || s.removals.length)) return true;
return false;
},
// Async guard used by the lightbox before closing / hiding / navigating.
@ -1204,10 +1112,7 @@ export default {
this.editingField = null;
this.editOriginal = null;
this._editStartedAt = null;
this.pendingLabelRemovals = [];
this.pendingLabelAdditions = [];
this.pendingAlbumRemovals = [];
this.pendingAlbumAdditions = [];
this.resetChipState();
},
// Blur handler for inline text fields (title/caption/subject/artist/
// copyright/license/keywords/notes). Commits the edit instead of
@ -1288,6 +1193,36 @@ export default {
this.chipSearch = "";
this.chipKey++;
},
// Generic chip-state helpers. Field is "labels" or "albums"; the key is
// whatever uniquely identifies a chip in that field's domain (Label.ID
// for labels, Album.UID for albums on the removals side; the typed name
// for label additions, Album.UID for album additions).
isChipPendingRemoval(field, key) {
const state = this.chipState[field];
return Boolean(state && key != null && state.removals.includes(key));
},
togglePendingChipRemoval(field, key) {
const state = this.chipState[field];
if (!state || key == null) return;
const idx = state.removals.indexOf(key);
if (idx >= 0) {
state.removals.splice(idx, 1);
} else {
state.removals.push(key);
}
},
removePendingChipAdd(field, key) {
const state = this.chipState[field];
if (!state || key == null) return;
const idx = field === "labels" ? state.additions.indexOf(key) : state.additions.findIndex((a) => a.UID === key);
if (idx >= 0) state.additions.splice(idx, 1);
},
resetChipState() {
Object.values(this.chipState).forEach((s) => {
s.additions = [];
s.removals = [];
});
},
addPendingLabel(rawName) {
const name = (rawName || "").trim();
if (!name) return false;
@ -1297,14 +1232,15 @@ export default {
}
const norm = this.$util.normalizeLabelTitle(name);
if (!norm) return false;
if (this.pendingLabelAdditions.some((n) => this.$util.normalizeLabelTitle(n) === norm)) return false;
const additions = this.chipState.labels.additions;
if (additions.some((n) => this.$util.normalizeLabelTitle(n) === norm)) return false;
if (this.labels.some((l) => this.$util.normalizeLabelTitle(l?.Label?.Name) === norm)) return false;
this.pendingLabelAdditions.push(name);
additions.push(name);
return true;
},
albumTitleConflicts(norm) {
if (!norm) return true;
if (this.pendingAlbumAdditions.some((a) => this.$util.normalizeLabelTitle(a?.Title) === norm)) return true;
if (this.chipState.albums.additions.some((a) => this.$util.normalizeLabelTitle(a?.Title) === norm)) return true;
if (this.albums.some((a) => this.$util.normalizeLabelTitle(a?.Title) === norm)) return true;
return false;
},
@ -1316,12 +1252,13 @@ export default {
this.$notify.error(this.$gettext("Name too long"));
return false;
}
const additions = this.chipState.albums.additions;
if (album.UID) {
if (this.pendingAlbumAdditions.some((a) => a.UID === album.UID)) return false;
if (additions.some((a) => a.UID === album.UID)) return false;
if (this.albums.some((a) => a.UID === album.UID)) return false;
}
if (this.albumTitleConflicts(this.$util.normalizeLabelTitle(title))) return false;
this.pendingAlbumAdditions.push(album);
additions.push(album);
return true;
},
onLabelSelected(value) {
@ -1342,54 +1279,33 @@ export default {
this.clearChipInput();
}
},
removePendingLabelAdd(name) {
const idx = this.pendingLabelAdditions.indexOf(name);
if (idx >= 0) this.pendingLabelAdditions.splice(idx, 1);
},
isLabelPendingRemoval(label) {
return this.pendingLabelRemovals.includes(label.Label.ID);
},
toggleLabelRemoval(label) {
if (!label?.Label?.ID) return;
const id = label.Label.ID;
const idx = this.pendingLabelRemovals.indexOf(id);
if (idx >= 0) {
this.pendingLabelRemovals.splice(idx, 1);
} else {
this.pendingLabelRemovals.push(id);
}
},
confirmLabels() {
if (!this.photo) {
this.editingField = null;
return;
}
const removals = this.pendingLabelRemovals.slice();
const additions = this.pendingLabelAdditions.slice();
const state = this.chipState.labels;
const removals = state.removals.slice();
const additions = state.additions.slice();
this.editingField = null;
this.pendingLabelRemovals = [];
this.pendingLabelAdditions = [];
state.removals = [];
state.additions = [];
const promises = [];
removals.forEach((id) => promises.push(this.photo.removeLabel(id)));
additions.forEach((name) => promises.push(this.photo.addLabel(name)));
// Cache freshness: photo.addLabel / removeLabel patch this.photo.Labels
// locally on success, and the backend publishes photos.updated which
// evicts the cached entry via evictCachedFromEntities see
// model/photo.js. confirmAlbums needs an explicit evict + re-find
// because Album mutations go through raw $api.delete/post and don't
// patch this.photo.Albums; that asymmetry is intentional.
if (promises.length) {
Promise.all(promises).catch(() => {});
}
},
isAlbumPendingRemoval(album) {
return this.pendingAlbumRemovals.includes(album.UID);
},
toggleAlbumRemoval(album) {
if (!album?.UID) return;
const uid = album.UID;
const idx = this.pendingAlbumRemovals.indexOf(uid);
if (idx >= 0) {
this.pendingAlbumRemovals.splice(idx, 1);
} else {
this.pendingAlbumRemovals.push(uid);
Promise.all(promises).catch(() => {
this.$notify.error(this.$gettext("Failed to save changes"));
});
}
},
confirmAlbums() {
@ -1398,11 +1314,12 @@ export default {
return;
}
const removals = this.pendingAlbumRemovals.slice();
const additions = this.pendingAlbumAdditions.slice();
const state = this.chipState.albums;
const removals = state.removals.slice();
const additions = state.additions.slice();
this.editingField = null;
this.pendingAlbumRemovals = [];
this.pendingAlbumAdditions = [];
state.removals = [];
state.additions = [];
const promises = [];
removals.forEach((uid) => promises.push(this.$api.delete(`albums/${uid}/photos`, { data: { photos: [this.photo.UID] } })));
@ -1411,13 +1328,19 @@ export default {
if (promises.length) {
Promise.all(promises)
.then(() => {
// Album mutations don't patch this.photo.Albums locally and the
// backend publishes only albums.updated (not photos.updated) for
// membership changes, so we evict + re-find here so the sidebar
// reflects the saved state without waiting for navigation.
Photo.evictCache(this.photo.UID);
return this.photo.find(this.photo.UID);
})
.then((photo) => {
this.photo.setValues(photo.getValues());
})
.catch(() => {});
.catch(() => {
this.$notify.error(this.$gettext("Failed to save changes"));
});
}
},
onAlbumSelected(value) {
@ -1475,10 +1398,6 @@ export default {
this.clearChipInput();
});
},
removePendingAlbumAdd(album) {
const idx = this.pendingAlbumAdditions.findIndex((a) => a.UID === album.UID);
if (idx >= 0) this.pendingAlbumAdditions.splice(idx, 1);
},
confirmDateTime(data) {
this.dateTimeDialog = false;

View file

@ -0,0 +1,17 @@
<template>
<v-icon v-if="editing" icon="mdi-check" size="small" class="meta-inline-confirm" @mousedown.prevent @click.stop="$emit('confirm')"></v-icon>
<v-icon v-else icon="mdi-pencil-outline" size="small" class="meta-inline-pencil" @click.stop="$emit('start')"></v-icon>
</template>
<script>
export default {
name: "PSidebarInlineToolbar",
props: {
editing: {
type: Boolean,
default: false,
},
},
emits: ["confirm", "start"],
};
</script>

View file

@ -25,8 +25,7 @@ Additional information can be found in our Developer Guide:
// Deep-clones a plain object via JSON. Used at both ends of the cache
// lifecycle (set + hydrate) so callers can never share refs with cached
// values — see "isolation contract" in
// specs/frontend/model-lru-cache.md.
// values (cache isolation contract).
function deepClone(value) {
if (value === null || typeof value !== "object") {
return value;
@ -52,7 +51,7 @@ export class ModelCacheStaleFetchError extends Error {
// is model-layer infrastructure: a subclass (e.g. Photo) supplies snapshot
// and hydrate hooks so the cache can stay neutral about model shape.
//
// Contract (see specs/frontend/model-lru-cache.md):
// Contract:
// - Stores plain value snapshots, never live model instances.
// - Returns a fresh hydrated instance for every cache hit so callers
// can mutate freely without aliasing the cached source of truth.

View file

@ -1286,9 +1286,8 @@ export class Photo extends RestModel {
// Module-level Photo cache. Per-subclass scoping (rather than a shared
// static on Rest) keeps Photo's size budget and invalidation surface
// independent from other model caches — see
// specs/frontend/model-lru-cache.md. Snapshot via getValues so
// type coercion through getDefaults() is applied; hydrate by constructing
// independent from other model caches. Snapshot via getValues so type
// coercion through getDefaults() is applied; hydrate by constructing
// a fresh Photo from the cached values.
static _cache = new ModelCache({
max: 50,
@ -1323,8 +1322,7 @@ export class Photo extends RestModel {
// in-flight fetch whose epoch no longer matches REJECTS with
// ModelCacheStaleFetchError instead of resolving — so neither the
// cache nor a .then-chained UI assignment can leak role-A data into
// role B during the post-logout unmount window. See
// specs/frontend/model-lru-cache.md Decisions §5 for the design.
// role B during the post-logout unmount window.
static clearCache() {
Photo._cache.clear();
}

View file

@ -1112,10 +1112,10 @@ describe("PSidebarInfo component", () => {
global: { stubs: { PMap: true } },
});
expect(w.vm.hasPendingEdit()).toBe(false);
w.vm.pendingLabelAdditions = ["New Label"];
w.vm.chipState.labels.additions = ["New Label"];
expect(w.vm.hasPendingEdit()).toBe(true);
w.vm.pendingLabelAdditions = [];
w.vm.pendingLabelRemovals = [{ Label: { UID: "lbl1" } }];
w.vm.chipState.labels.additions = [];
w.vm.chipState.labels.removals = [{ Label: { UID: "lbl1" } }];
expect(w.vm.hasPendingEdit()).toBe(true);
});
@ -1125,10 +1125,10 @@ describe("PSidebarInfo component", () => {
global: { stubs: { PMap: true } },
});
expect(w.vm.hasPendingEdit()).toBe(false);
w.vm.pendingAlbumAdditions = [{ UID: "alb-new", Title: "New" }];
w.vm.chipState.albums.additions = [{ UID: "alb-new", Title: "New" }];
expect(w.vm.hasPendingEdit()).toBe(true);
w.vm.pendingAlbumAdditions = [];
w.vm.pendingAlbumRemovals = [{ UID: "alb1" }];
w.vm.chipState.albums.additions = [];
w.vm.chipState.albums.removals = [{ UID: "alb1" }];
expect(w.vm.hasPendingEdit()).toBe(true);
});
@ -1784,13 +1784,13 @@ describe("PSidebarInfo component", () => {
props: { modelValue: mockModel, photo: mockPhoto, canEdit: true, context: contexts.Photos },
global: { stubs: { PMap: true } },
});
const label = { Label: { ID: 1, UID: "lbl1", Name: "Nature" } };
const id = 1;
expect(w.vm.isLabelPendingRemoval(label)).toBe(false);
w.vm.toggleLabelRemoval(label);
expect(w.vm.isLabelPendingRemoval(label)).toBe(true);
w.vm.toggleLabelRemoval(label);
expect(w.vm.isLabelPendingRemoval(label)).toBe(false);
expect(w.vm.isChipPendingRemoval("labels", id)).toBe(false);
w.vm.togglePendingChipRemoval("labels", id);
expect(w.vm.isChipPendingRemoval("labels", id)).toBe(true);
w.vm.togglePendingChipRemoval("labels", id);
expect(w.vm.isChipPendingRemoval("labels", id)).toBe(false);
});
it("should add and remove pending label additions", () => {
@ -1798,11 +1798,11 @@ describe("PSidebarInfo component", () => {
props: { modelValue: mockModel, photo: mockPhoto, canEdit: true, context: contexts.Photos },
global: { stubs: { PMap: true } },
});
w.vm.pendingLabelAdditions.push("Sunset");
expect(w.vm.pendingLabelAdditions).toContain("Sunset");
w.vm.chipState.labels.additions.push("Sunset");
expect(w.vm.chipState.labels.additions).toContain("Sunset");
w.vm.removePendingLabelAdd("Sunset");
expect(w.vm.pendingLabelAdditions).not.toContain("Sunset");
w.vm.removePendingChipAdd("labels", "Sunset");
expect(w.vm.chipState.labels.additions).not.toContain("Sunset");
});
it("should ignore duplicate pending label additions via onLabelSelected", () => {
@ -1810,21 +1810,21 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.onLabelSelected({ Name: "Sunset", UID: "lbl-new" });
w.vm.onLabelSelected({ Name: "Sunset", UID: "lbl-new" });
expect(w.vm.pendingLabelAdditions).toHaveLength(1);
expect(w.vm.chipState.labels.additions).toHaveLength(1);
});
it("should ignore non-object values in onLabelSelected", () => {
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.onLabelSelected("string-value");
w.vm.onLabelSelected(null);
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
});
it("should skip labels already on the photo in onLabelSelected", () => {
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.editingField = "labels";
w.vm.onLabelSelected({ Name: "Nature", UID: "lbl1" });
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
});
// Label validation parity with batch edit + labels tab.
@ -1833,23 +1833,23 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.onLabelSelected({ Name: "cat" });
w.vm.onLabelSelected({ Name: "CAT" });
expect(w.vm.pendingLabelAdditions).toEqual(["cat"]);
expect(w.vm.chipState.labels.additions).toEqual(["cat"]);
});
it("should skip labels already on the photo case-insensitively in onLabelSelected", () => {
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.editingField = "labels";
w.vm.onLabelSelected({ Name: "nature" });
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
});
it("should dedupe pending label additions case-insensitively in onLabelEnter", () => {
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.editingField = "labels";
w.vm.pendingLabelAdditions.push("cat");
w.vm.chipState.labels.additions.push("cat");
w.vm.chipSearch = "CAT";
w.vm.onLabelEnter();
expect(w.vm.pendingLabelAdditions).toEqual(["cat"]);
expect(w.vm.chipState.labels.additions).toEqual(["cat"]);
});
it("should trim whitespace in onLabelEnter", () => {
@ -1857,7 +1857,7 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.chipSearch = " dog ";
w.vm.onLabelEnter();
expect(w.vm.pendingLabelAdditions).toEqual(["dog"]);
expect(w.vm.chipState.labels.additions).toEqual(["dog"]);
});
it("should silently reject empty or whitespace-only label input in onLabelEnter", () => {
@ -1865,7 +1865,7 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.chipSearch = " ";
w.vm.onLabelEnter();
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
expect(w.vm.$notify.error).not.toHaveBeenCalled();
});
@ -1874,7 +1874,7 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.chipSearch = "a".repeat(CLIP_LEN + 10);
w.vm.onLabelEnter();
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
expect(w.vm.$notify.error).toHaveBeenCalledWith("Name too long");
});
@ -1887,7 +1887,7 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.chipSearch = "cat";
w.vm.onLabelEnter();
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
});
it("should match existing labels through normalization (& vs and)", () => {
@ -1899,7 +1899,7 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.chipSearch = "rock and roll";
w.vm.onLabelEnter();
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
});
it("should silently reject punctuation-only label input", () => {
@ -1907,7 +1907,7 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.chipSearch = "!!!";
w.vm.onLabelEnter();
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
expect(w.vm.$notify.error).not.toHaveBeenCalled();
});
@ -1916,7 +1916,7 @@ describe("PSidebarInfo component", () => {
w.vm.editingField = "labels";
w.vm.chipSearch = "🌅";
w.vm.onLabelEnter();
expect(w.vm.pendingLabelAdditions).toEqual(["🌅"]);
expect(w.vm.chipState.labels.additions).toEqual(["🌅"]);
});
// Pending album operations
@ -1925,13 +1925,13 @@ describe("PSidebarInfo component", () => {
props: { modelValue: mockModel, photo: mockPhoto, canEdit: true, context: contexts.Photos },
global: { stubs: { PMap: true } },
});
const album = { UID: "alb1", Title: "Vacation 2023" };
const uid = "alb1";
expect(w.vm.isAlbumPendingRemoval(album)).toBe(false);
w.vm.toggleAlbumRemoval(album);
expect(w.vm.isAlbumPendingRemoval(album)).toBe(true);
w.vm.toggleAlbumRemoval(album);
expect(w.vm.isAlbumPendingRemoval(album)).toBe(false);
expect(w.vm.isChipPendingRemoval("albums", uid)).toBe(false);
w.vm.togglePendingChipRemoval("albums", uid);
expect(w.vm.isChipPendingRemoval("albums", uid)).toBe(true);
w.vm.togglePendingChipRemoval("albums", uid);
expect(w.vm.isChipPendingRemoval("albums", uid)).toBe(false);
});
it("should add and remove pending album additions", () => {
@ -1939,38 +1939,38 @@ describe("PSidebarInfo component", () => {
const album = { UID: "alb-new", Title: "New Album" };
w.vm.onAlbumSelected(album);
expect(w.vm.pendingAlbumAdditions).toHaveLength(1);
expect(w.vm.pendingAlbumAdditions[0].UID).toBe("alb-new");
expect(w.vm.chipState.albums.additions).toHaveLength(1);
expect(w.vm.chipState.albums.additions[0].UID).toBe("alb-new");
w.vm.removePendingAlbumAdd(album);
expect(w.vm.pendingAlbumAdditions).toHaveLength(0);
w.vm.removePendingChipAdd("albums", album.UID);
expect(w.vm.chipState.albums.additions).toHaveLength(0);
});
it("should ignore non-object values in onAlbumSelected", () => {
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.onAlbumSelected("string-value");
w.vm.onAlbumSelected(null);
expect(w.vm.pendingAlbumAdditions).toHaveLength(0);
expect(w.vm.chipState.albums.additions).toHaveLength(0);
});
it("should skip albums already on the photo in onAlbumSelected", () => {
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.onAlbumSelected({ UID: "alb1", Title: "Vacation 2023" });
expect(w.vm.pendingAlbumAdditions).toHaveLength(0);
expect(w.vm.chipState.albums.additions).toHaveLength(0);
});
// Album validation parity with batch edit + labels tab.
it("should dedupe albums by normalized title even when UIDs differ", () => {
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.onAlbumSelected({ UID: "alb-other", Title: "vacation 2023" });
expect(w.vm.pendingAlbumAdditions).toHaveLength(0);
expect(w.vm.chipState.albums.additions).toHaveLength(0);
});
it("should dedupe pending album additions by normalized title", () => {
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.pendingAlbumAdditions.push({ UID: "alb-a", Title: "Trip" });
w.vm.chipState.albums.additions.push({ UID: "alb-a", Title: "Trip" });
w.vm.onAlbumSelected({ UID: "alb-b", Title: "trip" });
expect(w.vm.pendingAlbumAdditions).toHaveLength(1);
expect(w.vm.chipState.albums.additions).toHaveLength(1);
});
it("should reject overlong album titles in onAlbumEnter and not call save", () => {
@ -1980,7 +1980,7 @@ describe("PSidebarInfo component", () => {
w.vm.chipSearch = "a".repeat(CLIP_LEN + 10);
w.vm.onAlbumEnter();
expect(saveSpy).not.toHaveBeenCalled();
expect(w.vm.pendingAlbumAdditions).toHaveLength(0);
expect(w.vm.chipState.albums.additions).toHaveLength(0);
expect(w.vm.$notify.error).toHaveBeenCalledWith("Name too long");
saveSpy.mockRestore();
});
@ -1992,7 +1992,7 @@ describe("PSidebarInfo component", () => {
w.vm.chipSearch = " ";
w.vm.onAlbumEnter();
expect(saveSpy).not.toHaveBeenCalled();
expect(w.vm.pendingAlbumAdditions).toHaveLength(0);
expect(w.vm.chipState.albums.additions).toHaveLength(0);
saveSpy.mockRestore();
});
@ -2003,7 +2003,7 @@ describe("PSidebarInfo component", () => {
w.vm.chipSearch = "VACATION 2023";
w.vm.onAlbumEnter();
expect(saveSpy).not.toHaveBeenCalled();
expect(w.vm.pendingAlbumAdditions).toHaveLength(0);
expect(w.vm.chipState.albums.additions).toHaveLength(0);
saveSpy.mockRestore();
});
@ -2011,11 +2011,11 @@ describe("PSidebarInfo component", () => {
const saveSpy = vi.spyOn(Album.prototype, "save").mockResolvedValue();
const w = mountInfoForChips({ modelValue: mockModel, photo: mockPhoto });
w.vm.editingField = "albums";
w.vm.pendingAlbumAdditions.push({ UID: "alb-pending", Title: "Trip" });
w.vm.chipState.albums.additions.push({ UID: "alb-pending", Title: "Trip" });
w.vm.chipSearch = "trip";
w.vm.onAlbumEnter();
expect(saveSpy).not.toHaveBeenCalled();
expect(w.vm.pendingAlbumAdditions).toHaveLength(1);
expect(w.vm.chipState.albums.additions).toHaveLength(1);
saveSpy.mockRestore();
});
@ -2031,8 +2031,8 @@ describe("PSidebarInfo component", () => {
w.vm.onAlbumEnter();
await new Promise((r) => setTimeout(r, 0));
expect(saveSpy).toHaveBeenCalledTimes(1);
expect(w.vm.pendingAlbumAdditions).toHaveLength(1);
expect(w.vm.pendingAlbumAdditions[0].Title).toBe("Brand New Trip");
expect(w.vm.chipState.albums.additions).toHaveLength(1);
expect(w.vm.chipState.albums.additions[0].Title).toBe("Brand New Trip");
expect(w.vm.albumOptions.some((a) => a.UID === "alb-created")).toBe(true);
saveSpy.mockRestore();
});
@ -2044,19 +2044,19 @@ describe("PSidebarInfo component", () => {
global: { stubs: { PMap: true } },
});
w.vm.editingField = "labels";
w.vm.pendingLabelRemovals = [1];
w.vm.pendingLabelAdditions = ["Sunset"];
w.vm.pendingAlbumRemovals = ["alb1"];
w.vm.pendingAlbumAdditions = [{ UID: "alb-new", Title: "New" }];
w.vm.chipState.labels.removals = [1];
w.vm.chipState.labels.additions = ["Sunset"];
w.vm.chipState.albums.removals = ["alb1"];
w.vm.chipState.albums.additions = [{ UID: "alb-new", Title: "New" }];
w.vm._editStartedAt = Date.now() - 300;
w.vm.cancelEditing();
expect(w.vm.editingField).toBeNull();
expect(w.vm.pendingLabelRemovals).toHaveLength(0);
expect(w.vm.pendingLabelAdditions).toHaveLength(0);
expect(w.vm.pendingAlbumRemovals).toHaveLength(0);
expect(w.vm.pendingAlbumAdditions).toHaveLength(0);
expect(w.vm.chipState.labels.removals).toHaveLength(0);
expect(w.vm.chipState.labels.additions).toHaveLength(0);
expect(w.vm.chipState.albums.removals).toHaveLength(0);
expect(w.vm.chipState.albums.additions).toHaveLength(0);
});
// Photo watcher: the parent lightbox owns the unsaved-changes guard, so

View file

@ -0,0 +1,81 @@
// Quiet jsdom's known false-positive CSS-parser warnings on
// Vuetify-flavored stylesheets, regardless of where the stylesheet
// is authored. Vuetify components, the Vuetify base styles, and
// PhotoPrism's own CSS all share the same surface (`.v-application`,
// `.v-overlay`, `--v-theme-*`, ...), and jsdom's parser rejects the
// modern rules they use (e.g. @layer, container queries, @scope) the
// same way in all of them. This module treats those warnings as a
// single category of noise and suppresses them across that surface.
//
// jsdom 29 emits a `jsdomError` of `type: "css-parsing"` whenever its
// CSS parser rejects a rule. The default
// `(new VirtualConsole()).forwardTo(console)` forwarder writes a bare
// "Could not parse CSS stylesheet" line per occurrence, with no
// origin, no offending text, and no parser stack — so it cannot point
// to a real regression even when one exists.
//
// This module replaces that default forwarder with one that:
// - Drops "css-parsing" errors whose stylesheet text contains any
// Vuetify-flavored marker (the surface jsdom is known to reject
// non-actionably). The check is heuristic and intentionally broad
// enough to cover Vuetify, Vuetify-derived components, and our
// own Vuetify-themed styles.
// - For every other "css-parsing" error (i.e. CSS that doesn't even
// touch the Vuetify surface), prints the message AND the
// underlying parser cause, so an unrelated regression surfaces
// with strictly more detail than the default forwarder produced.
// - Forwards non-CSS jsdomErrors using the same shape as jsdom 29's
// default forwardTo handler.
//
// Why this reaches into `window._virtualConsole`: Vitest does not
// expose a hook for the JSDOM virtualConsole. The documented config
// path (`environmentOptions.jsdom.virtualConsole`) is unusable under
// `pool: "vmForks"` because options are sent to the worker via
// structured clone and a VirtualConsole instance is not cloneable
// (its EventEmitter listeners are functions). Mutating
// `window._virtualConsole` after JSDOM initialization is a test-only
// workaround and is acknowledged here as such.
//
// MUST be imported before any module that loads CSS so the filter is
// installed when vitest injects stylesheets at import-evaluation time.
// Substrings whose presence in a stylesheet's text means the warning
// is from the Vuetify-flavored surface jsdom rejects non-actionably.
// Matching is by inclusion, not authorship — PhotoPrism CSS that
// extends Vuetify will (correctly) match.
const VUETIFY_FLAVORED_MARKERS = ["--v-theme-", "--v-medium-emphasis-opacity", ".v-application", ".v-locale-provider", ".v-overlay"];
function hasVuetifyFlavoredMarker(text) {
if (typeof text !== "string" || text.length === 0) {
return false;
}
for (const marker of VUETIFY_FLAVORED_MARKERS) {
if (text.includes(marker)) {
return true;
}
}
return false;
}
if (typeof window !== "undefined" && window._virtualConsole) {
const vc = window._virtualConsole;
vc.removeAllListeners("jsdomError");
vc.on("jsdomError", (err) => {
if (!err) {
return;
}
if (err.type === "css-parsing") {
if (hasVuetifyFlavoredMarker(err.sheetText)) {
return;
}
const cause = err.cause && err.cause.stack ? `\n${err.cause.stack}` : "";
console.error(`${err.message}${cause}`);
return;
}
if (err.type === "unhandled-exception" && err.cause) {
console.error(err.cause.stack);
return;
}
console.error(err.message);
});
}

View file

@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Imported for its side effect: installs the jsdomError filter on
// window._virtualConsole. Already loaded transitively by setup.js, but
// we re-import here for clarity.
import "./jsdom-quiet";
describe("helpers/jsdom-quiet", () => {
let errorSpy;
beforeEach(() => {
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
});
function emit(error) {
window._virtualConsole.emit("jsdomError", error);
}
function makeError({ type, message, sheetText, cause } = {}) {
const e = new Error(message ?? "boom");
if (type !== undefined) e.type = type;
if (sheetText !== undefined) e.sheetText = sheetText;
if (cause !== undefined) e.cause = cause;
return e;
}
it("drops css-parsing errors when the stylesheet contains Vuetify-flavored markers", () => {
// Stylesheets on the Vuetify surface — whether shipped by Vuetify
// itself or authored in PhotoPrism — are recognized by their
// selectors / custom properties and treated as the same noise.
emit(
makeError({
type: "css-parsing",
message: "Could not parse CSS stylesheet",
sheetText: ".v-application { --v-theme-primary: 0,0,0; }",
cause: new Error("rule parse failed"),
})
);
expect(errorSpy).not.toHaveBeenCalled();
});
it("logs css-parsing errors when the stylesheet has no Vuetify markers, including the underlying cause", () => {
const cause = new Error("rule parse failed");
emit(
makeError({
type: "css-parsing",
message: "Could not parse CSS stylesheet",
sheetText: ".photoprism-foo { color: red; }",
cause,
})
);
expect(errorSpy).toHaveBeenCalledTimes(1);
const arg = errorSpy.mock.calls[0][0];
expect(arg).toContain("Could not parse CSS stylesheet");
expect(arg).toContain(cause.stack);
});
it("logs unhandled-exception jsdomErrors via the cause stack", () => {
const cause = new Error("inner");
emit(makeError({ type: "unhandled-exception", cause }));
expect(errorSpy).toHaveBeenCalledWith(cause.stack);
});
it("logs other jsdomErrors via err.message", () => {
emit(makeError({ message: "something else" }));
expect(errorSpy).toHaveBeenCalledWith("something else");
});
});

View file

@ -1,3 +1,8 @@
// MUST be the first import: filters jsdom's known false-positive
// "Could not parse CSS stylesheet" warnings on Vuetify-flavored
// stylesheets before any subsequent import injects CSS.
import "./helpers/jsdom-quiet";
import { afterEach, vi } from "vitest";
import "@testing-library/jest-dom";
import { config } from "@vue/test-utils";

8
go.mod
View file

@ -77,7 +77,7 @@ require (
github.com/IGLOU-EU/go-wildcard v1.0.3
github.com/davidbyttow/govips/v2 v2.18.0
github.com/go-co-op/gocron/v2 v2.21.1
github.com/go-sql-driver/mysql v1.9.3
github.com/go-sql-driver/mysql v1.10.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/klauspost/compress v1.18.6
github.com/pquerna/otp v1.5.0
@ -88,7 +88,7 @@ require (
github.com/ugjka/go-tz/v2 v2.2.8
github.com/urfave/cli/v2 v2.27.7
github.com/wamuir/graft v0.10.0
github.com/yalue/onnxruntime_go v1.28.0
github.com/yalue/onnxruntime_go v1.30.0
github.com/zitadel/oidc/v3 v3.47.5
golang.org/x/mod v0.35.0
golang.org/x/sys v0.43.0
@ -133,7 +133,7 @@ require (
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@ -143,7 +143,7 @@ require (
github.com/mandykoh/go-parallel v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/modelcontextprotocol/go-sdk v1.6.0
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect

16
go.sum
View file

@ -194,8 +194,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
@ -239,8 +239,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/open-location-code/go v0.0.0-20250620134813-83986da0156b h1:MQ/kiBq8Vl8huvJFEBZGDURueIzCLwqB9g5EfrRQYes=
github.com/google/open-location-code/go v0.0.0-20250620134813-83986da0156b/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
@ -339,8 +339,8 @@ github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhg
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -455,8 +455,8 @@ github.com/wamuir/graft v0.10.0 h1:HSpBUvm7O+jwsRIuDQlw80xW4xMXRFkOiVLtWaZCU2s=
github.com/wamuir/graft v0.10.0/go.mod h1:k6NJX3fCM/xzh5NtHky9USdgHTcz2vAvHp4c23I6UK4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yalue/onnxruntime_go v1.28.0 h1:ximEqgLtBhb3DY0IHyR0GWGpGJ+xef85qxgPwa/iotg=
github.com/yalue/onnxruntime_go v1.28.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yalue/onnxruntime_go v1.30.0 h1:VAZyXhTu0mUkq+hXwVp/flc7sndhSxz9787lZvBUVm0=
github.com/yalue/onnxruntime_go v1.30.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

View file

@ -56,7 +56,6 @@ The API package exposes PhotoPrisms HTTP endpoints via Gin handlers. Each fil
status.Error(err),
}, refID)
```
- See `specs/common/audit-logs.md` for the full conventions and additional examples that agents should follow.
### Swagger Documentation

View file

@ -72,11 +72,15 @@ func ServeMCP(router *gin.RouterGroup) {
// McpSessionTimeout bounds how long idle sessions linger; active
// clients renew the timer on every request, so interactive IDE use
// is unaffected while abandoned sessions free up promptly.
// CrossOriginProtection must be set explicitly: go-sdk v1.6.0 dropped
// the implicit default that previously rejected cross-origin requests
// when the field was nil (modelcontextprotocol/go-sdk#906).
handler := sdkmcp.NewStreamableHTTPHandler(
func(r *http.Request) *sdkmcp.Server { return mcpServer },
&sdkmcp.StreamableHTTPOptions{
SessionTimeout: McpSessionTimeout,
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})),
SessionTimeout: McpSessionTimeout,
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})),
CrossOriginProtection: &http.CrossOriginProtection{},
},
)

View file

@ -312,7 +312,7 @@ func NewIsolatedTestConfig(dbName, dataPath string, createDirs bool) *Config {
}
// NewTestConfig initializes test data so required directories exist before tests run.
// See AGENTS.md (Test Data & Fixtures) and specs/dev/backend-testing.md for guidance.
// See AGENTS.md (Test Data & Fixtures) for guidance.
func NewTestConfig(dbName string) *Config {
defer log.Debug(capture.Time(time.Now(), "config: new test config created"))

View file

@ -11,6 +11,8 @@ import (
// activeLabelByExactName finds an active label by exact name, falling back to a
// same-name slug match when only the slug lookup is stable across collations.
// A slug-only hit is also accepted when the candidate was renamed away from
// the queried name (see acceptLabelSlugMatch).
func activeLabelByExactName(name string) *Label {
name = normalizeLabelName(name)
@ -24,7 +26,7 @@ func activeLabelByExactName(name string) *Label {
return result
}
if candidate := activeLabelBySlugValue(txt.Slug(name)); candidate != nil && sameLabelName(candidate.LabelName, name) {
if candidate := activeLabelBySlugValue(txt.Slug(name)); acceptLabelSlugMatch(candidate, name) {
return candidate
}

View file

@ -7,6 +7,44 @@ import (
"github.com/stretchr/testify/require"
)
func TestActiveLabelByExactName(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.Nil(t, activeLabelByExactName(""))
})
t.Run("ResolvesRenamedLabelByPreviousName", func(t *testing.T) {
original := FirstOrCreateLabel(NewLabel("RenameAxnA", 0))
require.NotNil(t, original)
t.Cleanup(func() {
_ = Db().Unscoped().Delete(original).Error
FlushLabelCache()
})
require.True(t, original.SetName("RenameAxnB"))
require.NoError(t, Db().Save(original).Error)
FlushLabelCache()
got := activeLabelByExactName("RenameAxnA")
require.NotNil(t, got)
assert.Equal(t, original.ID, got.ID)
assert.Equal(t, "RenameAxnB", got.LabelName)
})
t.Run("DoesNotMergeHomophones", func(t *testing.T) {
first := FirstOrCreateLabel(NewLabel("问", 0))
require.NotNil(t, first)
t.Cleanup(func() {
_ = Db().Unscoped().Delete(first).Error
FlushLabelCache()
})
// "吻" slugifies to the same value as "问" but does not yet exist;
// activeLabelByExactName must not return the existing "问" row for it.
got := activeLabelByExactName("吻")
assert.Nil(t, got)
})
}
func TestLabelSlugs(t *testing.T) {
t.Run("PipeSeparated", func(t *testing.T) {
assert.Equal(t, []string{"cake", "flower"}, LabelSlugs("cake|flower", "|"))
@ -45,6 +83,29 @@ func TestFindLabels(t *testing.T) {
assert.Equal(t, second.ID, labels[0].ID)
assert.Equal(t, "吻", labels[0].LabelName)
})
t.Run("ResolvesRenamedLabelByPreviousNameEndToEnd", func(t *testing.T) {
// Integration check: searching by an old name yields the renamed
// row. FindLabels has multiple slug fallbacks of its own; the
// activeLabelByExactName branch is pinned directly by
// TestActiveLabelByExactName/ResolvesRenamedLabelByPreviousName.
original := FirstOrCreateLabel(NewLabel("RenameDog", 0))
require.NotNil(t, original)
t.Cleanup(func() {
_ = Db().Unscoped().Delete(original).Error
FlushLabelCache()
})
require.True(t, original.SetName("RenameHund"))
require.NoError(t, Db().Save(original).Error)
FlushLabelCache()
labels, err := FindLabels("RenameDog", " ")
require.NoError(t, err)
require.Len(t, labels, 1)
assert.Equal(t, original.ID, labels[0].ID)
assert.Equal(t, "RenameHund", labels[0].LabelName)
})
t.Run("PreservesPipeSeparatorSemantics", func(t *testing.T) {
labels, err := FindLabels("potato|couch", "|")
assert.Error(t, err)

View file

@ -38,7 +38,7 @@ func findLabelByExactName(name string) *Label {
result := &Label{}
if err := UnscopedDb().Where("label_name = ?", name).First(result).Error; err != nil {
if candidate := findLabelBySlugValue(txt.Slug(name), 0); candidate != nil && sameLabelName(candidate.LabelName, name) {
if candidate := findLabelBySlugValue(txt.Slug(name), 0); candidate != nil && acceptLabelSlugMatch(candidate, name) {
return candidate
}
@ -48,6 +48,32 @@ func findLabelByExactName(name string) *Label {
return result
}
// acceptLabelSlugMatch reports whether a slug-based hit should resolve to the
// queried name. It accepts the match when the canonical names also agree
// (homophone-safe), or when the candidate was renamed away from the queried
// name: its immutable LabelSlug still records the old slug while CustomSlug
// has moved to the slug of the current name. The rename branch deliberately
// excludes the case where LabelSlug equals CustomSlug, which is how the
// first-created member of a homophone pair is stored — the second homophone
// always carries a hashed LabelSlug, so it cannot trigger this branch.
func acceptLabelSlugMatch(candidate *Label, name string) bool {
if candidate == nil {
return false
}
if sameLabelName(candidate.LabelName, name) {
return true
}
queriedSlug := txt.Slug(name)
if queriedSlug == "" {
return false
}
return candidate.LabelSlug == queriedSlug && candidate.CustomSlug != queriedSlug
}
// findLabelBySlugValue looks up a label by label or custom slug, including soft-deleted rows.
func findLabelBySlugValue(slugValue string, excludeID uint) *Label {
if slugValue == "" {

View file

@ -0,0 +1,35 @@
package entity
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAcceptLabelSlugMatch(t *testing.T) {
t.Run("Nil", func(t *testing.T) {
assert.False(t, acceptLabelSlugMatch(nil, "Cat"))
})
t.Run("EmptyQuery", func(t *testing.T) {
candidate := &Label{LabelName: "Cat", LabelSlug: "cat", CustomSlug: "cat"}
assert.False(t, acceptLabelSlugMatch(candidate, ""))
})
t.Run("SameName", func(t *testing.T) {
candidate := &Label{LabelName: "Cat", LabelSlug: "cat", CustomSlug: "cat"}
assert.True(t, acceptLabelSlugMatch(candidate, "cat"))
})
t.Run("RenamedAwayFromQueriedName", func(t *testing.T) {
candidate := &Label{LabelName: "Katze", LabelSlug: "cat", CustomSlug: "katze"}
assert.True(t, acceptLabelSlugMatch(candidate, "Cat"))
})
t.Run("HomophoneNotRenamed", func(t *testing.T) {
// First-created homophone has LabelSlug == CustomSlug; a query for
// a different name that slugifies the same must not be accepted.
candidate := &Label{LabelName: "问", LabelSlug: "wen", CustomSlug: "wen"}
assert.False(t, acceptLabelSlugMatch(candidate, "吻"))
})
t.Run("DifferentNameAndDifferentSlug", func(t *testing.T) {
candidate := &Label{LabelName: "Other", LabelSlug: "other", CustomSlug: "other"}
assert.False(t, acceptLabelSlugMatch(candidate, "Cat"))
})
}

View file

@ -220,6 +220,33 @@ func TestFirstOrCreateLabel(t *testing.T) {
assert.Equal(t, first.LabelSlug, first.CustomSlug)
assert.Equal(t, second.LabelSlug, second.CustomSlug)
})
t.Run("ReusesRenamedLabelByPreviousName", func(t *testing.T) {
original := FirstOrCreateLabel(NewLabel("RenameCat", 0))
require.NotNil(t, original)
t.Cleanup(func() {
_ = Db().Unscoped().Delete(original).Error
FlushLabelCache()
})
require.True(t, original.SetName("RenameKatze"))
require.NoError(t, Db().Save(original).Error)
FlushLabelCache()
assert.Equal(t, "renamecat", original.LabelSlug)
assert.Equal(t, "renamekatze", original.CustomSlug)
again := FirstOrCreateLabel(NewLabel("RenameCat", 0))
require.NotNil(t, again)
assert.Equal(t, original.ID, again.ID)
assert.Equal(t, "RenameKatze", again.LabelName)
var rows []Label
require.NoError(t, UnscopedDb().
Where("label_slug LIKE ? OR custom_slug LIKE ?", "renamecat%", "renamecat%").
Find(&rows).Error)
assert.Len(t, rows, 1, "rename + re-add must not create a duplicate")
})
}
func TestLabel_UpdateClassify(t *testing.T) {

View file

@ -701,6 +701,41 @@ func TestPhoto_AddLabels(t *testing.T) {
}
assert.Equal(t, labelCountBefore, labelCountAfter)
})
t.Run("ReusesRenamedLabelFromClassifier", func(t *testing.T) {
// Reproduces the AI ingestion path from issue #5531: a classifier
// re-emitting the previous name of a renamed label must reuse the
// existing row instead of creating a duplicate.
original := FirstOrCreateLabel(NewLabel("RenameClassifyA", 0))
require.NotNil(t, original)
t.Cleanup(func() {
_ = Db().Unscoped().Delete(original).Error
FlushLabelCache()
})
require.True(t, original.SetName("RenameClassifyB"))
require.NoError(t, Db().Save(original).Error)
FlushLabelCache()
var before int
require.NoError(t, UnscopedDb().Model(&Label{}).
Where("label_slug LIKE ? OR custom_slug LIKE ?", "renameclassify%", "renameclassify%").
Count(&before).Error)
assert.Equal(t, 1, before)
photo := PhotoFixtures.Get("19800101_000002_D640C559")
photo.AddLabels(classify.Labels{{Name: "RenameClassifyA", Uncertainty: 30, Source: SrcImage, Priority: 0}})
var after int
require.NoError(t, UnscopedDb().Model(&Label{}).
Where("label_slug LIKE ? OR custom_slug LIKE ?", "renameclassify%", "renameclassify%").
Count(&after).Error)
assert.Equal(t, 1, after, "classifier with old name must not create a duplicate label")
joined, err := FindPhotoLabel(photo.ID, original.ID, false)
require.NoError(t, err)
assert.Equal(t, original.ID, joined.LabelID)
})
t.Run("SkipZeroProbability", func(t *testing.T) {
photo := PhotoFixtures.Get("Photo15")
initialLen := len(photo.Labels)

View file

@ -1,8 +1,6 @@
## PhotoPrism MCP Server
**Last Updated:** April 20, 2026
> See `specs/platform/mcp.md` for the canonical specification, including the rationale for the user-access policy and the role/grant matrix per edition.
**Last Updated:** May 5, 2026
### Current Capabilities
@ -242,6 +240,6 @@ To revoke access without disabling the user account, the administrator runs:
./photoprism clients remove <client-id>
```
> **Heads up:** `photoprism auth add --scope mcp <username>` creates an *app password* tied to a user account, but it currently does **not** grant MCP access — `RoleUser` is not in the `ResourceMCP` ACL. Use `photoprism clients add` for MCP integrations until that policy changes. The reasoning is documented in `specs/platform/mcp.md` under *User Access Model* (deliberate hold, not an oversight).
> **Heads up:** `photoprism auth add --scope mcp <username>` creates an *app password* tied to a user account, but it currently does **not** grant MCP access — `RoleUser` is not in the `ResourceMCP` ACL. Use `photoprism clients add` for MCP integrations until that policy changes. This is a deliberate hold, not an oversight.
When MCP eventually grows tools that need user-scoped data (e.g. "list my albums"), the team will revisit the policy and likely add `RoleUser → GrantSearchAll` so the app-password path lights up. Until then, every MCP integration is an admin-provisioned client token tied to a named application.

View file

@ -45,7 +45,7 @@
### Configuration & Safety Notes
- Compression: configured via `PHOTOPRISM_HTTP_COMPRESSION` / `--http-compression` as a comma-separated preference list. Supported tokens are `zstd`, `gzip`, and `none` (empty value also disables compression). The default ships as `zstd,gzip` so capable clients receive zstd while everyone else falls back to gzip; unknown tokens are ignored with a startup warning. See [`specs/platform/http-compression.md`](../../specs/platform/http-compression.md) for the negotiation rules and exclusion list.
- Compression: configured via `PHOTOPRISM_HTTP_COMPRESSION` / `--http-compression` as a comma-separated preference list. Supported tokens are `zstd`, `gzip`, and `none` (empty value also disables compression). The default ships as `zstd,gzip` so capable clients receive zstd while everyone else falls back to gzip; unknown tokens are ignored with a startup warning.
- Bundled frontend assets under `/static/*` are served with precompressed `.zst` / `.gz` siblings produced at build time by `frontend/scripts/precompress.js` (the npm `postbuild` hook for `make build-js`), selected via `PrecompressedStatic` in `static_precompressed.go`. Custom static assets under `/c/static/*` go through the same handler so extensions and operators *may* ship precompressed siblings alongside their files; without siblings the route serves identity. The runtime middleware bypasses both routes so it never re-encodes an already-encoded body and so `PHOTOPRISM_HTTP_COMPRESSION=none` consistently disables every encoded code path on these routes.
- Trusted proxies/platform headers are read from config; keep the list tight.
- If no trusted proxy ranges are configured (or the configured ranges are invalid), proxy trust is disabled and client IP resolution falls back to the TCP peer address.

4541
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,6 @@
"name": "photoprism",
"private": true,
"overrides": {
"minimatch@~3.0": "^3.1.3",
"serialize-javascript": "^7.0.5"
},
"workspaces": [

View file

@ -78,8 +78,17 @@ rm -rf "/tmp/libheif"
echo "Cloning git repository..."
git clone -c advice.detachedHead=false -b "$LIBHEIF_VERSION" --depth 1 https://github.com/strukturag/libheif.git libheif
cd libheif || exit
(mkdir build && cd build && cmake --preset=release ..)
make -C build
EXTRA_CMAKE=()
if [[ $VERSION_CODENAME == "jammy" ]]; then
# Ubuntu 22.04 (Jammy) ships libdav1d 0.9.x, which predates the Dav1dSettings::n_threads
# field that libheif 1.21+ writes. Skip the dav1d plugin on Jammy; AV1 decode still works
# via the aomdec plugin.
EXTRA_CMAKE+=("-DWITH_DAV1D=OFF")
fi
(mkdir build && cd build && cmake --preset=release "${EXTRA_CMAKE[@]}" ..) || exit 1
make -C build || exit 1
# Install heif-convert, heif-enc, heif-info, and heif-thumbnailer in "/usr/local".
echo "Installing binaries..."

View file

@ -4,12 +4,52 @@
# - AMD64: Google Chrome stable (apt repo at dl.google.com).
# - ARM64: Chromium (native apt package; no snap).
# - Debian hosts: native apt chromium.
# - Ubuntu hosts: Debian bookworm chromium, since Ubuntu's chromium-browser
# is only a snap-shim transitional package and unusable in Docker.
# Bookworm's userspace lib requirements (libjpeg62-turbo, libopenh264-7,
# libminizip1, ...) are close enough to Ubuntu LTS that apt can resolve
# them by pulling those few libs from the Debian repo; the chromium
# binary itself runs fine on Ubuntu glibc from jammy (22.04, 2.35) up.
# - Ubuntu hosts: chromium from PhotoPrism's internal mirror at
# https://dl.photoprism.app/dist/chromium/. The mirror is a small
# reprepro tree of the XtraDeb PPA's chromium / chromium-driver /
# chromium-sandbox .debs (BSD-1-Clause); we mirror them so CI builds
# survive Launchpad outages. Ubuntu's own chromium-browser is a
# snap-shim transitional package and unusable in Docker.
# Upstream PPA: https://launchpad.net/~xtradeb/+archive/ubuntu/apps
#
# Override the ARM64 Ubuntu chromium source via env var:
# CHROMIUM_SOURCE=internal (default) — https://dl.photoprism.app/dist/chromium/
# CHROMIUM_SOURCE=xtradeb — https://ppa.launchpadcontent.net/xtradeb/apps/ubuntu
# Use 'xtradeb' only when our mirror has fallen behind a chromium update
# (e.g. CVE patch landed at XtraDeb but cron hasn't refreshed yet).
#
# Distribution package set installed (named explicitly on the apt-get install
# line so a missing piece fails fast and visibly):
# - chromium — the browser binary
# - chromium-common — shared resources (strict-version Depends of chromium)
# - chromium-driver — chromedriver for headless / Selenium / TestCafe
# - chromium-sandbox — setuid sandbox helper
#
# Runtime library dependencies (transitively pulled in by apt). The exact
# package names drift between Ubuntu releases (jammy/noble/questing/resolute);
# the categories below stay stable. Confirm against
# `dpkg-deb -f chromium_*.deb Depends`
# on the relevant codename before bumping a base image.
# - GTK / GUI: libgtk-3-0[t64], libxnvctrl0, libxrandr2, libxkbcommon0,
# libxcomposite1, libxdamage1, libxfixes3, libxext6,
# libxcb1, libx11-6, libpango-1.0-0, libcairo2
# - Accessibility: libatk1.0-0[t64], libatk-bridge2.0-0[t64], libatspi2.0-0[t64]
# - GPU / EGL: libgbm1 (>= 21.1.0), mesa-libgallium (transitive)
# - Crypto / TLS: libnss3 (>= 2:3.30), libnspr4
# - Audio: libasound2[t64], libpulse0, libopus0, libflac8|12|14
# - Codecs / images: libdav1d5|7, libopenh264-6|7|8, libopenjp2-7,
# liblcms2-2, libjpeg8, libfreetype6
# - Compression: libzstd1 (>= 1.4 / 1.5.5), zlib1g, libminizip1[t64]
# - DBus / udev: libdbus-1-3, libudev1
# - System: libc6, libgcc-s1, libcups2[t64], libfontconfig1,
# libdouble-conversion3, libexpat1, libgraphite2-3,
# libglib2.0-0[t64], libharfbuzz0b, libharfbuzz-subset0
#
# In our build images these come from the standard Ubuntu archive; the slim
# variants of `photoprism/develop:*` deliberately exclude many of them, which
# is why chromium installs there fail with "libbsd0:arm64 is selected for
# removal" or similar dep-resolver errors. Install chromium only into the
# full (non-slim) develop images.
#
# This script must run as root. Use one of these invocations:
#
@ -46,41 +86,48 @@ DESTARCH=${BUILD_ARCH:-$SYSTEM_ARCH}
# shellcheck source=/dev/null
. /etc/os-release
# Adds the Debian bookworm apt source and installs chromium from it.
install_chromium_from_debian_bookworm() {
local keyring=/etc/apt/keyrings/debian-archive-bookworm.gpg
local src=/etc/apt/sources.list.d/debian-bookworm-chromium.sources
local pin=/etc/apt/preferences.d/debian-bookworm-chromium.pref
# Adds PhotoPrism's internal chromium mirror as an apt source and installs
# chromium from it. Mirror is rebuilt from XtraDeb upstream weekly via
# `make -C services/downloads chromium` on web2 (services/downloads/scripts/).
install_chromium_from_internal_mirror() {
local keyring=/etc/apt/keyrings/photoprism-apt.gpg
local src=/etc/apt/sources.list.d/photoprism-chromium.sources
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://ftp-master.debian.org/keys/archive-key-12.asc \
# PhotoPrism APT signing key (master): 99F2 9643 6E34 A5DD 1782 A5C9 7FE2 4EBF 235B EBF9
curl -fsSL "https://dl.photoprism.app/dist/chromium/photoprism-apt.gpg.asc" \
| gpg --no-tty --batch --yes --dearmor -o "$keyring"
cat > "$src" <<EOF
Types: deb
URIs: http://deb.debian.org/debian
Suites: bookworm
URIs: https://dl.photoprism.app/dist/chromium
Suites: ${VERSION_CODENAME}
Components: main
Signed-By: ${keyring}
EOF
# Pin Debian bookworm to a low priority so apt only pulls Bookworm packages
# that have no Ubuntu equivalent. Without this, apt's solver "upgrades"
# unrelated Ubuntu packages such as libjpeg-dev to Bookworm's higher epoch
# version (1:2.1.5-2 vs Ubuntu's 8c-*ubuntu*), which then pulls
# libjpeg62-turbo-dev and collides with Ubuntu's already-installed
# libjpeg-turbo8-dev (both ship /usr/include/<triplet>/jconfig.h).
# The chromium-* packages are elevated to priority 990 so they still install
# from Bookworm; their runtime dep on libjpeg62-turbo (no Ubuntu equivalent)
# is resolved as the only available candidate, which is harmless.
cat > "$pin" <<EOF
Package: *
Pin: release o=Debian,n=bookworm
Pin-Priority: 100
apt-get update
apt-get -qq install -y --no-install-recommends \
chromium chromium-common chromium-driver chromium-sandbox
}
Package: chromium chromium-common chromium-driver chromium-sandbox
Pin: release o=Debian,n=bookworm
Pin-Priority: 990
# Adds the XtraDeb PPA apt source and installs chromium from it. Fallback
# path used only when CHROMIUM_SOURCE=xtradeb.
install_chromium_from_xtradeb_ppa() {
local keyring=/etc/apt/keyrings/xtradeb-apps.gpg
local src=/etc/apt/sources.list.d/xtradeb-apps.sources
install -m 0755 -d /etc/apt/keyrings
# PPA signing key fingerprint: 5301FA4FD93244FBC6F6149982BB6851C64F6880
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x5301FA4FD93244FBC6F6149982BB6851C64F6880" \
| gpg --no-tty --batch --yes --dearmor -o "$keyring"
cat > "$src" <<EOF
Types: deb
URIs: https://ppa.launchpadcontent.net/xtradeb/apps/ubuntu
Suites: ${VERSION_CODENAME}
Components: main
Signed-By: ${keyring}
EOF
apt-get update
@ -104,13 +151,25 @@ case $DESTARCH in
debian)
echo "Installing Chromium on ${ID} for ${DESTARCH^^}..."
apt-get update
apt-get -qq install chromium chromium-driver chromium-sandbox
apt-get -qq install chromium chromium-common chromium-driver chromium-sandbox
;;
ubuntu)
echo "Installing Chromium (via Debian bookworm) on ${ID} ${VERSION_CODENAME:-} for ${DESTARCH^^}..."
apt-get -qq install -y --no-install-recommends ca-certificates curl gnupg
install_chromium_from_debian_bookworm
case "${CHROMIUM_SOURCE:-internal}" in
internal)
echo "Installing Chromium (via PhotoPrism internal mirror) on ${ID} ${VERSION_CODENAME:-} for ${DESTARCH^^}..."
install_chromium_from_internal_mirror
;;
xtradeb)
echo "Installing Chromium (via XtraDeb PPA — fallback) on ${ID} ${VERSION_CODENAME:-} for ${DESTARCH^^}..."
install_chromium_from_xtradeb_ppa
;;
*)
echo "Unknown CHROMIUM_SOURCE='${CHROMIUM_SOURCE}' (expected 'internal' or 'xtradeb')" 1>&2
exit 1
;;
esac
;;
*)

View file

@ -15,7 +15,7 @@ fi
DESTDIR=$(realpath "${1:-/usr/local}")
# In addition, you can specify a custom version to be installed as the second argument.
LIBHEIF_VERSION=${2:-v1.20.2}
LIBHEIF_VERSION=${2:-v1.21.2}
# Determine target architecture.
if [[ $PHOTOPRISM_ARCH ]]; then

View file

@ -2,7 +2,7 @@
set -euo pipefail
ONNX_VERSION=${ONNX_VERSION:-1.25.0}
ONNX_VERSION=${ONNX_VERSION:-1.25.1}
TODAY=$(date -u +%Y%m%d)
TMPDIR=${TMPDIR:-/tmp}
SYSTEM=$(uname -s)
@ -30,11 +30,11 @@ case "${SYSTEM}" in
case "${ARCH}" in
amd64|AMD64|x86_64|x86-64)
archive="onnxruntime-linux-x64-${ONNX_VERSION}.tgz"
sha="e0a8998e70416801f9a634a8ea1d369a255ff109741469f9d99cf369a46a1492"
sha="eb566a49cfc49ef0642f809b69340b5bb656c7c4905ba873526d226f2c005816"
;;
arm64|ARM64|aarch64)
archive="onnxruntime-linux-aarch64-${ONNX_VERSION}.tgz"
sha="849c04634e76446bbe0a92f67955a9641415c37f11930804066057bf9eadbd03"
sha="daa71b56b00c4ab34798a3d96ca41a32ece4d3e302dc2386d3cca83fd4491214"
;;
*)
echo "Warning: ONNX Runtime is not provided for Linux/${ARCH}; skipping install." >&2
@ -46,7 +46,7 @@ case "${SYSTEM}" in
case "${ARCH}" in
arm64|ARM64|aarch64)
archive="onnxruntime-osx-arm64-${ONNX_VERSION}.tgz"
sha="65405dc8793c86cadb98b5e07f6d3bdde84f8300f1b030d4736b41c17610d6c1"
sha="18987ec3187b5f29ba798109750f6135060560ad4e0a52678fcc753ee8fb3091"
;;
x86_64|x86-64)
echo "Warning: ONNX Runtime is not provided for macOS/${ARCH} in v${ONNX_VERSION}; skipping install." >&2

View file

@ -111,7 +111,13 @@ else
--push .
fi
# Capture the buildx exit code so we can still run cleanup below
# and propagate the failure to make. Without this, a failed build
# would silently exit 0 because the trailing `echo` succeeds.
rc=$?
echo "Removing multibuilder..."
docker buildx rm multibuilder
echo "Done."
exit "$rc"