Commit graph

4463 commits

Author SHA1 Message Date
Michael Mayer
0cecb5287f Search: Match folder album_path case-insensitively #5724
Folder search lowercases the query term, but album_path is VARBINARY and
compared byte-exact (case-sensitive) on MySQL/MariaDB, so a lowercased
term no longer matched uppercase folder paths. A parent folder still
appeared via its case-insensitive title, while child folders - matchable
only by path - dropped out of the results.

Add a dialect-aware PathLike helper: MySQL converts album_path to a
case-insensitive collation for the LIKE, SQLite and other dialects fall
back to a plain LIKE (SQLite is already ASCII case-insensitive). Only the
folder search filter is affected; byte-exact album_path identity lookups
are unchanged, so emoji and case-distinct folders stay distinct.
2026-07-16 16:53:28 +00:00
Michael Mayer
3f039866ff Sharing: Fix single-item access for shared smart albums #5727
Pictures shared only through a folder, moment, calendar, or region link
have no photos_albums row, so the by-UID and by-hash visibility checks
reported them out of scope and returned "Entity not found" - for example
when downloading a single image from a shared folder. The photo and file
visibility helpers now fall back to the shared album's filter, matching
what browsing and full-album downloads already allow. The fallback runs
only after the cheaper personal-scope check misses and skips regular
albums, so full-access and regular-share sessions add no queries.
2026-07-16 12:10:44 +00:00
Michael Mayer
4946bd2050 Vision: Add OpenAI service_tier via Service.Tier config #5725
Add a string-valued Service.Tier field (OpenAI-scoped) that is sent
verbatim as a top-level service_tier field in the Responses API request
body, reusing the existing Org/Project plumbing. Defaults empty
(backward compatible, OpenAI "auto") and supports ${ENV} expansion.

Bounded exponential-backoff retry on HTTP 429 is a separate follow-up
on the shared client (#5729).
2026-07-16 10:37:15 +00:00
Michael Mayer
311929ab27 Vision: Disable Ollama reasoning by default, refresh defaults #5728 #5726
- Default Service.Think to "false" for the Ollama engine via a new
  EngineInfo.DefaultThink applied in ApplyEngineDefaults; re-enable
  reasoning explicitly with Service.Think: "true".
- Replace the retired cloud default qwen3-vl:235b-instruct-cloud with
  minimax-m3:cloud, and refresh the self-hosted default from
  gemma3:latest to gemma4:latest.
- Warn on HTTP 404/410 (retired/renamed model) and other 4xx statuses
  in ollamaParser.Parse so failures are diagnosable instead of silent.
- Defensively strip a leading, tag-delimited <think>...</think> block
  from the response body.
- Update tests, regenerate testdata/vision.yml, and refresh package docs.
2026-07-16 10:21:49 +00:00
Michael Mayer
017e24927e Tests: Refine isolated test-folder cleanup and logging #5263 2026-07-12 06:31:52 +00:00
Keith Martin
b6687b6c29
Tests: Improve isolation and add fixes #5263
* Tests: fix random selection of deleted record causing test failure
* Tests: fix test folders being left after test
* Tests: ensure that required records are available
* Tests: make each package execute against it's own testdata folder to ensure no interpackage test failures
* Tests: add unit tests of CleanupTestFolder
* Tests:  Allow defer in TestMain
* Tests: rename testMain to runTestMain
2026-07-12 05:47:21 +02:00
Cathie Integra
fb6e17ee69 Index: Resolve S2 cell in XMP branch, downgrade geocoding warn
When an XMP sidecar provides GPS coordinates, call UpdateLocation()
immediately so the S2 cell is resolved and cached in the local DB.
Without this, subsequent re-indexes always triggered a live geocoding
request (even though the coordinates were already known), producing a
spurious error on every re-index pass.

Downgrade the geocoding failure log in photo_location.go and
places/cell.go from Errorf to Warnf: a transient network failure to
the places service is non-fatal (the photo is indexed successfully,
just without a resolved place name) and must not surface as an
indexing error in the test suite.
2026-07-10 10:14:02 +02:00
Michael Mayer
12d043f809 Places: Reuse pooled transport and log geocoding host only 2026-07-09 15:08:40 +00:00
Ömer Duran
6dc0acac13
Settings: Reorganize UI and add an Accessibility section #848 #799 #5429
* Frontend: Add settings for albums, favorites, folders, and media features

* Frontend: Enhance download settings for files and albums #848

* Frontend: Enhance download settings for files and albums #848

* Frontend: Update album download settings to restrict access to super admins

* Frontend: Add album sorting options to settings for super admins

* Frontend: Update download settings visibility

* Frontend: Refine download settings access control for user roles

* Tests: Add album, folder, moment, state, and month order selection options to page-model

* Frontend: Update access control for super admins in settings to include scope check

* Tests: Add new tests for download name and album order settings persistence

* Tests: Implement new tests for default album sort order persistence in settings

* Frontend: Move default album order settings

* Frontend: Add zoom accessibility option in settings #799

* Frontend: Update download name from "Share Identifier" to "Share Friendly" and refine hints for download options in settings

* Tests: Add new acceptance test for disabling album downloads while allowing file downloads in settings

* Frontend: Improve download options layout and hints

* Frontend: Refactor download options condition in settings page

* Frontend: Implement native zoom control in lightbox component

* Settings: Add Collections tab and move Services to its own page #848 #5429

- Add a super-admin Collections tab after Content holding the album
  Download and Sort Order settings; remove those cards from Content.
- Move Services from a settings tab to a dedicated page
  (page/services.vue) with a search toolbar and a Reload / Learn More
  action menu; link it from the navigation settings menu.
- Implement server-side services search (acc_name filter) with tests.
- De-duplicate the picture sort-order options into a shared
  SortOrderOptions builder in options.js.
- Update acceptance/unit tests and page models to match.

* Frontend: Regenerate translation catalogs #848 #5429

Ran `make gettext-extract` to pick up the new settings strings
(Collections, Features, Disable Downloads, and related messages) so
Weblate can translate them. Existing translations are preserved.

* Frontend: Move lightbox viewport-zoom handling into the view helper

- Move the viewport pinch-zoom lock/restore (disableNativeZoom /
  restoreNativeZoom + savedViewportContent) from lightbox.vue onto the
  View singleton in common/view.js.
- Drive it passively from View.apply() via a disableViewportZoom flag in
  the PLightbox case, alongside hideScrollbar / disableScrolling /
  disableNavigationGestures; lightbox.vue no longer references zoom.
- Make save/restore idempotent and extract the viewport constants.
- Cover the helpers and the apply() wiring in view.test.js.

* Settings: Add Accessibility section to General with Reduce Motion #848 #5429

- Move the page-zoom checkbox out of the feature-flags grid into a
  dedicated Accessibility card (super-admin only) with four toggles:
  Open on Hover, Reduce Motion, Hide Scrollbar, and Allow Page Zoom.
- Add a UI.ReduceMotion flag (defaults false) that toggles an
  html.reduce-motion class via config.setReduceMotion and suppresses map
  fly-to animations non-destructively (util.mapAnimateDuration).
- Make Open on Hover apply live by reading shouldOpenOnHover() through a
  reactive computed in the action/auth/lightbox menus; Zoom and Hide
  Scrollbar reload since they are baked into the server-rendered HTML.
- Reword two feature-grid hints to the "pictures" convention and cover
  the new helpers with Go and Vitest tests.

* Frontend: Regenerate translation catalogs #848 #5429

Ran `make gettext-extract` to pick up the new Accessibility settings
strings (Accessibility, Allow Page Zoom, Open on Hover, Hide Scrollbar,
Reduce Motion, and their hints) and the reworded feature-grid hints.
Existing translations are preserved.

---------

Co-authored-by: Michael Mayer <michael@photoprism.app>
2026-07-08 14:11:36 +02:00
Michael Mayer
c9f21cd965 Server: Derive WebDAV proxy test paths from proxy.PathPrefix
TestIsWebDAVPath hardcoded the "/i/" proxy prefix, so it failed when
proxy.DefaultPathPrefix changed. Build the proxy cases from proxy.PathPrefix,
the same value IsWebDAVPath uses, so they track the configured prefix.
2026-07-07 23:47:57 +00:00
Michael Mayer
a693df1eaa WebDAV: Route favorite-flag sidecar errors to the system log #5715
WebDAVSetFavoriteFlag logged sidecar mkdir/write failures at error level on
the UI log stream with the raw error, leaking the absolute sidecar path.
Route them through event.SystemError with clean.Error, matching the request
error handling in the WebDAV logger.
2026-07-07 23:47:57 +00:00
Michael Mayer
7bea2dccfe WebDAV: Route request errors to the system log, not the UI #5715
All WebDAV write-method errors (PUT/DELETE/MOVE/COPY/MKCOL/...) now go to
the console-only system log instead of the in-app errors table and browser
log viewer, because golang.org/x/net/webdav embeds absolute server paths in
its messages. Operators still see full detail in the container/service logs.

A MKCOL against an already-existing collection is a benign probe used by
sync clients such as PhotoSync to test for a directory before creating it;
it returns 405 and is logged at debug rather than as an error.
2026-07-07 23:27:52 +00:00
Michael Mayer
d33bca4f48 Metadata: Fix XMP body serial and add tiff: field fallbacks #2260
Read the standard exifEX:BodySerialNumber property, which ExifTool and
Adobe emit; the previous exifEX:SerialNumber matched no compliant XMP
sidecar and silently fell through to aux:SerialNumber.

Add deprecated TIFF-schema fallbacks still emitted by some writers:
tiff:Software (Software), tiff:ImageDescription (Description), and
tiff:Copyright (Copyright).
2026-07-07 11:44:33 +00:00
Michael Mayer
b7e758c607 PWA: Only emit maskable icons when the variant exists #5696 2026-07-06 10:58:16 +00:00
Ömer Duran
4b33d3bd1a
PWA: Add maskable icons, lang/dir, and install screenshots #5696 2026-06-30 19:53:52 +02:00
Michael Mayer
25b99b3ec4 Convert: Gate RAW render rejection by file format #5673
Apply the RawTherapee stderr rejection (raw.DecoderErrors) only for a
gated set of formats (default .cr3) instead of every RAW format.
RawTherapee prints "Cannot use camera white balance" for any sensor it
cannot read, including bodies whose default-WB render is fine, so for
formats it alone can decode (e.g. .raw, .kdc) the warning-based discard
left nothing to index. CR3 is unchanged: a magenta render warns and
falls back to the embedded preview.
2026-06-30 14:04:59 +00:00
Michael Mayer
6dc3ecbf25 Metadata: Map XMP dc:subject to Subject field, not Keywords #2075 #2260
dc:subject is Adobe's "Keywords" panel, but PhotoPrism's Keywords,
Labels, and Subject fields serve distinct purposes. Route dc:subject to
the descriptive Details.Subject field only (matching the embedded/
ExifTool path, where data.Subject comes from the dc:subject-backed
Subject tag and data.Keywords from IPTC Keywords), never the Keywords
field. Multi-word entries keep their spaces; the value stays searchable
via IndexKeywords and still drives label matching.
2026-06-30 10:12:45 +00:00
Michael Mayer
38b50724f5 Convert: Prefer embedded preview over untrustworthy RAW render #5673 2026-06-30 07:49:32 +00:00
Michael Mayer
1b572c0da7 Auth: Store session login_at as NULL when no client IP is set #5707 2026-06-29 23:28:12 +00:00
Michael Mayer
5eeb0d6291 Convert: Verify RAW previews and prefer djxl for JPEG XL #5673 #5693 2026-06-29 21:59:48 +00:00
Michael Mayer
d330f0e05c Users: Apply 2FA changes for the cluster service principal 2026-06-29 21:59:48 +00:00
Michael Mayer
9d0aca64b8 OIDC: Add configurable authorization prompt option #5698
Add PHOTOPRISM_OIDC_PROMPT (--oidc-prompt) to forward the OpenID Connect
"prompt" parameter (login, select_account, consent) on the authorization
request, so a rejected user can re-authenticate or pick another account
instead of being silently signed in again. Unsupported values (including
"none", which would break interactive login) are dropped with a warning;
empty preserves the seamless-SSO default.
2026-06-27 12:35:04 +00:00
Michael Mayer
a2e8592dd5 Auth: Localize login/session/OIDC error responses via messageId #5699 #5682 2026-06-27 11:34:54 +00:00
Ömer Duran
b2b1785126
Tests: Add unit tests for recent metadata, auth, and config changes #5697 2026-06-26 14:12:59 +02:00
Michael Mayer
41fdb578b2 Users: Prevent a non-super-admin admin from locking out the super admin 2026-06-25 16:24:51 +00:00
Michael Mayer
57e4de738e Vision: Fix data race in image classifier during parallel indexing #5694
Indexing workers share one classify.Model whose Run() built tensors into a single shared ImageTensorBuilder buffer with no lock, so concurrent classification corrupted images and produced garbage labels. Pool a per-call builder instead, restoring the pre-#5164 concurrency safety.

Add a concurrent regression test and consolidate the model benchmark.
2026-06-25 15:01:23 +00:00
Michael Mayer
80437299da OIDC: Send and validate a nonce on authorization requests #5695 2026-06-25 16:54:29 +02:00
Michael Mayer
9a25a19824 JPEG XL: Decode natively via libvips with "djxl" fallback #5693
Route JPEG XL through the native libvips path (govips jxlload) like
HEIC/AVIF, keeping the external "djxl" decoder as an automatic fallback
for runtimes whose libvips lacks JPEG XL support.

Decouple format enablement from "djxl": DisableJpegXL now stays enabled
when either the decoder is present or libvips can decode JPEG XL natively.
The libvips capability probe (thumb.JpegXLSupported) is cached and runs
only when no decoder is present, so config introspection on standard
installs (which ship "djxl") does not start libvips.

Add a shared assets/samples/dice.jxl fixture and focused tests for the
native conversion path, the djxl fallback command, and the capability
decision table.
2026-06-25 14:31:10 +00:00
Michael Mayer
8a3c3fb8f1 Tests: Make TestService_Discovery use a local WebDAV server
Service discovery was probing the live https://www.photoprism.app/ and asserting
success, so the test broke when the website migrated to Hugo (no PROPFIND
support). Point it at a local httptest WebDAV-like server and allow the loopback
host via servicesCIDR, making the test deterministic and offline-safe.
2026-06-24 15:31:31 +00:00
Michael Mayer
11c074cb85 Auth: Delegate cluster OIDC logout to the Portal end-session endpoint #5684
On a cluster-OIDC Sign-Out the instance now delegates the Portal session and the
upstream RP-logout to the Portal's end-session endpoint instead of doing them
itself: revokePeerSessions skips the Portal peer's backend DELETE (still clearing
its storage), and DeleteSession skips clearing the OP session cookie when it
returns a providerLogoutUri — otherwise the Portal OP could not resolve the
session (via that cookie) to chain the upstream logout. onLogout now resolves to
the chosen landing URL so the /logout route guard follows the provider logout URL
too. Adds the oidcLogout client-config accessor and an instance.portal session
marker so the Portal peer is identified explicitly rather than by URL shape.
2026-06-24 15:22:04 +00:00
Michael Mayer
50976f427e Auth: Add RP-initiated OIDC logout via PHOTOPRISM_OIDC_LOGOUT #5684 #5433
Sign-out of an OIDC session redirects the browser to the provider's
discovered end_session_endpoint (id_token_hint + absolute
post_logout_redirect_uri) when PHOTOPRISM_OIDC_LOGOUT is enabled, so the
provider SSO session ends and the next login re-prompts. Opt-in (default
off); local/LDAP logout and providers without an end-session endpoint fall
back to local logout.

Adds the shared CE /api/v1/oauth/logout route and OAuthLogoutHandler hook
plus the Portal OP end_session_endpoint advertised in the Portal discovery
document, for Portal builds to serve via the override hook.
2026-06-24 17:21:00 +02:00
Cathie Integra
3f096a592b Links: Use canonical trailing-slash form for website URLs 2026-06-24 17:20:30 +02:00
Michael Mayer
c5658a053d API: Align i18n.Response Go field names with JSON keys #5682
Rename the Response struct fields to match their json tags (Err to Error, Msg to Message) and the Error() method to ErrorString() to avoid a field/method clash; update callers and tests. JSON wire format is unchanged.
2026-06-23 12:42:57 +00:00
Michael Mayer
fd8448d6de UX: Name message fields messageId/messageParams in i18n payloads #5682
Rename the i18n notification/error envelope fields from id/params to messageId/messageParams across the WS notify Data and the i18n.Response body, so they are unambiguous next to session, log, and correlation ids. Updates the frontend consumers (notify.vue, api.js), tests, swagger, and the event/locales READMEs.
2026-06-23 12:22:30 +00:00
Michael Mayer
f5373ccec6 UX: Render backend error responses in the current UI locale #5682
The i18n.Response envelope now carries the untranslated source id and params; the frontend (api.js) renders them via Tp in the user's UI locale, with the server-rendered error/message as the instance-locale fallback. Common not-found/conflict/bad-request handlers were switched from hardcoded literals and raw err.Error() to Abort/i18n.Message helpers, so they benefit automatically.
2026-06-23 12:06:49 +00:00
Michael Mayer
a12d287c19 Docs: Document per-user notification localization in READMEs #5682
Describe the event *Msg structured payload and frontend per-user rendering in the event and backend-locales READMEs; fix a stale go-generate path (/internal/i18n to /pkg/i18n).
2026-06-23 09:23:31 +00:00
Michael Mayer
9ec70731e2 UX: Fix notification messages that bypassed translation #5682
Route user-facing notifications through extractable $gettext (aliased ctx.$gettext is not extracted), wrap hardcoded English strings, replace concatenation with interpolation, and drop redundant English backend label toasts already covered by localized frontend ones.
2026-06-23 09:06:56 +00:00
Michael Mayer
9ecdb0ae26 UX: Render backend notifications in the current UI locale #5682
Backend *Msg helpers now publish the untranslated message id and params alongside the rendered fallback; the frontend translates the id and applies a positional-placeholder shim so notifications render in each user's UI language.
2026-06-23 09:06:46 +00:00
Michael Mayer
1350d9fe85 RAW: Fall back to embedded JPEG previews for unsupported files #5673 2026-06-18 12:29:55 +00:00
Michael Mayer
4ab00fa32d Config: Raise thumbnail and conversion size limits to 16K #5669 2026-06-17 04:12:40 +00:00
Michael Mayer
43506ad883 Thumbs: Add 16K (15360px) fit size for photos and videos #5669 #5623 2026-06-17 04:12:40 +00:00
Ömer Duran
3ea547a564
Lightbox: Add support for 360° photos and videos #352 #4718 #5623
* Lightbox: Add 360° photo and video sphere viewer support

Wires Photo Sphere Viewer v5 into the lightbox for equirectangular
media. Backend detects UsePanoramaViewer and IsPhotosphere markers
alongside ProjectionType, and exposes Panorama + Projection in the
viewer / search result DTOs so the frontend can dispatch on the
projection type. The renderer (ThreeJS + PSV core + video plugin +
equirectangular video adapter) is lazy-loaded into a dedicated
webpack chunk so the base bundle is unchanged for users with no
360° media. PhotoPrism's existing lightbox controls drive the
underlying HTMLVideoElement that the PSV adapter creates for 360°
videos. Includes Vitest coverage for the sphere helper and the
lightbox dispatch, plus a TestCafe smoke spec and three new
ExifTool metadata fixtures.

* Lightbox: Fix 360° controls leak, hide zoom button, add sidebar icon

* Lightbox: Fix 360° touch panning and navigation arrows

* Lightbox: Fix fast swipe switching photos on 360° slides

Suppress PhotoSwipe swipe/drag navigation at its source via the dispatched pointer hook while a sphere slide is active, since the per-element gesture trap is outrun by a fast swipe whose pointer leaves the sphere container (touch-capable Windows). UI controls stay excluded so buttons and arrows still navigate.

* Lightbox: Route only equirectangular media to the 360° viewer

Routing was based on the loose photo_panorama flag for videos, which is also
true for cubemap and merely-wide (aspect > 1.9) videos, so non-equirectangular
media wrongly opened, distorted, in the sphere viewer. Route strictly on the
equirectangular projection for both photos and videos. Surface the video file's
projection in the viewer DTO via Photo.MediaProjection() (the primary search row
is a poster JPEG with no projection). Add an MP4 metadata-detection test.

* Lightbox: Open tagless 2:1 360° videos in the sphere viewer

Many 360° videos carry no projection tag PhotoPrism can read, so strict
equirectangular-projection routing left them in the flat player. Fall back to
equirectangular's defining 2:1 frame for panorama-flagged videos without a
projection, via is360Equirectangular() in common/sphere.js, while cubemap and
ultrawide (~2.35:1) clips stay flat.

* Tests: Make the 360° sphere video test assert instead of skipping
2026-06-17 03:26:25 +02:00
Michael Mayer
a2e708e7ab Meta: Update go-mp4, regenerate NOTICE, fix XMP doc spelling #2260 #5563
Bump abema/go-mp4 to v1.7.1 (only outdated direct dependency) and tidy
go.sum, pruning stale checksums left by the XMP sidecar merge. Regenerate
the license NOTICE for the updated dependency.

Use US-English spelling in the new XMP sidecar comments and tests, and
drop a specs/ reference from internal/meta/README.md so the public package
doc has no link into the private subrepo. Regenerate dialect_mysql.go from
SQL.
2026-06-17 00:14:33 +00:00
Ömer Duran
86efc1c982
Index: Improve support for metadata in XMP sidecar files #2260 #2828 #5563
* Meta: Add XMP sidecar fixture corpus for reader rewrite #2260
* Meta: Replace XMP sidecar reader with antchfx/xmlquery #2260
* Meta: Add XMP sidecar reader test suite #2260
* Meta: Assert XMP GPS extraction on Apple/Adobe sidecars #2828
* Meta: Apply XMP sidecar metadata to photo and primary file #2260
* Meta: Resolve XMP sidecar time zone via shared helper #2260
* Meta: Derive panorama & caption keywords on XMP sidecar reads #2260
* Meta: Assert XMP sidecar GPS override and malformed-file handling #2260
* Meta: Tidy XMP sidecar reader comments and reuse logName local #2260
* Meta: Fix XMP sidecar capture-time priority OffsetTime handling #2260
* Meta: Address XMP sidecar review feedback #2260
2026-06-17 01:53:28 +02:00
Michael Mayer
ee0b1a616a Search: Sort geo "near" results by distance to the photo #5643
A "near=<uid>" geo search set the S2 cell but left the form lat/lng at
zero, so results sorted by distance from the (0,0) origin instead of
the referenced picture. Use the picture's own position in the ORDER BY,
falling back to time order when it has no usable coordinates.
2026-06-16 23:19:46 +00:00
Michael Mayer
bad538e51b Index: Do not assign OriginalName to indexed files #5668
Stop deriving a file's OriginalName from cached metadata during indexing.
The ExifTool JSON cache is keyed by content hash, so a renamed file was
re-indexed against the old name and surfaced it as OriginalName, making
cards show a stale name while the sidebar showed the current one.

OriginalName is now set only from the explicit import original name, so
plain-indexed files no longer carry one and imported files are unaffected.
2026-06-16 17:21:56 +00:00
Michael Mayer
74a95fc748 ACL: Generate CLI role & auth-provider help from role tables #5667
Add pkg/txt.JoinOr plus acl.RolesCliUsageString and UserRoleUsageFor so the users/clients --role and --auth help derive from the role tables instead of hand-maintained literals, filtering the app/uploader aliases and visitor. Each edition lists its own roles; federatable contexts (LDAP, OIDC group-role, cluster grants) use ClusterInstanceRoles.
2026-06-16 11:57:44 +02:00
Cathie Integra
f1d5ae2ba2 ACL: Add ClusterInstanceRoles source of truth for group-role help 2026-06-16 09:32:15 +02:00
Michael Mayer
75a5e1ee0b Auth: Log anonymous API requests at debug level instead of warning #5647
Only warn when a request presents a token that is rejected; requests
without any credentials (e.g. the web app loading public config before
login) are routine and now logged at debug level, reducing log noise.
2026-06-15 23:56:56 +00:00
Michael Mayer
c45ee6e54f Auth: Revoke derived sessions & gate app passwords by login state #5647
- Add scoped session revocation (RevokeSessions / RevokeDerivedSessions)
  shared by the user-update API and the CLI: a privilege-level change now
  revokes logins and app-password-derived sessions while keeping the app
  passwords themselves, so configured devices keep working.
- Identify app-password-derived sessions by auth_method "session".
- Deny app passwords on the REST API when the account cannot log in
  (DenyLogIn); WebDAV access stays governed by CanUseWebDAV.
- Reduce the WebDAV auth cache expiration to one minute.
- Add IsSystemOrInvalid; allow deleting the initial admin account and skip
  re-initializing a deactivated or deleted admin in InitAccount.
- Move session-revocation scopes to pkg/authn and extend tests.
2026-06-15 23:41:01 +00:00