mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Merge branch 'develop' into test-improvements
This commit is contained in:
commit
bde4be3bf8
49 changed files with 4297 additions and 1912 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -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: ''
|
||||
|
||||
---
|
||||
|
|
|
|||
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/ISSUE_TEMPLATE/feature-request.md
vendored
2
.github/ISSUE_TEMPLATE/feature-request.md
vendored
|
|
@ -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: ''
|
||||
|
||||
---
|
||||
|
|
|
|||
3
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
3
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
48
.ldap.cfg
48
.ldap.cfg
|
|
@ -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
|
||||
12
AGENTS.md
12
AGENTS.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
27
Makefile
27
Makefile
|
|
@ -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
23
NOTICE
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
102
frontend/README.md
Normal 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`.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
17
frontend/src/component/sidebar/inline-toolbar.vue
Normal file
17
frontend/src/component/sidebar/inline-toolbar.vue
Normal 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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
81
frontend/tests/vitest/helpers/jsdom-quiet.js
Normal file
81
frontend/tests/vitest/helpers/jsdom-quiet.js
Normal 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);
|
||||
});
|
||||
}
|
||||
72
frontend/tests/vitest/helpers/jsdom-quiet.test.js
Normal file
72
frontend/tests/vitest/helpers/jsdom-quiet.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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
8
go.mod
|
|
@ -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
16
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ The API package exposes PhotoPrism’s 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
35
internal/entity/label_slug_test.go
Normal file
35
internal/entity/label_slug_test.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
4541
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,6 @@
|
|||
"name": "photoprism",
|
||||
"private": true,
|
||||
"overrides": {
|
||||
"minimatch@~3.0": "^3.1.3",
|
||||
"serialize-javascript": "^7.0.5"
|
||||
},
|
||||
"workspaces": [
|
||||
|
|
|
|||
13
scripts/dist/build-libheif.sh
vendored
13
scripts/dist/build-libheif.sh
vendored
|
|
@ -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..."
|
||||
|
|
|
|||
125
scripts/dist/install-chrome.sh
vendored
125
scripts/dist/install-chrome.sh
vendored
|
|
@ -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
|
||||
;;
|
||||
|
||||
*)
|
||||
|
|
|
|||
2
scripts/dist/install-libheif.sh
vendored
2
scripts/dist/install-libheif.sh
vendored
|
|
@ -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
|
||||
|
|
|
|||
8
scripts/dist/install-onnx.sh
vendored
8
scripts/dist/install-onnx.sh
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue