From 86efc1c982089b3a084a993b23cda3f3735b032a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Duran?= Date: Wed, 17 Jun 2026 02:53:28 +0300 Subject: [PATCH] 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 --- NOTICE | 244 +++++ go.mod | 7 +- go.sum | 203 ++++ internal/entity/migrate/dialect_mysql.go | 6 + .../entity/migrate/mysql/20260602-000001.sql | 1 + internal/entity/photo.go | 2 +- internal/meta/README.md | 14 +- internal/meta/data.go | 5 + internal/meta/gps.go | 39 +- internal/meta/gps_test.go | 59 ++ internal/meta/json_exiftool.go | 109 +- internal/meta/resolver.go | 130 +++ internal/meta/resolver_test.go | 151 +++ internal/meta/testdata/xmp/README.md | 45 + .../testdata/xmp/adobe/bridge-2.exiftool.txt | 129 +++ internal/meta/testdata/xmp/adobe/bridge-2.xmp | 147 +++ .../testdata/xmp/adobe/bridge.exiftool.txt | 130 +++ internal/meta/testdata/xmp/adobe/bridge.xmp | 139 +++ .../xmp/darktable/aurora.jpg.exiftool.txt | 118 +++ .../testdata/xmp/darktable/aurora.jpg.xmp | 132 +++ .../xmp/digikam/aurora.jpg.exiftool.txt | 155 +++ .../meta/testdata/xmp/digikam/aurora.jpg.xmp | 159 +++ .../xmp/synthetic/alt-edge-cases.exiftool.txt | 32 + .../testdata/xmp/synthetic/alt-edge-cases.xmp | 28 + .../xmp/synthetic/aux-only.exiftool.txt | 32 + .../meta/testdata/xmp/synthetic/aux-only.xmp | 14 + .../xmp/synthetic/datecreated-priority.xmp | 11 + .../synthetic/exifex-camera-lens.exiftool.txt | 36 + .../xmp/synthetic/exifex-camera-lens.xmp | 18 + .../xmp/synthetic/gpano-360.exiftool.txt | 32 + .../meta/testdata/xmp/synthetic/gpano-360.xmp | 16 + .../synthetic/gps-time-combined.exiftool.txt | 35 + .../xmp/synthetic/gps-time-combined.xmp | 14 + .../xmp/synthetic/gps-time-split.exiftool.txt | 36 + .../testdata/xmp/synthetic/gps-time-split.xmp | 15 + .../multi-rdf-description.exiftool.txt | 43 + .../xmp/synthetic/multi-rdf-description.xmp | 36 + .../synthetic/notes-usercomment.exiftool.txt | 27 + .../xmp/synthetic/notes-usercomment.xmp | 15 + .../xmp/synthetic/software-only.exiftool.txt | 25 + .../testdata/xmp/synthetic/software-only.xmp | 9 + .../xmp/synthetic/subject-seq.exiftool.txt | 33 + .../testdata/xmp/synthetic/subject-seq.xmp | 17 + .../time-offsets-subsec.exiftool.txt | 33 + .../xmp/synthetic/time-offsets-subsec.xmp | 17 + .../synthetic/xmpdm-creationdate.exiftool.txt | 25 + .../xmp/synthetic/xmpdm-creationdate.xmp | 9 + internal/meta/xmp.go | 188 +++- internal/meta/xmp_bench_test.go | 45 + internal/meta/xmp_document.go | 985 +++++++++++++----- internal/meta/xmp_document_test.go | 870 ++++++++++++++++ internal/meta/xmp_migration_test.go | 112 ++ internal/meta/xmp_security_test.go | 91 ++ internal/meta/xmp_test.go | 164 ++- internal/photoprism/index_mediafile.go | 48 + internal/photoprism/index_related_test.go | 413 ++++++++ 56 files changed, 5246 insertions(+), 402 deletions(-) create mode 100644 internal/entity/migrate/mysql/20260602-000001.sql create mode 100644 internal/meta/resolver.go create mode 100644 internal/meta/resolver_test.go create mode 100644 internal/meta/testdata/xmp/README.md create mode 100644 internal/meta/testdata/xmp/adobe/bridge-2.exiftool.txt create mode 100644 internal/meta/testdata/xmp/adobe/bridge-2.xmp create mode 100644 internal/meta/testdata/xmp/adobe/bridge.exiftool.txt create mode 100644 internal/meta/testdata/xmp/adobe/bridge.xmp create mode 100644 internal/meta/testdata/xmp/darktable/aurora.jpg.exiftool.txt create mode 100644 internal/meta/testdata/xmp/darktable/aurora.jpg.xmp create mode 100644 internal/meta/testdata/xmp/digikam/aurora.jpg.exiftool.txt create mode 100644 internal/meta/testdata/xmp/digikam/aurora.jpg.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/alt-edge-cases.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/alt-edge-cases.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/aux-only.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/aux-only.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/datecreated-priority.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/exifex-camera-lens.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/gpano-360.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/gpano-360.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/gps-time-combined.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/gps-time-combined.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/gps-time-split.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/gps-time-split.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/multi-rdf-description.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/multi-rdf-description.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/notes-usercomment.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/notes-usercomment.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/software-only.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/software-only.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/subject-seq.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/subject-seq.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/time-offsets-subsec.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/time-offsets-subsec.xmp create mode 100644 internal/meta/testdata/xmp/synthetic/xmpdm-creationdate.exiftool.txt create mode 100644 internal/meta/testdata/xmp/synthetic/xmpdm-creationdate.xmp create mode 100644 internal/meta/xmp_bench_test.go create mode 100644 internal/meta/xmp_document_test.go create mode 100644 internal/meta/xmp_migration_test.go create mode 100644 internal/meta/xmp_security_test.go diff --git a/NOTICE b/NOTICE index 506c5f058..6a6994187 100644 --- a/NOTICE +++ b/NOTICE @@ -294,6 +294,52 @@ SOFTWARE. -------------------------------------------------------------------------------- +Package: github.com/antchfx/xmlquery +Version: v1.5.1 +License: MIT (https://github.com/antchfx/xmlquery/blob/v1.5.1/LICENSE) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- + +Package: github.com/antchfx/xpath +Version: v1.3.6 +License: MIT (https://github.com/antchfx/xpath/blob/v1.3.6/LICENSE) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +-------------------------------------------------------------------------------- + Package: github.com/beorn7/perks/quantile Version: v1.0.1 License: MIT (https://github.com/beorn7/perks/blob/v1.0.1/LICENSE) @@ -2595,6 +2641,204 @@ License: Apache-2.0 (https://github.com/golang/geo/blob/f1a45663b0f3/LICENSE) -------------------------------------------------------------------------------- +Package: github.com/golang/groupcache/lru +Version: v0.0.0-20210331224755-41bb18bfe9da +License: Apache-2.0 (https://github.com/golang/groupcache/blob/41bb18bfe9da/LICENSE) + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, "control" means (i) the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising +permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +"Object" form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +"submitted" means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +2. Grant of Copyright License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +3. Grant of Patent License. + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of +this License; and +You must cause any modified files to carry prominent notices stating that You +changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +5. Submission of Contributions. + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +6. Trademarks. + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +8. Limitation of Liability. + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets "[]" replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same "printed page" as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +-------------------------------------------------------------------------------- + Package: github.com/google/jsonschema-go/jsonschema Version: v0.4.3 License: MIT (https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE) diff --git a/go.mod b/go.mod index 18995e2d3..bd8c28468 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,10 @@ require github.com/olekukonko/tablewriter v1.1.4 require github.com/google/uuid v1.6.0 -require github.com/chzyer/readline v1.5.1 // indirect +require ( + github.com/chzyer/readline v1.5.1 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect +) require github.com/gabriel-vasile/mimetype v1.4.13 @@ -75,6 +78,8 @@ require golang.org/x/text v0.38.0 require ( github.com/IGLOU-EU/go-wildcard v1.0.3 + github.com/antchfx/xmlquery v1.5.1 + github.com/antchfx/xpath v1.3.6 github.com/davidbyttow/govips/v2 v2.18.0 github.com/go-co-op/gocron/v2 v2.21.2 github.com/go-sql-driver/mysql v1.10.0 diff --git a/go.sum b/go.sum index de7ab7db7..54092c015 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -7,33 +9,87 @@ cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTj cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= +cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= +cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= +cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0 h1:sAbMqjY1PEQKZBWfbu6Y6bsupJ9c4QdHnzg/VvYTLcE= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/datastore v1.0.0 h1:Kt+gOPPp2LEPWp8CSfxhsM8ik9CcyE/gYu+0r+RnZvM= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0 h1:9/vpR43S4aJaROxqQHQ3nH9lfyKKV0dC3vOmnw8ebQQ= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI= +cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= +codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0= +codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU= +codeberg.org/go-latex/latex v0.1.0 h1:hoGO86rIbWVyjtlDLzCqZPjNykpWQ9YuTZqAzPcfL3c= +codeberg.org/go-latex/latex v0.1.0/go.mod h1:LA0q/AyWIYrqVd+A9Upkgsb+IqPcmSTKc9Dny04MHMw= +codeberg.org/go-pdf/fpdf v0.10.0 h1:u+w669foDDx5Ds43mpiiayp40Ov6sZalgcPMDBcZRd4= +codeberg.org/go-pdf/fpdf v0.10.0/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUuKccphEpTJnWhS2jkQyMt6Y7pJCD7fY= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38= +git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94= +github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0 h1:lhSJz9RMbJcTgxifR1hUNJnn6CNYtbgEDtQV22/9RBA= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0 h1:OYa9vmRX2XC5GXRAzeggG12sF/z5D9Ahtdm9EJ00WN4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= +github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0 h1:v9p9TfTbf7AwNb5NYQt7hI41IfPoLFiFkLtb+bmGjT0= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/IGLOU-EU/go-wildcard v1.0.3 h1:r8T46+8/9V1STciXJomTWRpPEv4nGJATDbJkdU0Nou0= github.com/IGLOU-EU/go-wildcard v1.0.3/go.mod h1:/qeV4QLmydCbwH0UMQJmXDryrFKJknWi/jjO8IiuQfY= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/abema/go-mp4 v1.6.0 h1:aXw6240IdFHH5laJuiN992nWMHFkPAREm0yCTAFsceE= github.com/abema/go-mp4 v1.6.0/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= +github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= +github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= +github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= @@ -47,9 +103,14 @@ github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoO github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= +github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= +github.com/census-instrumentation/opencensus-proto v0.2.1 h1:glEXhBS5PSLLv4IXzLA5yPRVX4bilULVyxxbrfOtDAk= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -59,6 +120,7 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= @@ -66,8 +128,11 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.7 h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -79,7 +144,9 @@ github.com/denisenkom/go-mssqldb v0.12.3 h1:pBSGx9Tq67pBOTLmxNuirNTeB8Vjmf886Kx+ github.com/denisenkom/go-mssqldb v0.12.3/go.mod h1:k0mtMFOnU+AihqFxPMiF05rtiDrorD1Vrm1KEz5hxDo= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4 h1:bVaiYo8amn7Lu93sz6mTlYB3EtLG9aRcMnM1Eps8fmM= github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E= github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8= github.com/dsoprea/go-exif/v3 v3.0.0-20200717071058-9393e7afd446/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk= @@ -107,6 +174,7 @@ github.com/dsoprea/go-png-image-structure/v2 v2.0.0-20210512210324-29b889a6093d github.com/dsoprea/go-png-image-structure/v2 v2.0.0-20210512210324-29b889a6093d/go.mod h1:scnx0wQSM7UiCMK66dSdiPZvL2hl6iF5DvpZ7uT59MY= github.com/dsoprea/go-tiff-image-structure/v2 v2.0.0-20221003165014-8ecc4f52edca h1:ag7px8g8TlKi2E7wVEdJB4DXg+K5CEKiDGQXbn0bWIo= github.com/dsoprea/go-tiff-image-structure/v2 v2.0.0-20221003165014-8ecc4f52edca/go.mod h1:Ami42nLqJrb3jlvAoWXxBfMaGgOTAa0lrGBkDvmIIR4= +github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf h1:/w4QxepU4AHh3AuO6/g8y/YIIHH5+aKP3Bj8sg5cqhU= github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8= github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU= github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc= @@ -117,16 +185,25 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ= github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473 h1:4cmBvAEBNJaGARUEs3/suWRyfyBfhf7I60WBZq+bv2w= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/gzip v1.2.6 h1:OtN8DplD5DNZCSLAnQ5HxRkD2qZ5VU+JhOrcfJrcRvg= @@ -147,7 +224,9 @@ github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72 h1:b+9H1GAsx5RsjvDFLoS5zkNBzIQMuVKUYQDmxU3N5XE= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= @@ -165,6 +244,7 @@ github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZL github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= @@ -199,6 +279,8 @@ github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF 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= +github.com/goccmack/gocc v1.0.2 h1:PHv20lcM1Erz+kovS+c07DnDFp6X5cvghndtTXuEyfE= +github.com/goccmack/gocc v1.0.2/go.mod h1:LXX2tFVUggS/Zgx/ICPOr3MLyusuM7EcbfkPvNsjdO8= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -210,6 +292,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= @@ -219,15 +303,22 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -236,23 +327,38 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v31 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo= +github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/go-units v0.0.0-20250612230646-eddd77f68220 h1:hM8xVjUr4Iv/iQIx4Jq1xckZkKlXu51Gqku5HlEpQAE= +github.com/google/go-units v0.0.0-20250612230646-eddd77f68220/go.mod h1:wBcRMlRM/bVzYk9xtR2hOp3+iWOhEh1FiK8sAzeR9eA= 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.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= 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= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12 h1:TgXhFz35pKlZuUz1pNlOKk1UCSXPpuUIc144Wd7SxCA= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -264,7 +370,9 @@ github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOn github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6 h1:UDMh68UUwekSh5iP2OMhRRZJiiBccgV7axzUG8vi56c= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= @@ -278,10 +386,12 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/jdeng/goheif v0.0.0-20200323230657-a0d6a8b3e68f h1:jYkcRYsnnvPF07yn4XJx3k8duM4KDw3QYB3p8bUrk80= github.com/jdeng/goheif v0.0.0-20200323230657-a0d6a8b3e68f/go.mod h1:G7IyA3/eR9IFmUIPdyP3c0l4ZaqEvXAk876WfaQ8plc= github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= @@ -292,14 +402,24 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/karrick/godirwalk v1.17.0 h1:b4kY7nqDdioR/6qnbHQyDvmA17u5G1cZ6J+CZXwSWoI= github.com/karrick/godirwalk v1.17.0/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= +github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= @@ -309,10 +429,13 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeKl/aw8= github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= @@ -324,12 +447,18 @@ github.com/lib/pq v1.12.1 h1:x1nbl/338GLqeDJ/FAiILallhAsqubLzEZu/pXtHUow= github.com/lib/pq v1.12.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/machinebox/progress v0.2.0 h1:7z8+w32Gy1v8S6VvDoOPPBah3nLqdKjr3GUly18P8Qo= +github.com/machinebox/progress v0.2.0/go.mod h1:hl4FywxSjfmkmCrersGhmJH7KwuKl+Ueq9BXkOny+iE= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mandykoh/go-parallel v0.1.0 h1:7vJMNMC4dsbgZdkAb2A8tV5ENY1v7VxIO1wzQWZoT8k= github.com/mandykoh/go-parallel v0.1.0/go.mod h1:lkYHqG1JNTaSS6lG+PgFCnyMd2VDy8pH9jN9pY899ig= github.com/mandykoh/prism v0.35.3 h1:H9althbP8zJFC+0kuv1EMmSB3/QsAUYwnttKXNmROsI= github.com/mandykoh/prism v0.35.3/go.mod h1:XppnIliS0AUO5YVJvDbU72xot6qY3CmD6IX92ZNnJmg= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= @@ -339,6 +468,8 @@ github.com/mattn/go-runewidth v0.0.23/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.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +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/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -346,6 +477,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5 h1:8Q0qkMVC/MmWkpIdlvZgcv2o2jrlF6zqVOh7W5YHdMA= github.com/modocache/gover v0.0.0-20171022184752-b58185e213c5/go.mod h1:caMODM3PzxT8aQXRPkAt8xlV/e7d7w8GM5g0fa5F0D8= github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ= github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= @@ -355,6 +487,10 @@ github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/ github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.3.0 h1:teJvgLGUEqMzBUms+Dj3/3szNqCG/Jdw9iDbum8fR6U= @@ -363,6 +499,8 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= +github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 h1:LiZB1h0GIcudcDci2bxbqI6DXV8bF8POAnArqvRrIyw= +github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw= github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -373,7 +511,10 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2D github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= @@ -408,11 +549,16 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sevlyar/go-daemon v0.1.6 h1:EUh1MDjEM4BI109Jign0EaknA2izkOyi0LV3ro3QQGs= github.com/sevlyar/go-daemon v0.1.6/go.mod h1:6dJpPatBT9eUwM5VCw9Bt6CdX9Tk6UWvhW3MebLDRKE= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -432,6 +578,7 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= @@ -451,12 +598,23 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/wamuir/graft v0.10.0 h1:HSpBUvm7O+jwsRIuDQlw80xW4xMXRFkOiVLtWaZCU2s= github.com/wamuir/graft v0.10.0/go.mod h1:k6NJX3fCM/xzh5NtHky9USdgHTcz2vAvHp4c23I6UK4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 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.31.0 h1:1ln4YW1SFOFfGJZXe3jNOb2JUSt+l2pEneZfV8HdtFA= github.com/yalue/onnxruntime_go v1.31.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/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zitadel/logging v0.7.0 h1:eugftwMM95Wgqwftsvj81isL0JK/hoScVqp/7iA2adQ= github.com/zitadel/logging v0.7.0/go.mod h1:9A6h9feBF/3u0IhA4uffdzSDY7mBaf7RE78H5sFMINQ= @@ -469,13 +627,24 @@ go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzyb go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3 h1:8sGtKOrtQqkN1bp2AtX+misvLIlOmsEsNd+9NIcPEm8= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -513,6 +682,7 @@ golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm0 golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd h1:zkO/Lhoka23X63N9OSzpSeROEUQ5ODw47tM3YWjygbs= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -529,7 +699,10 @@ golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20241112194109-818c5a804067 h1:adDmSQyFTCiv19j015EGKJBoaa7ElV0Q1Wovb/4G7NA= +golang.org/x/lint v0.0.0-20241112194109-818c5a804067/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= @@ -629,6 +802,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -636,6 +811,9 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -687,9 +865,14 @@ golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +gonum.org/v1/plot v0.15.2 h1:Tlfh/jBk2tqjLZ4/P8ZIwGrLEWQSPDLRm/SNWKNXiGI= +gonum.org/v1/plot v0.15.2/go.mod h1:DX+x+DWso3LTha+AdkJEv5Txvi+Tql3KAGkehP0/Ubg= +gonum.org/v1/tools v0.0.0-20200318103217-c168b003ce8c h1:cJWOvXtcaFSGXz2F4z2AMM0VV7edDDGrxb5GLQH7ayQ= +gonum.org/v1/tools v0.0.0-20200318103217-c168b003ce8c/go.mod h1:fy6Otjqbk477ELp8IXTpw1cObQtLbRCBVonY+bTTfcM= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -698,10 +881,13 @@ google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsb google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ= +google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -716,6 +902,12 @@ google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvx google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -723,12 +915,15 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= @@ -746,7 +941,15 @@ honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0 h1:9JKUTTIUgS6kzR9mK1YuGKv6Nl+DijDNIc0ghT58FaY= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/entity/migrate/dialect_mysql.go b/internal/entity/migrate/dialect_mysql.go index 5334b0b23..4c79cd207 100644 --- a/internal/entity/migrate/dialect_mysql.go +++ b/internal/entity/migrate/dialect_mysql.go @@ -238,6 +238,12 @@ var DialectMySQL = Migrations{ Statements: []string{"DROP INDEX IF EXISTS idx_albums_album_path ON albums;", "ALTER TABLE albums MODIFY album_path VARBINARY(1024);", "CREATE OR REPLACE INDEX idx_albums_album_path ON albums (album_path(512));"}, }, { + ID: "20260602-000001", + Dialect: "mysql", + Stage: "main", + Statements: []string{"ALTER TABLE photos MODIFY uuid VARBINARY(255);"}, + }, + { ID: "20260612-000001", Dialect: "mysql", Stage: "main", diff --git a/internal/entity/migrate/mysql/20260602-000001.sql b/internal/entity/migrate/mysql/20260602-000001.sql new file mode 100644 index 000000000..89688eba5 --- /dev/null +++ b/internal/entity/migrate/mysql/20260602-000001.sql @@ -0,0 +1 @@ +ALTER TABLE photos MODIFY uuid VARBINARY(255); diff --git a/internal/entity/photo.go b/internal/entity/photo.go index 2e4c85089..3f0c0f0dd 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -46,7 +46,7 @@ func MapKey(takenAt time.Time, cellId string) string { // Photo represents a photo, all its properties, and link to all its images and sidecar files. type Photo struct { ID uint `gorm:"primary_key" yaml:"-"` - UUID string `gorm:"type:VARBINARY(64);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"` + UUID string `gorm:"type:VARBINARY(255);index;" json:"DocumentID,omitempty" yaml:"DocumentID,omitempty"` TakenAt time.Time `gorm:"type:DATETIME;index:idx_photos_taken_uid;" json:"TakenAt" yaml:"TakenAt"` TakenAtLocal time.Time `gorm:"type:DATETIME;" json:"TakenAtLocal" yaml:"TakenAtLocal"` TakenSrc string `gorm:"type:VARBINARY(8);" json:"TakenSrc" yaml:"TakenSrc,omitempty"` diff --git a/internal/meta/README.md b/internal/meta/README.md index bd167b39c..e2c29b8d1 100644 --- a/internal/meta/README.md +++ b/internal/meta/README.md @@ -1,6 +1,6 @@ ## PhotoPrism — Metadata Pipeline -**Last Updated:** February 11, 2026 +**Last Updated:** May 6, 2026 ### Overview @@ -27,7 +27,17 @@ The `internal/meta` package extracts, normalizes, and reports metadata from imag - Exif → XMP → JSON (ExifTool/GPhotos/motion) → filename → filesystem mtime. Each stage logs source and errors but continues when safe. - Brute-force Exif search is used when native parsers fail; errors are logged with context. -- GPS parsing supports decimal and DMS formats; regexes are kept simple and precompiled. +- GPS parsing supports decimal, DMS (`51 deg 15' 17.47" N`), and the 2-component Adobe XMP form (`52,30.4567N`); regexes are kept simple and precompiled. + +### XMP Sidecar Reader + +The `.xmp` sidecar reader (`xmp.go` + `xmp_document.go`) is XPath-based on `antchfx/xmlquery` and namespace-aware via `xpath.CompileWithNS`. Each accessor declares a `chainXPath` priority list; the engine evaluates links left-to-right and returns the first non-empty match. Composition (Lat sign from `GPSLatitudeRef`, sub-second join from `SubSecTimeOriginal`, etc.) lives in the relevant accessor — never in the chain engine. + +- **Loader security guards.** `Load` rejects sidecars larger than 1 MiB (`ErrXmpFileTooLarge`) and documents nesting deeper than 64 elements (`ErrXmpTooDeep`). XXE and DTD attacks are mitigated by `encoding/xml`'s default behaviour (no external entity resolution); `xmp_security_test.go` is the regression guard. +- **Element-or-attribute helper.** RDF/XML allows scalar properties to be expressed as either child elements or attributes on `rdf:Description`. The `elemOrAttr(qname)` helper builds a union XPath that matches both — required because digiKam emits `xmpMM:*`/`exif:*`/`tiff:*` as attributes while Adobe writes them as child elements. +- **Adding an accessor.** Declare a `chainXPath` at package init using `mustCompile` (or `elemOrAttr` for scalar fields), document the priority chain in a one-line comment, then add the accessor that calls `firstNonEmpty` (for scalars) or `queryAll` (for `rdf:Bag`/`rdf:Seq`). Wire the new field into `xmp.go` with the existing "set only when non-empty" pattern. +- **Source priority.** Sidecar values are tagged `SrcXmp` (priority 32), which outranks `SrcMeta` (priority 16) at the entity layer. Re-indexing a photo after the sidecar has been added overwrites previously-`SrcMeta` values without a database wipe. +- **Coverage.** The proposal `specs/proposals/xmp-improvement.md` and the fixture corpus under `testdata/xmp/{adobe,darktable,digikam,synthetic}/` document the full set of supported tags and their per-fixture provenance. ### Motion Photos & Embedded Media diff --git a/internal/meta/data.go b/internal/meta/data.go index 004fc8953..957ae652a 100644 --- a/internal/meta/data.go +++ b/internal/meta/data.go @@ -15,6 +15,11 @@ const ( ) // Data represents image metadata. +// +// Note: the meta:"…", xmp:"…", and dc:"…" struct tags below are read by +// internal/meta/report.go via reflection to render the metadata-source +// columns of `photoprism show metadata-fields`. Do not delete them as +// "vestigial" — they are documentation that ships in the CLI report. type Data struct { FileName string `meta:"FileName"` MimeType string `meta:"MIMEType" report:"-"` diff --git a/internal/meta/gps.go b/internal/meta/gps.go index 401584da5..58a055de7 100644 --- a/internal/meta/gps.go +++ b/internal/meta/gps.go @@ -64,7 +64,10 @@ func GpsToLatLng(s string) (lat, lng float64) { return latDeg.Decimal(), lngDeg.Decimal() } -// GpsToDecimal returns the GPS latitude or longitude as decimal float point number. +// GpsToDecimal returns the GPS latitude or longitude as a decimal +// floating-point number. Accepted forms: pure decimal ("47.6754"), +// 3-component DMS ("51 deg 15' 17.47\" N"), and 2-component +// degrees+decimal-minutes ("52,30.4567N", as Adobe XMP commonly writes). func GpsToDecimal(s string) float64 { // Empty? if s == "" { @@ -80,18 +83,34 @@ func GpsToDecimal(s string) float64 { co := GpsCoordsRegexp.FindAllString(s, -1) re := GpsRefRegexp.FindAllString(s, -1) - if len(co) != 3 || len(re) != 1 { + if len(re) != 1 { return 0 } - latDeg := exif.GpsDegrees{ - Orientation: re[0][0], - Degrees: ParseFloat(co[0]), - Minutes: ParseFloat(co[1]), - Seconds: ParseFloat(co[2]), + switch len(co) { + case 2: + // Adobe XMP 2-component form: degrees, decimal-minutes, cardinal + // direction. Seconds are folded into the minutes value already. + deg := exif.GpsDegrees{ + Orientation: re[0][0], + Degrees: ParseFloat(co[0]), + Minutes: ParseFloat(co[1]), + Seconds: 0, + } + return deg.Decimal() + case 3: + // ExifTool / EXIF 3-component DMS form: degrees, minutes, + // seconds, cardinal direction. + deg := exif.GpsDegrees{ + Orientation: re[0][0], + Degrees: ParseFloat(co[0]), + Minutes: ParseFloat(co[1]), + Seconds: ParseFloat(co[2]), + } + return deg.Decimal() + default: + return 0 } - - return latDeg.Decimal() } // ParseFloat returns a single GPS coordinate value as floating point number (degree, minute or second). @@ -113,7 +132,7 @@ func ParseFloat(s string) float64 { // NormalizeGPS normalizes the longitude and latitude of the GPS position to a generally valid range. func NormalizeGPS(lat, lng float64) (float64, float64) { if lat < LatMax || lat > LatMax || lng < LngMax || lng > LngMax { - // Clip the latitude. Normalise the longitude. + // Clip the latitude. Normalize the longitude. lat, lng = clipLat(lat), normalizeLng(lng) } diff --git a/internal/meta/gps_test.go b/internal/meta/gps_test.go index b25e38d0a..4e0292565 100644 --- a/internal/meta/gps_test.go +++ b/internal/meta/gps_test.go @@ -53,6 +53,65 @@ func TestGpsToDecimal(t *testing.T) { r := GpsToDecimal("abc") assert.Equal(t, float64(0), r) }) + t.Run("PureDecimal", func(t *testing.T) { + // Plain float passes through ParseFloat unchanged. + assert.Equal(t, 47.6754, GpsToDecimal("47.6754")) + assert.Equal(t, -47.6754, GpsToDecimal("-47.6754")) + }) + t.Run("AdobeTwoComponentNorth", func(t *testing.T) { + // 52° 30.4567'N → 52 + 30.4567/60 = 52.5076... + r := GpsToDecimal("52,30.4567N") + assert.InEpsilon(t, 52.50761166666667, r, 1e-6) + }) + t.Run("AdobeTwoComponentSouth", func(t *testing.T) { + // Cardinal S inverts the sign per exif.GpsDegrees.Decimal. + r := GpsToDecimal("27,20.4263S") + assert.InEpsilon(t, -27.340438333333333, r, 1e-6) + }) + t.Run("AdobeTwoComponentEast", func(t *testing.T) { + // 13° 24.5678'E → 13 + 24.5678/60 ≈ 13.4094633. + r := GpsToDecimal("13,24.5678E") + assert.InEpsilon(t, 13.409463333333334, r, 1e-6) + }) + t.Run("AdobeTwoComponentLeadingZeros", func(t *testing.T) { + // Adobe writes longitudes with leading zeros (031 = 31°). + r := GpsToDecimal("031,53.5529E") + assert.InEpsilon(t, 31.892548333333334, r, 1e-6) + }) + t.Run("RejectsZeroComponentsWithRef", func(t *testing.T) { + // One coordinate component plus a ref is too few to interpret. + assert.Equal(t, float64(0), GpsToDecimal("N")) + }) + t.Run("RejectsFourComponentsWithRef", func(t *testing.T) { + // More than three components is also unsupported. + assert.Equal(t, float64(0), GpsToDecimal("1 2 3 4 N")) + }) +} + +// TestGpsToDecimal_RegressionAgainstExistingFixtures asserts that +// GpsToDecimal still parses the 3-component DMS form used by the +// JSON fixtures under testdata/, so a regression surfaces here before +// it reaches the broader exif/json test suites. +func TestGpsToDecimal_RegressionAgainstExistingFixtures(t *testing.T) { + cases := []struct { + name, input string + want float64 + eps float64 + }{ + {"gopher-original Lat", `52 deg 27' 34.56" N`, 52.45960, 1e-4}, + {"gopher-original Lng", `13 deg 19' 18.48" E`, 13.32180, 1e-4}, + {"panorama360 Lat", `59 deg 50' 27.00" N`, 59.84083, 1e-4}, + {"panorama360 Lng", `30 deg 30' 36.00" E`, 30.51000, 1e-4}, + {"date.mov Lat", `55 deg 33' 48.96" N`, 55.56360, 1e-4}, + {"date.mov Lng", `37 deg 58' 56.64" E`, 37.98240, 1e-4}, + {"berlin-landscape Lat", `52 deg 27' 53.64" N`, 52.46490, 1e-4}, + {"berlin-landscape Lng", `13 deg 18' 53.28" E`, 13.31480, 1e-4}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.InDelta(t, c.want, GpsToDecimal(c.input), c.eps) + }) + } } func TestGpsCoord(t *testing.T) { diff --git a/internal/meta/json_exiftool.go b/internal/meta/json_exiftool.go index 4bb8b1c7c..ed700cee4 100644 --- a/internal/meta/json_exiftool.go +++ b/internal/meta/json_exiftool.go @@ -17,7 +17,6 @@ import ( "github.com/photoprism/photoprism/pkg/media/projection" "github.com/photoprism/photoprism/pkg/media/video" "github.com/photoprism/photoprism/pkg/rnd" - "github.com/photoprism/photoprism/pkg/time/tz" "github.com/photoprism/photoprism/pkg/txt" ) @@ -208,110 +207,10 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) { } } - hasTimeOffset := false - - // Has Media Create Date? - if !data.CreatedAt.IsZero() { - data.TakenAt = data.CreatedAt - } - - // Fallback to GPS UTC Time? - if data.TakenAt.IsZero() && data.TakenAtLocal.IsZero() && !data.TakenGps.IsZero() { - data.TimeZone = tz.UTC - data.TakenAt = data.TakenGps.UTC() - data.TakenAtLocal = time.Time{} - } - - // Check plausibility of the local <> UTC time difference. - if !data.TakenAt.IsZero() && !data.TakenAtLocal.IsZero() { - if d := data.TakenAt.Sub(data.TakenAtLocal).Abs(); d > time.Hour*27 { - log.Infof("metadata: %s has an invalid local time offset (%s)", logName, d.String()) - log.Debugf("metadata: %s was taken at %s, local time %s, create time %s, time zone %s", logName, clean.Log(data.TakenAt.UTC().String()), clean.Log(data.TakenAtLocal.String()), clean.Log(data.CreatedAt.String()), clean.Log(data.TimeZone)) - data.TakenAtLocal = data.TakenAt - data.TakenAt = data.TakenAt.UTC() - } - } - - // Has time zone offset? - if _, offset := data.TakenAtLocal.Zone(); offset != 0 && !data.TakenAtLocal.IsZero() { - hasTimeOffset = true - } else if mt, ok := data.json["MIMEType"]; ok && data.TakenAtLocal.IsZero() && (mt == MimeVideoMp4 || mt == MimeQuicktime) { - // Assume default time zone for MP4 & Quicktime videos is UTC. - // see https://exiftool.org/TagNames/QuickTime.html - log.Tracef("metadata: default time zone for %s is UTC (%s)", logName, clean.Log(mt)) - data.TimeZone = tz.UTC - data.TakenAt = data.TakenAt.UTC() - data.TakenAtLocal = time.Time{} - } - - // Set time zone and calculate UTC time. - if data.Lat != 0 && data.Lng != 0 { - if zone := tz.Position(data.Lat, data.Lng); zone != "" { - data.TimeZone = zone - } - - if loc := tz.Find(data.TimeZone); !data.TakenAtLocal.IsZero() { - if tl, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); parseErr == nil { - data.TakenAtLocal = tz.Strip(data.TakenAtLocal) - data.TakenAt = tl.Truncate(time.Second).UTC() - } else { - log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen - } - } else if !data.TakenAt.IsZero() { - if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil { - data.TakenAtLocal = localUtc - data.TakenAt = data.TakenAt.UTC() - } else { - log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen - } - } - } else if hasTimeOffset { - if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil { - data.TakenAtLocal = localUtc.Truncate(time.Second).UTC() - } - - data.TakenAt = data.TakenAt.Truncate(time.Second).UTC() - } - - // Set UTC offset as time zone? - if data.TimeZone != "" && data.TimeZone != tz.Local && data.TimeZone != tz.UTC || data.TakenAt.IsZero() { - // Don't change existing time zone. - } else if utcOffset := tz.UtcOffset(data.TakenAt, data.TakenAtLocal, data.TimeOffset); utcOffset != "" { - data.TimeZone = utcOffset - - if data.TakenAtLocal.IsZero() { - data.TakenAtLocal = tz.Strip(data.TakenAt) - } - - data.TakenAt = data.TakenAt.UTC() - log.Infof("metadata: %s has time offset %s (exiftool)", logName, clean.Log(utcOffset)) - } else if data.TimeOffset != "" { - log.Infof("metadata: %s has invalid time offset %s (exiftool)", logName, clean.Log(data.TimeOffset)) - } - - // Normalize time zone name. - data.TimeZone = tz.Name(data.TimeZone) - - // Set local time based on UTC time if empty. - if data.TakenAtLocal.IsZero() && !data.TakenAt.IsZero() { - if loc := tz.Find(data.TimeZone); loc.String() == tz.Local { - data.TakenAtLocal = tz.Strip(data.TakenAt) - data.TakenAt = data.TakenAt.UTC() - } else if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil { - data.TakenAtLocal = localUtc - data.TakenAt = data.TakenAt.UTC() - } else { - log.Errorf("metadata: %s (exiftool)", clean.Error(parseErr)) // this should never happen - } - } - - // Add nanoseconds to the calculated UTC and local time. - if data.TakenAt.Nanosecond() == 0 { - if ns := time.Duration(data.TakenNs); ns > 0 && ns <= time.Second { - data.TakenAt = data.TakenAt.Truncate(time.Second).UTC().Add(ns) - data.TakenAtLocal = data.TakenAtLocal.Truncate(time.Second).Add(ns) - } - } + // Normalize capture time, local time, and time zone using the shared + // resolver so the ExifTool JSON path and the XMP sidecar path produce + // identical entity state for the same metadata. + data.ResolveTimeZone(logName) // Use actual image width and height if available, see issue #2447. if jsonValues["ImageWidth"].Exists() && jsonValues["ImageHeight"].Exists() { diff --git a/internal/meta/resolver.go b/internal/meta/resolver.go new file mode 100644 index 000000000..935c2cabe --- /dev/null +++ b/internal/meta/resolver.go @@ -0,0 +1,130 @@ +/* +Package meta resolver.go normalizes the time-zone, capture-time, and local-time +fields on a Data receiver from any combination of CreatedAt, TakenGps, Lat/Lng, +TimeOffset, MimeType, and TakenNs already populated by an upstream reader. +Shared between the ExifTool JSON path (internal/meta/json_exiftool.go) and the +XMP sidecar path (internal/meta/xmp.go) so both flows produce identical entity +state for the same capture metadata. +*/ +package meta + +import ( + "time" + + "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/time/tz" +) + +// ResolveTimeZone normalizes TakenAt, TakenAtLocal, and TimeZone from any +// combination of CreatedAt, TakenGps, Lat/Lng, TimeOffset, MimeType, and +// TakenNs already present on the Data receiver. Safe to call from any +// metadata reader (EXIF, ExifTool JSON, XMP sidecar). Reads no external +// state beyond the receiver; writes only data.TakenAt, data.TakenAtLocal, +// and data.TimeZone. The logName argument is used for diagnostic log +// output only. +func (data *Data) ResolveTimeZone(logName string) { + hasTimeOffset := false + + // Has Media Create Date? + if !data.CreatedAt.IsZero() { + data.TakenAt = data.CreatedAt + } + + // Fallback to GPS UTC Time? + if data.TakenAt.IsZero() && data.TakenAtLocal.IsZero() && !data.TakenGps.IsZero() { + data.TimeZone = tz.UTC + data.TakenAt = data.TakenGps.UTC() + data.TakenAtLocal = time.Time{} + } + + // Check plausibility of the local <> UTC time difference. + if !data.TakenAt.IsZero() && !data.TakenAtLocal.IsZero() { + if d := data.TakenAt.Sub(data.TakenAtLocal).Abs(); d > time.Hour*27 { + log.Infof("metadata: %s has an invalid local time offset (%s)", logName, d.String()) + log.Debugf("metadata: %s was taken at %s, local time %s, create time %s, time zone %s", logName, clean.Log(data.TakenAt.UTC().String()), clean.Log(data.TakenAtLocal.String()), clean.Log(data.CreatedAt.String()), clean.Log(data.TimeZone)) + data.TakenAtLocal = data.TakenAt + data.TakenAt = data.TakenAt.UTC() + } + } + + // Has time zone offset? + if _, offset := data.TakenAtLocal.Zone(); offset != 0 && !data.TakenAtLocal.IsZero() { + hasTimeOffset = true + } else if mt := data.MimeType; mt != "" && data.TakenAtLocal.IsZero() && (mt == MimeVideoMp4 || mt == MimeQuicktime) { + // Assume default time zone for MP4 & Quicktime videos is UTC. + // see https://exiftool.org/TagNames/QuickTime.html + log.Tracef("metadata: default time zone for %s is UTC (%s)", logName, clean.Log(mt)) + data.TimeZone = tz.UTC + data.TakenAt = data.TakenAt.UTC() + data.TakenAtLocal = time.Time{} + } + + // Set time zone and calculate UTC time. + if data.Lat != 0 && data.Lng != 0 { + if zone := tz.Position(data.Lat, data.Lng); zone != "" { + data.TimeZone = zone + } + + if loc := tz.Find(data.TimeZone); !data.TakenAtLocal.IsZero() { + if tl, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), loc); parseErr == nil { + data.TakenAtLocal = tz.Strip(data.TakenAtLocal) + data.TakenAt = tl.Truncate(time.Second).UTC() + } else { + log.Errorf("metadata: %s", clean.Error(parseErr)) // this should never happen + } + } else if !data.TakenAt.IsZero() { + if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil { + data.TakenAtLocal = localUtc + data.TakenAt = data.TakenAt.UTC() + } else { + log.Errorf("metadata: %s", clean.Error(parseErr)) // this should never happen + } + } + } else if hasTimeOffset { + if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAtLocal.Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil { + data.TakenAtLocal = localUtc.Truncate(time.Second).UTC() + } + + data.TakenAt = data.TakenAt.Truncate(time.Second).UTC() + } + + // Set UTC offset as time zone? + if data.TimeZone != "" && data.TimeZone != tz.Local && data.TimeZone != tz.UTC || data.TakenAt.IsZero() { + // Don't change existing time zone. + } else if utcOffset := tz.UtcOffset(data.TakenAt, data.TakenAtLocal, data.TimeOffset); utcOffset != "" { + data.TimeZone = utcOffset + + if data.TakenAtLocal.IsZero() { + data.TakenAtLocal = tz.Strip(data.TakenAt) + } + + data.TakenAt = data.TakenAt.UTC() + log.Infof("metadata: %s has time offset %s", logName, clean.Log(utcOffset)) + } else if data.TimeOffset != "" { + log.Infof("metadata: %s has invalid time offset %s", logName, clean.Log(data.TimeOffset)) + } + + // Normalize time zone name. + data.TimeZone = tz.Name(data.TimeZone) + + // Set local time based on UTC time if empty. + if data.TakenAtLocal.IsZero() && !data.TakenAt.IsZero() { + if loc := tz.Find(data.TimeZone); loc.String() == tz.Local { + data.TakenAtLocal = tz.Strip(data.TakenAt) + data.TakenAt = data.TakenAt.UTC() + } else if localUtc, parseErr := time.ParseInLocation("2006:01:02 15:04:05", data.TakenAt.In(loc).Format("2006:01:02 15:04:05"), time.UTC); parseErr == nil { + data.TakenAtLocal = localUtc + data.TakenAt = data.TakenAt.UTC() + } else { + log.Errorf("metadata: %s", clean.Error(parseErr)) // this should never happen + } + } + + // Add nanoseconds to the calculated UTC and local time. + if data.TakenAt.Nanosecond() == 0 { + if ns := time.Duration(data.TakenNs); ns > 0 && ns <= time.Second { + data.TakenAt = data.TakenAt.Truncate(time.Second).UTC().Add(ns) + data.TakenAtLocal = data.TakenAtLocal.Truncate(time.Second).Add(ns) + } + } +} diff --git a/internal/meta/resolver_test.go b/internal/meta/resolver_test.go new file mode 100644 index 000000000..fab5b922a --- /dev/null +++ b/internal/meta/resolver_test.go @@ -0,0 +1,151 @@ +package meta + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// TestData_ResolveTimeZone exercises each branch of (*Data).ResolveTimeZone in +// isolation so a regression in either the EXIF/ExifTool path or the XMP path +// surfaces as a focused unit-test failure rather than as a downstream entity +// mismatch. +func TestData_ResolveTimeZone(t *testing.T) { + t.Run("CreatedAtOverridesTakenAt", func(t *testing.T) { + // Mirrors the Media Create Date branch (resolver line ~215): a + // non-zero CreatedAt wins over the parsed TakenAt for videos and + // other containers where the "create" tag is authoritative. + created := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + other := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + data := Data{CreatedAt: created, TakenAt: other, TakenAtLocal: other} + + data.ResolveTimeZone("test.jpg") + + assert.Equal(t, created, data.TakenAt) + }) + t.Run("GpsUtcFallback", func(t *testing.T) { + // No TakenAt / TakenAtLocal, but GPSDateTime is present — the + // resolver promotes the GPS UTC timestamp and clears any stale + // local time. + gps := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + data := Data{TakenGps: gps} + + data.ResolveTimeZone("test.jpg") + + assert.Equal(t, gps, data.TakenAt) + assert.Equal(t, "UTC", data.TimeZone) + assert.True(t, data.TakenAtLocal.IsZero() || data.TakenAtLocal.Equal(gps)) + }) + t.Run("PlausibilityCheckFork", func(t *testing.T) { + // Local and UTC time differ by >27h → the resolver assumes the + // local timestamp is bogus and forces it to mirror the UTC value. + utc := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + local := time.Date(2024, 6, 17, 12, 0, 0, 0, time.UTC) + data := Data{TakenAt: utc, TakenAtLocal: local} + + data.ResolveTimeZone("test.jpg") + + assert.Equal(t, data.TakenAt.UTC(), data.TakenAtLocal.UTC()) + }) + t.Run("Mp4DefaultsToUtc", func(t *testing.T) { + // MP4 containers conventionally store UTC timestamps with no + // explicit zone, so the resolver flips to UTC when MimeType + // matches and no local time is set. + taken := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + data := Data{TakenAt: taken, MimeType: MimeVideoMp4} + + data.ResolveTimeZone("test.mp4") + + assert.Equal(t, "UTC", data.TimeZone) + }) + t.Run("QuicktimeDefaultsToUtc", func(t *testing.T) { + taken := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + data := Data{TakenAt: taken, MimeType: MimeQuicktime} + + data.ResolveTimeZone("test.mov") + + assert.Equal(t, "UTC", data.TimeZone) + }) + t.Run("GpsResolvesZoneAndLocal", func(t *testing.T) { + // Berlin GPS + Berlin wall-clock. Resolver picks the IANA zone + // from coordinates and derives the UTC instant from the local + // time (CET is +01:00 in January). + berlin := time.FixedZone("CET", 3600) + local := time.Date(2024, 1, 15, 17, 28, 25, 0, berlin) + data := Data{ + TakenAt: local, + TakenAtLocal: local, + Lat: 52.5, + Lng: 13.4, + } + + data.ResolveTimeZone("test.jpg") + + assert.Equal(t, "Europe/Berlin", data.TimeZone) + assert.Equal(t, time.Date(2024, 1, 15, 16, 28, 25, 0, time.UTC), data.TakenAt.UTC()) + assert.Equal(t, "2024-01-15 17:28:25", data.TakenAtLocal.Format("2006-01-02 15:04:05")) + }) + t.Run("OffsetResolvesZone", func(t *testing.T) { + // No GPS, no wall-clock zone — the resolver falls back to the + // TimeOffset string ("+02:00") to derive a fixed-offset zone. + taken := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + data := Data{ + TakenAt: taken, + TakenAtLocal: taken, + TimeOffset: "+02:00", + } + + data.ResolveTimeZone("test.jpg") + + assert.Equal(t, "UTC+2", data.TimeZone) + }) + t.Run("FallbackLocalFromUtc", func(t *testing.T) { + // TakenAtLocal zero, TakenAt set, no GPS/offset — resolver fills + // in TakenAtLocal so downstream consumers always see a value. + taken := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + data := Data{TakenAt: taken} + + data.ResolveTimeZone("test.jpg") + + assert.False(t, data.TakenAtLocal.IsZero()) + }) + t.Run("NanosAppliedToBoth", func(t *testing.T) { + // Sub-second precision lives in TakenNs (mirroring SubSecTimeOriginal). + // The resolver applies it to both TakenAt and TakenAtLocal when the + // truncated UTC time has no nanoseconds yet, preserving entity-layer + // parity across the two timestamp columns. + taken := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + data := Data{ + TakenAt: taken, + TakenAtLocal: taken, + TakenNs: 123456789, + } + + data.ResolveTimeZone("test.jpg") + + assert.Equal(t, 123456789, data.TakenAt.Nanosecond()) + assert.Equal(t, 123456789, data.TakenAtLocal.Nanosecond()) + }) + t.Run("NoopOnEmpty", func(t *testing.T) { + // All time fields zero — the resolver must not panic and must + // leave the receiver in a coherent (still empty) state. + data := Data{} + + assert.NotPanics(t, func() { data.ResolveTimeZone("test.jpg") }) + + assert.True(t, data.TakenAt.IsZero()) + assert.True(t, data.TakenAtLocal.IsZero()) + }) + t.Run("PreservesTakenAtIfNonZero", func(t *testing.T) { + // CreatedAt empty, TakenAt populated — the resolver must not blank + // out a non-zero TakenAt just because CreatedAt is missing. + taken := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + data := Data{TakenAt: taken, TakenAtLocal: taken} + + data.ResolveTimeZone("test.jpg") + + assert.False(t, data.TakenAt.IsZero()) + assert.Equal(t, taken.UTC().Format("2006-01-02 15:04:05"), data.TakenAt.UTC().Format("2006-01-02 15:04:05")) + }) +} diff --git a/internal/meta/testdata/xmp/README.md b/internal/meta/testdata/xmp/README.md new file mode 100644 index 000000000..01f15a03a --- /dev/null +++ b/internal/meta/testdata/xmp/README.md @@ -0,0 +1,45 @@ +# XMP Fixture Corpus + +Test fixtures for the `internal/meta` XMP sidecar reader rewrite (issue #2260, proposal `specs/proposals/xmp-improvement.md`). + +Each fixture is paired with an `.exiftool.txt` reference (output of `exiftool -X `) that captures the canonical interpretation; regression tests compare reader output against the same XMP read through ExifTool. + +## Layout + +| Directory | Source | Purpose | +|--------------|-----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| `adobe/` | Adobe Bridge / Lightroom / Camera Raw exports | Reference implementation. Covers the full EXIF camera/lens/exposure surface, GPS in 2-component Adobe form, and the `xmpMM:*` triple. | +| `darktable/` | Darktable lighttable + map mode | Open-source writer with `` for `dc:subject`, vendor `darktable:*` history, and the 2-component GPS form. | +| `digikam/` | digiKam Edit Metadata dialog | Open-source writer with `xmpRights:UsageTerms` (License), `xmpMM:DocumentID/InstanceID/OriginalDocumentID` triple, and section-2 noise. | +| `synthetic/` | Hand-written, validated with `exiftool -X` | Targeted coverage for tags absent in the tool fixtures and edge cases that real writers don't reliably produce. | + +## Regenerating Exiftool References + +After editing a fixture: + + for f in internal/meta/testdata/xmp/{adobe,darktable,digikam,synthetic}/*.xmp; do + exiftool -X "$f" > "${f%.xmp}.exiftool.txt" + done + +## Synthetic Fixture Index + +| File | Purpose | +|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `software-only.xmp` | `xmp:CreatorTool` (Software). Single-tag fixture for the simplest accessor. | +| `gps-time-combined.xmp` | `exif:GPSTimeStamp` as a single combined ISO 8601 / RFC 3339 datetime — the spec-canonical encoding for `TakenGps`. | +| `gps-time-split.xmp` | Legacy split form: `exif:GPSDateStamp` + `exif:GPSTimeStamp`. Some older writers emit this; secondary `TakenGps` fallback. | +| `time-offsets-subsec.xmp` | `exif:OffsetTimeOriginal/OffsetTime/OffsetTimeDigitized` cascade plus `exif:SubSecTimeOriginal` joined into `TakenAt`. | +| `notes-usercomment.xmp` | `exif:UserComment` as `lang-alt` (`` with `x-default` + `en` + `de`). Confirms reader prefers `x-default`. | +| `xmpdm-creationdate.xmp` | `xmpDM:CreationDate` — secondary fallback for `CreatedAt` (rare; emitted by Adobe Premiere/After Effects). | +| `subject-seq.xmp` | `dc:subject` as `` instead of ``. Confirms reader handles both list types. | +| `aux-only.xmp` | Adobe `aux:` namespace — `OwnerName`, `Lens`, `LensID`, `LensSerialNumber`, `SerialNumber`, `Firmware`. Legacy Lightroom/ACR. | +| `exifex-camera-lens.xmp` | EXIF 2.3 for XMP `exifEX:` namespace — `LensMake`, `LensModel`, `SerialNumber` (= EXIF BodySerialNumber), `PhotographicSensitivity`, etc. | +| `gpano-360.xmp` | Google Photo Sphere — `GPano:ProjectionType = equirectangular` plus required dimension fields. | +| `multi-rdf-description.xmp` | Bug 2 demonstration: four sibling `` blocks each declaring a different namespace. Tests that XPath walks all blocks. | +| `alt-edge-cases.xmp` | `` shapes that real writers don't always produce: no `x-default` (only `de`/`en`), missing `xml:lang`, duplicate `x-default`. | + +## Source Notes + +- `digikam/aurora.jpg.xmp` deliberately includes section-2 (out-of-scope) tags such as `xmp:Label`, `digiKam:TagsList`, `lr:hierarchicalSubject`, `MicrosoftPhoto:LastKeywordXMP`, and `xmpMM:History`. These are negative-test bait — the reader must **not** persist them. +- `darktable/aurora.jpg.xmp` includes the `` develop stack (also section-2 ignored) and uses the 2-component Adobe GPS form (`87,21.291962N`) that today's `GpsToDecimal` silently drops. +- Adobe Bridge fixtures emit `` as a struct with `` sub-field — the only fixtures in the corpus that exercise the `Flash` composition rule. diff --git a/internal/meta/testdata/xmp/adobe/bridge-2.exiftool.txt b/internal/meta/testdata/xmp/adobe/bridge-2.exiftool.txt new file mode 100644 index 000000000..b97b3af62 --- /dev/null +++ b/internal/meta/testdata/xmp/adobe/bridge-2.exiftool.txt @@ -0,0 +1,129 @@ + + + + + 13.25 + bridge-2.xmp + internal/meta/testdata/xmp/adobe + 8.3 kB + 2026:05:06 06:39:34+00:00 + 2026:05:06 06:41:40+00:00 + 2026:05:06 06:42:06+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + Adobe XMP Core 9.1-c002 165.59ab891, 2024/09/18-09:57:10 + 0231 + 1599 + 1066 + Uncalibrated + 52 deg 26' 35.09" N + MANUAL + 13 deg 34' 35.81" E + Above Sea Level + 15000 m + 2021:01:24 08:28:28+01:00 + Horizontal (normal) + 1599 + 1066 + RGB + 3 + 1 + 1 + None + + + 8 + 8 + 8 + + + 7386F4EF875E59E482D08597C0D25DB7 + 7386F4EF875E59E482D08597C0D25DB7 + xmp.iid:5b51221e-0c75-412c-93da-f71cb4a8a0d7 + + + saved + saved + + + + + xmp.iid:113d1054-020b-4d10-bc66-38aff071f520 + xmp.iid:5b51221e-0c75-412c-93da-f71cb4a8a0d7 + + + + + 2025:07:25 14:28:38+02:00 + 2026:04:28 11:54:38+02:00 + + + + + Adobe Photoshop Camera Raw 17.4 + Adobe Photoshop Camera Raw 17.4 + + + + + /metadata + /metadata + + + image/jpeg + Theresa Creator + Description from Theresa + + + keyworda + keywordb + keywordc + + + Document Title Created with Bridge + copyright notice + RGB + Bridge Job Title + Headline from Photoshop + 2026:05:01 02:30:15 + Review + 2026:04:28 11:54:38+02:00 + 4 + legal url.de + right usage terms + Some Street + Berlin + Berlin + 12163 + Deutschland + 030/123456 + test@mail.com + www.website.de + nice alt text + extended description + 2021:01:24 03:28:28+01:00 + 1599x1066 + 1.7 + 15000 m Above Sea Level + North + East + 52 deg 26' 35.09" N, 13 deg 34' 35.81" E + + diff --git a/internal/meta/testdata/xmp/adobe/bridge-2.xmp b/internal/meta/testdata/xmp/adobe/bridge-2.xmp new file mode 100644 index 000000000..5aaf0f02e --- /dev/null +++ b/internal/meta/testdata/xmp/adobe/bridge-2.xmp @@ -0,0 +1,147 @@ + + + + + 0231 + 1599 + 1066 + 65535 + 52,26.5849N + MANUAL + 013,34.5968E + 0 + 1500000/100 + 2021-01-24T08:28:28+01:00 + 1 + 1599 + 1066 + 2 + 3 + 1/1 + 1/1 + 1 + + + 8 + 8 + 8 + + + 7386F4EF875E59E482D08597C0D25DB7 + 7386F4EF875E59E482D08597C0D25DB7 + xmp.iid:5b51221e-0c75-412c-93da-f71cb4a8a0d7 + + + + saved + xmp.iid:113d1054-020b-4d10-bc66-38aff071f520 + 2025-07-25T14:28:38+02:00 + Adobe Photoshop Camera Raw 17.4 + /metadata + + + saved + xmp.iid:5b51221e-0c75-412c-93da-f71cb4a8a0d7 + 2026-04-28T11:54:38+02:00 + Adobe Photoshop Camera Raw 17.4 + /metadata + + + + image/jpeg + + + Theresa Creator + + + + + Description from Theresa + + + + + keyworda + keywordb + keywordc + + + + + Document Title Created with Bridge + + + + + copyright notice + + + 3 + Bridge Job Title + Headline from Photoshop + 2026-05-01T02:30:15 + Review + 2026-04-28T11:54:38+02:00 + 4 + legal url.de + + + right usage terms + + + + Some Street + Berlin + Berlin + 12163 + Deutschland + 030/123456 + test@mail.com + www.website.de + + + + nice alt text + + + + + extended description + + + 2021-01-24T03:28:28+01:00 + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/meta/testdata/xmp/adobe/bridge.exiftool.txt b/internal/meta/testdata/xmp/adobe/bridge.exiftool.txt new file mode 100644 index 000000000..d586e16a4 --- /dev/null +++ b/internal/meta/testdata/xmp/adobe/bridge.exiftool.txt @@ -0,0 +1,130 @@ + + + + + 13.25 + bridge.xmp + internal/meta/testdata/xmp/adobe + 8.1 kB + 2026:05:06 06:39:32+00:00 + 2026:05:06 06:42:01+00:00 + 2026:05:06 06:42:06+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + Adobe XMP Core 9.1-c002 165.59ab891, 2024/09/18-09:57:10 + 0 + 2026:03:22 16:32:42.55 + 2026:03:22 16:32:42.55 + 2026:04:28 12:02:29+02:00 + Canon + Canon EOS M6 + Horizontal (normal) + 6000 + 4000 + RGB + 3 + + + 16 + 16 + 16 + + + 0230 + 1/100 + 1/100 + 8.0 + 8.0 + Program AE + 2 + 100 + 0 + 6.3 + Multi-segment + 45.0 mm + One-chip color area + Digital Camera + Normal + Auto + Auto + Standard + 1 + 2688.72659301758 + 2688.72659301758 + cm + 2026:03:22 16:32:42.55 + 100 + False + No return detection + Off + False + False + 6000 + 4000 + 27 deg 20' 25.58" S + MANUAL + 31 deg 53' 33.17" E + Above Sea Level + 13300 m + John Doe + Attribution 4.0 International (CC BY 4.0) + image/x-canon-cr2 + 453051001355 + 15-45mm f/? + EF-M15-45mm f/3.5-6.3 IS STM + 4153 + 0000027443 + 0 + infinity + 0 + 1.00 + EF-M15-45mm f/3.5-6.3 IS STM + 2026:03:22 16:32:42.55 + RGB + + 804D95FE1D9E0CAB7430688EB0347B77 + 804D95FE1D9E0CAB7430688EB0347B77 + xmp.iid:327258bf-032d-4acd-9a2b-aa12611d3dfb + saved + xmp.iid:327258bf-032d-4acd-9a2b-aa12611d3dfb + 2026:04:28 12:02:29+02:00 + Adobe Photoshop Camera Raw 17.4 + /metadata + Camera Auto + + 8.0 + 6000x4000 + 24.0 + 1.6 + 1/100 + 13300 m Above Sea Level + Off, Did not fire + South + East + 0.019 mm + inf (13.59 m - inf) + 27.9 deg + 45.0 mm (35 mm equivalent: 72.6 mm) + 27 deg 20' 25.58" S, 31 deg 53' 33.17" E + 13.59 m + 12.6 + Canon EF-M 15-45mm f/3.5-6.3 IS STM + + diff --git a/internal/meta/testdata/xmp/adobe/bridge.xmp b/internal/meta/testdata/xmp/adobe/bridge.xmp new file mode 100644 index 000000000..dc3ddd89b --- /dev/null +++ b/internal/meta/testdata/xmp/adobe/bridge.xmp @@ -0,0 +1,139 @@ + + + + + 0 + 2026-03-22T16:32:42.55 + 2026-03-22T16:32:42.55 + 2026-04-28T12:02:29+02:00 + Canon + Canon EOS M6 + 1 + 6000 + 4000 + 2 + 3 + + + 16 + 16 + 16 + + + 0230 + 1/100 + 6643856/1000000 + 8/1 + 6/1 + 2 + 2 + 100 + 0/3 + 53125/10000 + 5 + 45/1 + 2 + 3 + 0 + 0 + 0 + 0 + 6000/6000 + 88104193/32768 + 88104193/32768 + 3 + 2026-03-22T16:32:42.55 + + + 100 + + + + False + 0 + 2 + False + False + + 6000 + 4000 + 27,20.4263S + MANUAL + 031,53.5529E + 0 + 1330000/100 + + + John Doe + + + + + Attribution 4.0 International (CC BY 4.0) + + + image/x-canon-cr2 + 453051001355 + 15/1 45/1 0/0 0/0 + EF-M15-45mm f/3.5-6.3 IS STM + 4153 + 0000027443 + 0 + 4294967295/1 + 0/1 + 1.00 + EF-M15-45mm f/3.5-6.3 IS STM + 2026-03-22T16:32:42.55 + 3 + + 804D95FE1D9E0CAB7430688EB0347B77 + 804D95FE1D9E0CAB7430688EB0347B77 + xmp.iid:327258bf-032d-4acd-9a2b-aa12611d3dfb + + + + saved + xmp.iid:327258bf-032d-4acd-9a2b-aa12611d3dfb + 2026-04-28T12:02:29+02:00 + Adobe Photoshop Camera Raw 17.4 + /metadata + + + + Camera Auto + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/meta/testdata/xmp/darktable/aurora.jpg.exiftool.txt b/internal/meta/testdata/xmp/darktable/aurora.jpg.exiftool.txt new file mode 100644 index 000000000..81c898fce --- /dev/null +++ b/internal/meta/testdata/xmp/darktable/aurora.jpg.exiftool.txt @@ -0,0 +1,118 @@ + + + + + 13.25 + aurora.jpg.xmp + internal/meta/testdata/xmp/darktable + 4.3 kB + 2026:05:06 06:49:32+00:00 + 2026:05:06 06:50:54+00:00 + 2026:05:06 06:53:16+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + XMP Core 4.4.0-Exiv2 + + 2.2.0.0 + 179 deg 59' 32.81" W + 87 deg 21' 17.52" N + aurora.jpg + 1 + 63913646604991295 + -1 + 63913646961369095 + -1 + 5 + 0 + 1 + 4 + 5 + 02d4cdbda625305c5e181669466f51d2 + 02d4cdbda625305c5e181669466f51d2 + + 0 + colorin + 1 + 7 + + darktable:multi_name= + 0 + 0 + 14 + 1 + colorout + 1 + 5 + gz35eJxjZBgFo4CBAQAEEAAC + + 0 + 0 + 14 + 2 + gamma + 1 + 1 + 0000000000000000 + + 0 + 0 + 14 + 3 + flip + 1 + 2 + ffffffff + _builtin_auto + 0 + 0 + 14 + This is notes + PhotoPrism + PhotoPrism + XMP Test - Aurora + Test fixture for darktable XMP sidecar + CC-BY-SA 4.0 + + + Aurora + Iceland + Nature + Places + Reykjavik + darktable + exported + format + jpg + + + + + Aurora + Iceland + Nature + Places + Reykjavik + darktable|exported + darktable|format|jpg + + + North + West + 87 deg 21' 17.52" N, 179 deg 59' 32.81" W + + diff --git a/internal/meta/testdata/xmp/darktable/aurora.jpg.xmp b/internal/meta/testdata/xmp/darktable/aurora.jpg.xmp new file mode 100644 index 000000000..bd5fd3719 --- /dev/null +++ b/internal/meta/testdata/xmp/darktable/aurora.jpg.xmp @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + PhotoPrism + + + + + PhotoPrism + + + + + XMP Test - Aurora + + + + + Test fixture for darktable XMP sidecar + + + + + CC-BY-SA 4.0 + + + + + Aurora + Iceland + Nature + Places + Reykjavik + darktable + exported + format + jpg + + + + + Aurora + Iceland + Nature + Places + Reykjavik + darktable|exported + darktable|format|jpg + + + + + diff --git a/internal/meta/testdata/xmp/digikam/aurora.jpg.exiftool.txt b/internal/meta/testdata/xmp/digikam/aurora.jpg.exiftool.txt new file mode 100644 index 000000000..16b78b91c --- /dev/null +++ b/internal/meta/testdata/xmp/digikam/aurora.jpg.exiftool.txt @@ -0,0 +1,155 @@ + + + + + 13.25 + [minor] Fixed incorrect URI for xmlns:MicrosoftPhoto + aurora.jpg.xmp + internal/meta/testdata/xmp/digikam + 5.5 kB + 2026:05:06 07:18:19+00:00 + 2026:05:06 07:18:22+00:00 + 2026:05:06 07:18:25+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + XMP Core 4.4.0-Exiv2 + adobe:docid:photoshop:7d592d87-eb1e-1040-809a-e16c6b85b3fd + xmp.iid:de0fb44d-bdc8-4cc3-aa50-9aefe4992b34 + 254738CA43CD69C01101874D65B006B4 + + + saved + saved + + + + + xmp.iid:380d6b1a-8ce6-4774-b1c0-f41dfa325e41 + xmp.iid:de0fb44d-bdc8-4cc3-aa50-9aefe4992b34 + + + + + 2021:10:29 13:42+02:00 + 2021:10:29 13:42+02:00 + + + + + Adobe Photoshop 22.3 (Macintosh) + Adobe Photoshop 22.3 (Macintosh) + + + + + / + / + + + image/jpeg + XMP Test - Aurora + + + Nature + Places + Reykjavik + Iceland + Aurora + + + (C) 2026 PhotoPrism — Test fixture + Test fixture for digiKam XMP sidecar + RGB + sRGB IEC61966-2.1 + 0 (reserved) + 2021:10:27 10:43:46 + PhotoPrism + PhotoPrism + 2021:10:27 10:43:46 + 2021:10:27 10:43:46 + NoColor + 2021:10:27 10:43:46 + 2021:10:27 10:43:46 + 0231 + sRGB + 2560 + 868 + Test fixture for digiKam XMP sidecar + 2021:10:27 10:43:46 + 4774 + 2889 + Horizontal (normal) + 3 + 72 + 72 + inches + Adobe Photoshop 22.3 (Macintosh) + Test fixture for digiKam XMP sidecar + (C) 2026 PhotoPrism — Test fixture + 8 8 8 + 0 + + + Nature + Places + Reykjavik + Iceland + Aurora + + + 2026-05-06T09:07:11 + XMP Test - Aurora + Test fixture for digiKam XMP sidecar + <Categories><Category Assigned="1">Nature</Category><Category Assigned="1">Places</Category><Category Assigned="1">Reykjavik</Category><Category Assigned="1">Iceland</Category><Category Assigned="1">Aurora</Category></Categories> + + + Nature + Places + Reykjavik + Iceland + Aurora + + + + + Nature + Places + Reykjavik + Iceland + Aurora + + + + + Nature + Places + Reykjavik + Iceland + Aurora + + + CC-BY-SA 4.0 + 4774x2889 + 13.8 + + diff --git a/internal/meta/testdata/xmp/digikam/aurora.jpg.xmp b/internal/meta/testdata/xmp/digikam/aurora.jpg.xmp new file mode 100644 index 000000000..d1f1523b9 --- /dev/null +++ b/internal/meta/testdata/xmp/digikam/aurora.jpg.xmp @@ -0,0 +1,159 @@ + + + + + + + + + + + + + XMP Test - Aurora + + + + + Nature + Places + Reykjavik + Iceland + Aurora + + + + + (C) 2026 PhotoPrism — Test fixture + + + + + Test fixture for digiKam XMP sidecar + + + + + Test fixture for digiKam XMP sidecar + + + + + Test fixture for digiKam XMP sidecar + + + + + (C) 2026 PhotoPrism — Test fixture + + + + + 8 8 8 + + + + + Nature + Places + Reykjavik + Iceland + Aurora + + + + + 2026-05-06T09:07:11 + + + + + Nature + Places + Reykjavik + Iceland + Aurora + + + + + Nature + Places + Reykjavik + Iceland + Aurora + + + + + Nature + Places + Reykjavik + Iceland + Aurora + + + + + CC-BY-SA 4.0 + + + + + + \ No newline at end of file diff --git a/internal/meta/testdata/xmp/synthetic/alt-edge-cases.exiftool.txt b/internal/meta/testdata/xmp/synthetic/alt-edge-cases.exiftool.txt new file mode 100644 index 000000000..2926f8663 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/alt-edge-cases.exiftool.txt @@ -0,0 +1,32 @@ + + + + + 13.25 + alt-edge-cases.xmp + internal/meta/testdata/xmp/synthetic + 991 bytes + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + Sonnenuntergang + Sunset + No xml:lang attribute on this li + First x-default rights + Second x-default rights (duplicate, should be ignored) + English rights + Photoshop Headline Fallback + + diff --git a/internal/meta/testdata/xmp/synthetic/alt-edge-cases.xmp b/internal/meta/testdata/xmp/synthetic/alt-edge-cases.xmp new file mode 100644 index 000000000..ca6f5ea2c --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/alt-edge-cases.xmp @@ -0,0 +1,28 @@ + + + + + + + Sonnenuntergang + Sunset + + + + + No xml:lang attribute on this li + + + + + First x-default rights + Second x-default rights (duplicate, should be ignored) + English rights + + + Photoshop Headline Fallback + + + diff --git a/internal/meta/testdata/xmp/synthetic/aux-only.exiftool.txt b/internal/meta/testdata/xmp/synthetic/aux-only.exiftool.txt new file mode 100644 index 000000000..18732f6de --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/aux-only.exiftool.txt @@ -0,0 +1,32 @@ + + + + + 13.25 + aux-only.xmp + internal/meta/testdata/xmp/synthetic + 602 bytes + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + Synthetic Photographer + 50.0 mm f/1.4 + 123 + LENS-SN-987654 + BODY-SN-123456 + Firmware 2.10 + 50.0mm f/1.4 + + diff --git a/internal/meta/testdata/xmp/synthetic/aux-only.xmp b/internal/meta/testdata/xmp/synthetic/aux-only.xmp new file mode 100644 index 000000000..1dc032056 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/aux-only.xmp @@ -0,0 +1,14 @@ + + + + + Synthetic Photographer + 50.0 mm f/1.4 + 123 + LENS-SN-987654 + BODY-SN-123456 + Firmware 2.10 + + + diff --git a/internal/meta/testdata/xmp/synthetic/datecreated-priority.xmp b/internal/meta/testdata/xmp/synthetic/datecreated-priority.xmp new file mode 100644 index 000000000..e9dc7a864 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/datecreated-priority.xmp @@ -0,0 +1,11 @@ + + + + + 2026-01-15T10:00:00 + 2026-03-20T18:30:00 + + + diff --git a/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.exiftool.txt b/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.exiftool.txt new file mode 100644 index 000000000..fc484f4b9 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.exiftool.txt @@ -0,0 +1,36 @@ + + + + + 13.25 + exifex-camera-lens.xmp + internal/meta/testdata/xmp/synthetic + 871 bytes + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + SyntheticCam + SC-1 Mark II + SyntheticLens Co. + SL 50mm f/1.4 + SL-50-987654 + SC1-BODY-123456 + 800 + Recommended Exposure Index + 800 + SL 50mm f/1.4 + + diff --git a/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp b/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp new file mode 100644 index 000000000..cbfbcb9a9 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/exifex-camera-lens.xmp @@ -0,0 +1,18 @@ + + + + + SyntheticCam + SC-1 Mark II + SyntheticLens Co. + SL 50mm f/1.4 + SL-50-987654 + SC1-BODY-123456 + 800 + 2 + 800 + + + diff --git a/internal/meta/testdata/xmp/synthetic/gpano-360.exiftool.txt b/internal/meta/testdata/xmp/synthetic/gpano-360.exiftool.txt new file mode 100644 index 000000000..f535917ce --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/gpano-360.exiftool.txt @@ -0,0 +1,32 @@ + + + + + 13.25 + gpano-360.xmp + internal/meta/testdata/xmp/synthetic + 858 bytes + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + equirectangular + True + 4096 + 2048 + 4096 + 2048 + 0 + 0 + + diff --git a/internal/meta/testdata/xmp/synthetic/gpano-360.xmp b/internal/meta/testdata/xmp/synthetic/gpano-360.xmp new file mode 100644 index 000000000..700f19262 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/gpano-360.xmp @@ -0,0 +1,16 @@ + + + + + equirectangular + True + 4096 + 2048 + 4096 + 2048 + 0 + 0 + + + diff --git a/internal/meta/testdata/xmp/synthetic/gps-time-combined.exiftool.txt b/internal/meta/testdata/xmp/synthetic/gps-time-combined.exiftool.txt new file mode 100644 index 000000000..ed08fbfc0 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/gps-time-combined.exiftool.txt @@ -0,0 +1,35 @@ + + + + + 13.25 + gps-time-combined.xmp + internal/meta/testdata/xmp/synthetic + 624 bytes + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + 2.2.0.0 + 52 deg 30' 27.40" N + 13 deg 24' 34.07" E + Above Sea Level + 34.5 m + 2026:05:06 15:42:18Z + 34.5 m Above Sea Level + North + East + 52 deg 30' 27.40" N, 13 deg 24' 34.07" E + + diff --git a/internal/meta/testdata/xmp/synthetic/gps-time-combined.xmp b/internal/meta/testdata/xmp/synthetic/gps-time-combined.xmp new file mode 100644 index 000000000..02ad71c79 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/gps-time-combined.xmp @@ -0,0 +1,14 @@ + + + + + 2.2.0.0 + 52,30.4567N + 13,24.5678E + 0 + 3450/100 + 2026-05-06T15:42:18Z + + + diff --git a/internal/meta/testdata/xmp/synthetic/gps-time-split.exiftool.txt b/internal/meta/testdata/xmp/synthetic/gps-time-split.exiftool.txt new file mode 100644 index 000000000..01c3eebb2 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/gps-time-split.exiftool.txt @@ -0,0 +1,36 @@ + + + + + 13.25 + gps-time-split.xmp + internal/meta/testdata/xmp/synthetic + 665 bytes + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + 2.2.0.0 + 52 deg 30' 27.40" N + 13 deg 24' 34.07" E + Above Sea Level + 34.5 m + 2026:05:06 + 15:42:18 + 34.5 m Above Sea Level + North + East + 52 deg 30' 27.40" N, 13 deg 24' 34.07" E + + diff --git a/internal/meta/testdata/xmp/synthetic/gps-time-split.xmp b/internal/meta/testdata/xmp/synthetic/gps-time-split.xmp new file mode 100644 index 000000000..d159b4bb5 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/gps-time-split.xmp @@ -0,0 +1,15 @@ + + + + + 2.2.0.0 + 52,30.4567N + 13,24.5678E + 0 + 3450/100 + 2026:05:06 + 15:42:18 + + + diff --git a/internal/meta/testdata/xmp/synthetic/multi-rdf-description.exiftool.txt b/internal/meta/testdata/xmp/synthetic/multi-rdf-description.exiftool.txt new file mode 100644 index 000000000..5c00d65e3 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/multi-rdf-description.exiftool.txt @@ -0,0 +1,43 @@ + + + + + 13.25 + multi-rdf-description.xmp + internal/meta/testdata/xmp/synthetic + 1383 bytes + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + 2026:05:06 07:32:31+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + Multi-Description Fixture + PhotoPrism + 52 deg 30' 27.40" N + 13 deg 24' 34.07" E + Above Sea Level + 34.5 m + SyntheticCam + SC-1 Mark II + xmp.did:11111111-2222-3333-4444-555555555555 + xmp.iid:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + 00000000-0000-0000-0000-000000000000 + 34.5 m Above Sea Level + North + East + 52 deg 30' 27.40" N, 13 deg 24' 34.07" E + + diff --git a/internal/meta/testdata/xmp/synthetic/multi-rdf-description.xmp b/internal/meta/testdata/xmp/synthetic/multi-rdf-description.xmp new file mode 100644 index 000000000..2215756bd --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/multi-rdf-description.xmp @@ -0,0 +1,36 @@ + + + + + + + Multi-Description Fixture + + + + + PhotoPrism + + + + + 52,30.4567N + 13,24.5678E + 0 + 3450/100 + + + SyntheticCam + SC-1 Mark II + + + xmp.did:11111111-2222-3333-4444-555555555555 + xmp.iid:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + 00000000-0000-0000-0000-000000000000 + + + diff --git a/internal/meta/testdata/xmp/synthetic/notes-usercomment.exiftool.txt b/internal/meta/testdata/xmp/synthetic/notes-usercomment.exiftool.txt new file mode 100644 index 000000000..2351db316 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/notes-usercomment.exiftool.txt @@ -0,0 +1,27 @@ + + + + + 13.25 + notes-usercomment.xmp + internal/meta/testdata/xmp/synthetic + 716 bytes + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + Synthetic note for the Notes accessor — exif:UserComment as lang-alt + Synthetic note for the Notes accessor — exif:UserComment as lang-alt + Synthetische Notiz für den Notes-Accessor — exif:UserComment als lang-alt + + diff --git a/internal/meta/testdata/xmp/synthetic/notes-usercomment.xmp b/internal/meta/testdata/xmp/synthetic/notes-usercomment.xmp new file mode 100644 index 000000000..be7c183e5 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/notes-usercomment.xmp @@ -0,0 +1,15 @@ + + + + + + + Synthetic note for the Notes accessor — exif:UserComment as lang-alt + Synthetic note for the Notes accessor — exif:UserComment as lang-alt + Synthetische Notiz für den Notes-Accessor — exif:UserComment als lang-alt + + + + + diff --git a/internal/meta/testdata/xmp/synthetic/software-only.exiftool.txt b/internal/meta/testdata/xmp/synthetic/software-only.exiftool.txt new file mode 100644 index 000000000..17535d348 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/software-only.exiftool.txt @@ -0,0 +1,25 @@ + + + + + 13.25 + software-only.xmp + internal/meta/testdata/xmp/synthetic + 379 bytes + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + PhotoPrism Synthetic Fixture 1.0.0 + + diff --git a/internal/meta/testdata/xmp/synthetic/software-only.xmp b/internal/meta/testdata/xmp/synthetic/software-only.xmp new file mode 100644 index 000000000..c57aaebb8 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/software-only.xmp @@ -0,0 +1,9 @@ + + + + + PhotoPrism Synthetic Fixture 1.0.0 + + + diff --git a/internal/meta/testdata/xmp/synthetic/subject-seq.exiftool.txt b/internal/meta/testdata/xmp/synthetic/subject-seq.exiftool.txt new file mode 100644 index 000000000..9b1bd6926 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/subject-seq.exiftool.txt @@ -0,0 +1,33 @@ + + + + + 13.25 + subject-seq.xmp + internal/meta/testdata/xmp/synthetic + 518 bytes + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + + + Sequenced + Keywords + Should + Also + Parse + + + + diff --git a/internal/meta/testdata/xmp/synthetic/subject-seq.xmp b/internal/meta/testdata/xmp/synthetic/subject-seq.xmp new file mode 100644 index 000000000..635b051c5 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/subject-seq.xmp @@ -0,0 +1,17 @@ + + + + + + + Sequenced + Keywords + Should + Also + Parse + + + + + diff --git a/internal/meta/testdata/xmp/synthetic/time-offsets-subsec.exiftool.txt b/internal/meta/testdata/xmp/synthetic/time-offsets-subsec.exiftool.txt new file mode 100644 index 000000000..4a121f807 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/time-offsets-subsec.exiftool.txt @@ -0,0 +1,33 @@ + + + + + 13.25 + time-offsets-subsec.xmp + internal/meta/testdata/xmp/synthetic + 843 bytes + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + 2026:05:06 15:42:18.123456 + 2026:05:06 15:42:18 + 123456 + +02:00 + +02:00 + +02:00 + 2026:05:06 15:42:18 + + diff --git a/internal/meta/testdata/xmp/synthetic/time-offsets-subsec.xmp b/internal/meta/testdata/xmp/synthetic/time-offsets-subsec.xmp new file mode 100644 index 000000000..a00666d2f --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/time-offsets-subsec.xmp @@ -0,0 +1,17 @@ + + + + + 2026-05-06T15:42:18.123456 + 2026-05-06T15:42:18 + 2026-05-06T15:42:18 + 123456 + +02:00 + +02:00 + +02:00 + + + diff --git a/internal/meta/testdata/xmp/synthetic/xmpdm-creationdate.exiftool.txt b/internal/meta/testdata/xmp/synthetic/xmpdm-creationdate.exiftool.txt new file mode 100644 index 000000000..80c551987 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/xmpdm-creationdate.exiftool.txt @@ -0,0 +1,25 @@ + + + + + 13.25 + xmpdm-creationdate.xmp + internal/meta/testdata/xmp/synthetic + 391 bytes + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + 2026:05:06 07:32:30+00:00 + -rw-r--r-- + XMP + xmp + application/rdf+xml + PhotoPrism Synthetic Fixture + 2026:05:06 15:42:18+02:00 + + diff --git a/internal/meta/testdata/xmp/synthetic/xmpdm-creationdate.xmp b/internal/meta/testdata/xmp/synthetic/xmpdm-creationdate.xmp new file mode 100644 index 000000000..13c52a117 --- /dev/null +++ b/internal/meta/testdata/xmp/synthetic/xmpdm-creationdate.xmp @@ -0,0 +1,9 @@ + + + + + 2026-05-06T15:42:18+02:00 + + + diff --git a/internal/meta/xmp.go b/internal/meta/xmp.go index c8947c974..c27ffbdfc 100644 --- a/internal/meta/xmp.go +++ b/internal/meta/xmp.go @@ -4,10 +4,11 @@ import ( "fmt" "path/filepath" "runtime/debug" + "time" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/time/tz" + "github.com/photoprism/photoprism/pkg/media/projection" ) // XMP parses an XMP file and returns a Data struct. @@ -17,65 +18,196 @@ func XMP(fileName string) (data Data, err error) { return data, err } +// applyTimeOffset reinterprets t's wall-clock components as local time in the +// fixed zone described by an EXIF OffsetTime string ("+02:00", "-05:30"). +// Returns t unchanged if the offset cannot be parsed. +func applyTimeOffset(t time.Time, offset string) time.Time { + z, err := time.Parse("-07:00", offset) + if err != nil { + return t + } + + _, secs := z.Zone() + loc := time.FixedZone(offset, secs) + + return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), loc) +} + // XMP parses an XMP file and returns a Data struct. func (data *Data) XMP(fileName string) (err error) { + logName := clean.Log(filepath.Base(fileName)) + defer func() { if e := recover(); e != nil { - err = fmt.Errorf("metadata: %s in %s (xmp panic)\nstack: %s", e, clean.Log(filepath.Base(fileName)), debug.Stack()) + err = fmt.Errorf("metadata: %s in %s (xmp panic)\nstack: %s", e, logName, debug.Stack()) } }() // Resolve file name e.g. in case it's a symlink. if fileName, err = fs.Resolve(fileName); err != nil { - return fmt.Errorf("metadata: %s %s (xmp)", err, clean.Log(filepath.Base(fileName))) + return fmt.Errorf("metadata: %s %s (xmp)", err, logName) } doc := XmpDocument{} if err = doc.Load(fileName); err != nil { - return fmt.Errorf("metadata: cannot read %s (xmp)", clean.Log(filepath.Base(fileName))) + return fmt.Errorf("metadata: cannot read %s (xmp)", logName) } - if doc.Title() != "" { - data.Title = doc.Title() + if v := doc.Title(); v != "" { + data.Title = v + } + if v := doc.Artist(); v != "" { + data.Artist = v + } + if v := doc.Description(); v != "" { + data.Caption = v + } + if v := doc.Copyright(); v != "" { + data.Copyright = v + } + if v := doc.License(); v != "" { + data.License = v + } + if v := doc.Software(); v != "" { + data.Software = v + } + if v := doc.DocumentID(); v != "" { + data.DocumentID = v + } + if v := doc.InstanceID(); v != "" { + data.InstanceID = v } - if doc.Artist() != "" { - data.Artist = doc.Artist() + // GPS Lat/Lng pass through NormalizeGPS for parity with the embedded + // path's clamp/normalize behavior. + if lat, lng := doc.Lat(), doc.Lng(); lat != 0 || lng != 0 { + data.Lat, data.Lng = NormalizeGPS(lat, lng) + } + if v := doc.Altitude(); v != 0 { + data.Altitude = v + } + if v := doc.TakenGps(); !v.IsZero() { + data.TakenGps = v.UTC() } - if doc.Description() != "" { - data.Caption = doc.Description() + if v := doc.CameraMake(); v != "" { + data.CameraMake = v + } + if v := doc.CameraModel(); v != "" { + data.CameraModel = v + } + if v := doc.LensMake(); v != "" { + data.LensMake = v + } + if v := doc.LensModel(); v != "" { + data.LensModel = v + } + if v := doc.CameraSerial(); v != "" { + data.CameraSerial = v + } + if v := doc.CameraOwner(); v != "" { + data.CameraOwner = v + } + if v := doc.Projection(); v != "" { + data.Projection = v + } + if v := doc.ColorProfile(); v != "" { + data.ColorProfile = v } - if doc.Copyright() != "" { - data.Copyright = doc.Copyright() + if v := doc.Aperture(); v != 0 { + data.Aperture = v + } + if v := doc.FNumber(); v != 0 { + data.FNumber = v + } + if v := doc.FocalLength(); v != 0 { + data.FocalLength = v + } + if v := doc.Iso(); v != 0 { + data.Iso = v + } + if v := doc.Exposure(); v != "" { + data.Exposure = v + } + if doc.Flash() { + // Mirror the embedded-EXIF flow: set the bool *and* add the + // "flash" keyword, so XMP-sidecar-only photos surface in the + // same searches as photos indexed via the EXIF path. + data.AddKeywords(KeywordFlash) + data.Flash = true + } + if v := doc.Notes(); v != "" { + data.Notes = v } - if doc.CameraMake() != "" { - data.CameraMake = doc.CameraMake() + if v := doc.TakenAt(data.TimeZone); !v.IsZero() { + // Keep the wall-clock value (carrying any FixedZone parsed from the + // XMP timestamp) on both fields so the shared resolver can normalize + // UTC and local time consistently — mirrors how the EXIF reflection + // loop fills TakenAt and TakenAtLocal from the same source tag. + data.TakenAt = v + data.TakenAtLocal = v + } + if v := doc.TakenNs(); v > 0 { + data.TakenNs = v + } + // CreatedAt only serves as a capture-time fallback for video/audio + // sidecars (xmpDM:CreationDate), where TakenAt has no source tag. When + // TakenAt is already set from photoshop:DateCreated, leaving CreatedAt + // empty stops the shared resolver from overwriting the higher-priority + // capture instant with the less-specific xmp:CreateDate. + if data.TakenAt.IsZero() { + if v := doc.CreatedAt(data.TimeZone); !v.IsZero() { + data.CreatedAt = v.UTC() + } + } + if v := doc.TimeOffset(); v != "" { + data.TimeOffset = v } - if doc.CameraModel() != "" { - data.CameraModel = doc.CameraModel() - } - - if doc.LensModel() != "" { - data.LensModel = doc.LensModel() - } - - if takenAt := doc.TakenAt(data.TimeZone); !takenAt.IsZero() { - data.TakenAt = takenAt.UTC() - if data.TimeZone == "" { - data.TimeZone = tz.UTC + // XMP capture-time tags carry no inline UTC offset, whereas the EXIF 2.31 + // OffsetTime* value lives in a separate tag. Attach it to TakenAt and + // TakenAtLocal so the shared resolver — which assumes TakenAtLocal knows + // its own zone — can derive the correct UTC instant. Skip when the parsed + // timestamp already carries an offset (non-UTC zone). + if data.TimeOffset != "" && !data.TakenAt.IsZero() { + if _, off := data.TakenAt.Zone(); off == 0 { + data.TakenAt = applyTimeOffset(data.TakenAt, data.TimeOffset) + data.TakenAtLocal = data.TakenAt } } - if len(doc.Keywords()) != 0 { - data.AddKeywords(doc.Keywords()) + if v := doc.Keywords(); len(v) != 0 { + data.AddKeywords(v) + } + // Subject mirrors the ExifTool Subject cascade (dc:subject usually wins, + // so Subject normally equals the keyword list) so XMP-sidecar photos get + // the same details.Subject the embedded/ExifTool JSON path would produce. + if v := doc.Subject(); v != "" { + data.Subject = SanitizeMeta(v) } data.Favorite = doc.Favorite() + // Auto-derive keywords from projection and caption text so XMP-only + // photos surface in the same searches as EXIF-indexed photos. Mirrors + // the EXIF and ExifTool JSON flows (exif.go's ProjectionType/ImageDescription + // keyword block, json_exiftool.go's panorama/caption keyword block). + // AutoAddKeywords also sets data.ImageType to ImageTypeHDR when the + // caption mentions "hdr". + if projection.Equirectangular.Equal(data.Projection) { + data.AddKeywords(KeywordPanorama) + } + if data.Caption != "" { + data.AutoAddKeywords(data.Caption) + } + + // Normalize capture time, local time, and time zone using the shared + // resolver so the XMP sidecar path produces the same entity state the + // ExifTool JSON path would for identical metadata. + data.ResolveTimeZone(logName) + return nil } diff --git a/internal/meta/xmp_bench_test.go b/internal/meta/xmp_bench_test.go new file mode 100644 index 000000000..b1570feea --- /dev/null +++ b/internal/meta/xmp_bench_test.go @@ -0,0 +1,45 @@ +package meta + +import ( + "testing" +) + +// BenchmarkXMPRichSidecar measures per-file parse time on a fully +// populated Adobe Bridge sidecar (descriptive metadata, GPS, full +// camera/lens/exposure cluster, IDs). The proposal's performance +// budget is ≤ 2× the previous hand-rolled reader; running this with +// `go test -bench .` documents the new reader's absolute time so the +// budget can be reasoned about without re-introducing the old code. +func BenchmarkXMPRichSidecar(b *testing.B) { + for i := 0; i < b.N; i++ { + var data Data + if err := data.XMP("testdata/xmp/adobe/bridge.xmp"); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkXMPMinimalSidecar measures the cheap path: a single-attribute +// fixture (F-Stop favorite). Sets a lower bound for how fast the loader +// + accessor pipeline can return when there is almost nothing to read. +func BenchmarkXMPMinimalSidecar(b *testing.B) { + for i := 0; i < b.N; i++ { + var data Data + if err := data.XMP("testdata/fstop-favorite.xmp"); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkXMPMultiDescription measures the worst-case shape: four +// sibling rdf:Description blocks each declaring a different namespace +// binding. Catches regressions where the XPath walk degrades when +// properties scatter across blocks. +func BenchmarkXMPMultiDescription(b *testing.B) { + for i := 0; i < b.N; i++ { + var data Data + if err := data.XMP("testdata/xmp/synthetic/multi-rdf-description.xmp"); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/meta/xmp_document.go b/internal/meta/xmp_document.go index 65c289558..2f5de00cc 100644 --- a/internal/meta/xmp_document.go +++ b/internal/meta/xmp_document.go @@ -1,292 +1,773 @@ package meta import ( - "encoding/xml" + "errors" + "fmt" + "io" + "math" "os" + "strconv" "strings" "time" + "github.com/antchfx/xmlquery" + "github.com/antchfx/xpath" + + "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/txt" ) -// XmpDocument represents an XMP sidecar file. -type XmpDocument struct { - XMLName xml.Name `xml:"xmpmeta" json:"xmpmeta"` - Text string `xml:",chardata" json:"text,omitempty"` - X string `xml:"x,attr" json:"x,omitempty"` - Xmptk string `xml:"xmptk,attr" json:"xmptk,omitempty"` - RDF struct { - Text string `xml:",chardata" json:"text,omitempty"` - Rdf string `xml:"rdf,attr" json:"rdf,omitempty"` - Description struct { - Text string `xml:",chardata" json:"text,omitempty"` - About string `xml:"about,attr" json:"about,omitempty"` - Xmp string `xml:"xmp,attr" json:"xmp,omitempty"` - Aux string `xml:"aux,attr" json:"aux,omitempty"` - ExifEX string `xml:"exifEX,attr" json:"exifex,omitempty"` - Photoshop string `xml:"photoshop,attr" json:"photoshop,omitempty"` - XmpMM string `xml:"xmpMM,attr" json:"xmpmm,omitempty"` - Dc string `xml:"dc,attr" json:"dc,omitempty"` - Tiff string `xml:"tiff,attr" json:"tiff,omitempty"` - Exif string `xml:"exif,attr" json:"exif,omitempty"` - XmpRights string `xml:"xmpRights,attr" json:"xmprights,omitempty"` - Iptc4xmpCore string `xml:"Iptc4xmpCore,attr" json:"iptc4xmpcore,omitempty"` - Iptc4xmpExt string `xml:"Iptc4xmpExt,attr" json:"iptc4xmpext,omitempty"` - CreatorTool string `xml:"CreatorTool"` // ELE-L29 10.0.0.168(C431E2... - ModifyDate string `xml:"ModifyDate"` // 2020-01-01T17:28:23.89961... - CreateDate string `xml:"CreateDate"` // 2020-01-01T17:28:23 - MetadataDate string `xml:"MetadataDate"` // 2020-01-01T17:28:23.89961... - Rating string `xml:"Rating"` // 4 - FStopFavorite string `xml:"http://www.fstopapp.com/xmp/ favorite,attr"` // 1 - Lens string `xml:"Lens"` // HUAWEI P30 Rear Main Came... - LensModel string `xml:"LensModel"` // HUAWEI P30 Rear Main Came... - DateCreated string `xml:"DateCreated"` // 2020-01-01T17:28:25.72962... - ColorMode string `xml:"ColorMode"` // 3 - ICCProfile string `xml:"ICCProfile"` // sRGB IEC61966-2.1 - AuthorsPosition string `xml:"AuthorsPosition"` // Maintainer - DocumentID string `xml:"DocumentID"` // 2C678C1811D7095FD79CC822B... - InstanceID string `xml:"InstanceID"` // 2C678C1811D7095FD79CC822B... - Format string `xml:"format"` // image/jpeg - Title struct { - Text string `xml:",chardata" json:"text,omitempty"` - Alt struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li struct { - Text string `xml:",chardata" json:"text,omitempty"` // Night Shift / Berlin / 20... - Lang string `xml:"lang,attr" json:"lang,omitempty"` - } `xml:"li" json:"li"` - } `xml:"Alt" json:"alt"` - } `xml:"title" json:"title"` - Creator struct { - Text string `xml:",chardata" json:"text,omitempty"` - Seq struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li string `xml:"li"` // Michael Mayer - } `xml:"Seq" json:"seq"` - } `xml:"creator" json:"creator"` - Description struct { - Text string `xml:",chardata" json:"text,omitempty"` - Alt struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li struct { - Text string `xml:",chardata" json:"text,omitempty"` // Example file for developm... - Lang string `xml:"lang,attr" json:"lang,omitempty"` - } `xml:"li" json:"li"` - } `xml:"Alt" json:"alt"` - } `xml:"description" json:"description"` - Subject struct { - Text string `xml:",chardata" json:"text,omitempty"` - Bag struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li []string `xml:"li"` // desk, coffee, computer - } `xml:"Bag" json:"bag"` - Seq struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li []string `xml:"li"` // desk, coffee, computer - } `xml:"Seq" json:"seq"` - } `xml:"subject" json:"subject"` - Rights struct { - Text string `xml:",chardata" json:"text,omitempty"` - Alt struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li struct { - Text string `xml:",chardata" json:"text,omitempty"` // This is an (edited) legal... - Lang string `xml:"lang,attr" json:"lang,omitempty"` - } `xml:"li" json:"li"` - } `xml:"Alt" json:"alt"` - } `xml:"rights" json:"rights"` - ImageWidth string `xml:"ImageWidth"` // 3648 - ImageLength string `xml:"ImageLength"` // 2736 - BitsPerSample struct { - Text string `xml:",chardata" json:"text,omitempty"` - Seq struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li []string `xml:"li"` // 8 - } `xml:"Seq" json:"seq"` - } `xml:"BitsPerSample" json:"bitspersample"` - PhotometricInterpretation string `xml:"PhotometricInterpretation"` // 2 - Orientation string `xml:"Orientation"` // 0 - SamplesPerPixel string `xml:"SamplesPerPixel"` // 3 - YCbCrPositioning string `xml:"YCbCrPositioning"` // 1 - XResolution string `xml:"XResolution"` // 72/1 - YResolution string `xml:"YResolution"` // 72/1 - ResolutionUnit string `xml:"ResolutionUnit"` // 2 - Make string `xml:"Make"` // HUAWEI - Model string `xml:"Model"` // ELE-L29 - ExifVersion string `xml:"ExifVersion"` // 0210 - FlashpixVersion string `xml:"FlashpixVersion"` // 0100 - ColorSpace string `xml:"ColorProfile"` // 1 - ComponentsConfiguration struct { - Text string `xml:",chardata" json:"text,omitempty"` - Seq struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li []string `xml:"li"` // 1, 2, 3, 0 - } `xml:"Seq" json:"seq"` - } `xml:"ComponentsConfiguration" json:"componentsconfiguration"` - CompressedBitsPerPixel string `xml:"CompressedBitsPerPixel"` // 95/100 - PixelXDimension string `xml:"PixelXDimension"` // 3648 - PixelYDimension string `xml:"PixelYDimension"` // 2736 - DateTimeOriginal string `xml:"DateTimeOriginal"` // 2020-01-01T17:28:23 - ExposureTime string `xml:"ExposureTime"` // 20000000/1000000000 - FNumber string `xml:"FNumber"` // 180/100 - ExposureProgram string `xml:"ExposureProgram"` // 2 - ISOSpeedRatings struct { - Text string `xml:",chardata" json:"text,omitempty"` - Seq struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li string `xml:"li"` // 200 - } `xml:"Seq" json:"seq"` - } `xml:"ISOSpeedRatings" json:"isospeedratings"` - ShutterSpeedValue string `xml:"ShutterSpeedValue"` // 298973/10000 - ApertureValue string `xml:"ApertureValue"` // 1695994/1000000 - BrightnessValue string `xml:"BrightnessValue"` // 0/1 - ExposureBiasValue string `xml:"ExposureBiasValue"` // 0/10 - MaxApertureValue string `xml:"MaxApertureValue"` // 169/100 - MeteringMode string `xml:"MeteringMode"` // 5 - LightSource string `xml:"LightSource"` // 1 - Flash struct { - Text string `xml:",chardata" json:"text,omitempty"` - ParseType string `xml:"parseType,attr" json:"parsetype,omitempty"` - Fired string `xml:"Fired"` // False - Return string `xml:"Return"` // 0 - Mode string `xml:"Mode"` // 0 - Function string `xml:"Function"` // False - RedEyeMode string `xml:"RedEyeMode"` // False - } `xml:"Flash" json:"flash"` - FocalLength string `xml:"FocalLength"` // 5580/1000 - SensingMethod string `xml:"SensingMethod"` // 2 - FileSource string `xml:"FileSource"` // 3 - SceneType string `xml:"SceneType"` // 1 - CustomRendered string `xml:"CustomRendered"` // 1 - ExposureMode string `xml:"ExposureMode"` // 0 - WhiteBalance string `xml:"WhiteBalance"` // 0 - DigitalZoomRatio string `xml:"DigitalZoomRatio"` // 100/100 - FocalLengthIn35mmFilm string `xml:"FocalLengthIn35mmFilm"` // 27 - SceneCaptureType string `xml:"SceneCaptureType"` // 0 - GainControl string `xml:"GainControl"` // 0 - Contrast string `xml:"Contrast"` // 0 - Saturation string `xml:"Saturation"` // 0 - Sharpness string `xml:"Sharpness"` // 0 - SubjectDistanceRange string `xml:"SubjectDistanceRange"` // 0 - SubSecTime string `xml:"SubSecTime"` // 899614 - SubSecTimeOriginal string `xml:"SubSecTimeOriginal"` // 899614 - SubSecTimeDigitized string `xml:"SubSecTimeDigitized"` // 899614 - GPSVersionID string `xml:"GPSVersionID"` // 2.2.0.0 - GPSLatitude string `xml:"GPSLatitude"` // 52,27.5814N - GPSLongitude string `xml:"GPSLongitude"` // 13,19.3099E - GPSAltitudeRef string `xml:"GPSAltitudeRef"` // 1 - GPSAltitude string `xml:"GPSAltitude"` // 0/100 - GPSTimeStamp string `xml:"GPSTimeStamp"` // 2020-01-01T16:28:22Z - Marked string `xml:"Marked"` // False - WebStatement string `xml:"WebStatement"` // http://docs.photoprism.or... - CreatorContactInfo struct { - Text string `xml:",chardata" json:"text,omitempty"` - ParseType string `xml:"parseType,attr" json:"parsetype,omitempty"` - CiAdrExtadr string `xml:"CiAdrExtadr"` // Zimmermannstr. 37 - CiAdrCity string `xml:"CiAdrCity"` // Berlin - CiAdrPcode string `xml:"CiAdrPcode"` // 12163 - CiAdrCtry string `xml:"CiAdrCtry"` // Germany - CiTelWork string `xml:"CiTelWork"` // +49123456789 - CiEmailWork string `xml:"CiEmailWork"` // hello@photoprism.org - CiUrlWork string `xml:"CiUrlWork"` // https://photoprism.org/ - } `xml:"CreatorContactInfo" json:"creatorcontactinfo"` - PersonInImage struct { - Text string `xml:",chardata" json:"text,omitempty"` - Bag struct { - Text string `xml:",chardata" json:"text,omitempty"` - Li string `xml:"li"` // Gopher - } `xml:"Bag" json:"bag"` - } `xml:"PersonInImage" json:"personinimage"` - } `xml:"Description" json:"description"` - } `xml:"RDF" json:"rdf"` +// xmpMaxFileSize caps XMP sidecar size to mitigate denial-of-service via +// pathologically large files. Real-world sidecars range from ~5 KB to +// ~200 KB; 1 MiB leaves generous headroom. +const xmpMaxFileSize = 1 * 1024 * 1024 + +// xmpMaxDepth caps element nesting to mitigate depth-bomb attacks. +// Natural XMP nests to ~10–12 levels; 64 is well above any legitimate use. +const xmpMaxDepth = 64 + +// ErrXmpFileTooLarge is returned when an XMP sidecar exceeds xmpMaxFileSize. +var ErrXmpFileTooLarge = errors.New("xmp: file size exceeds limit") + +// ErrXmpTooDeep is returned when an XMP document nests deeper than xmpMaxDepth. +var ErrXmpTooDeep = errors.New("xmp: element nesting exceeds limit") + +// xmpNamespaces binds canonical XMP namespace prefixes to their URIs. +// Adding a prefix here makes it available to every XPath expression +// compiled with mustCompile. +var xmpNamespaces = map[string]string{ + "xml": "http://www.w3.org/XML/1998/namespace", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "x": "adobe:ns:meta/", + "xmp": "http://ns.adobe.com/xap/1.0/", + "xmpMM": "http://ns.adobe.com/xap/1.0/mm/", + "xmpRights": "http://ns.adobe.com/xap/1.0/rights/", + "xmpDM": "http://ns.adobe.com/xmp/1.0/DynamicMedia/", + "dc": "http://purl.org/dc/elements/1.1/", + "tiff": "http://ns.adobe.com/tiff/1.0/", + "exif": "http://ns.adobe.com/exif/1.0/", + "exifEX": "http://cipa.jp/exif/1.0/", + "aux": "http://ns.adobe.com/exif/1.0/aux/", + "photoshop": "http://ns.adobe.com/photoshop/1.0/", + "GPano": "http://ns.google.com/photos/1.0/panorama/", + "Iptc4xmpCore": "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/", + "Iptc4xmpExt": "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", + "lr": "http://ns.adobe.com/lightroom/1.0/", + "fstop": "http://www.fstopapp.com/xmp/", } -// Load parses an XMP file and populates document values with its contents. -func (doc *XmpDocument) Load(filename string) error { - data, err := os.ReadFile(filename) //nolint:gosec // filename is provided by caller; reading sidecar files is expected +// chainXPath is an ordered list of pre-compiled XPath expressions +// evaluated left-to-right; the first non-empty match wins. +type chainXPath []*xpath.Expr +// firstNonEmpty returns the trimmed inner text of the first matching +// node; empty string when no link in the chain produces a value. +func (c chainXPath) firstNonEmpty(root *xmlquery.Node) string { + if root == nil { + return "" + } + for _, expr := range c { + if n := xmlquery.QuerySelector(root, expr); n != nil { + if s := strings.TrimSpace(n.InnerText()); s != "" { + return s + } + } + } + return "" +} + +// queryAll returns the trimmed text of every matching node in document +// order; empty matches are dropped. +func queryAll(root *xmlquery.Node, expr *xpath.Expr) []string { + if root == nil || expr == nil { + return nil + } + nodes := xmlquery.QuerySelectorAll(root, expr) + out := make([]string, 0, len(nodes)) + for _, n := range nodes { + if s := strings.TrimSpace(n.InnerText()); s != "" { + out = append(out, s) + } + } + return out +} + +// mustCompile compiles an XPath expression bound to xmpNamespaces and +// panics on a malformed expression. Intended for package-init wiring. +func mustCompile(expr string) *xpath.Expr { + compiled, err := xpath.CompileWithNS(expr, xmpNamespaces) + if err != nil { + panic(fmt.Sprintf("xmp: invalid XPath %q: %v", expr, err)) + } + return compiled +} + +// elemOrAttr matches the named element or the same property expressed +// as an attribute on rdf:Description (digiKam emits attributes, Adobe +// emits child elements; both are valid RDF/XML). +func elemOrAttr(qname string) *xpath.Expr { + return mustCompile(fmt.Sprintf("//%s | //rdf:Description/@%s", qname, qname)) +} + +// Pre-compiled query handles. Compile-once-at-init amortises XPath +// parsing across every sidecar in an indexer run. +var ( + // xmpTitleChain: dc:title (Alt/x-default) → first li → bare text → photoshop:Headline. + xmpTitleChain = chainXPath{ + mustCompile("//dc:title/rdf:Alt/rdf:li[@xml:lang='x-default']"), + mustCompile("(//dc:title/rdf:Alt/rdf:li)[1]"), + mustCompile("//dc:title[not(rdf:Alt)]"), + mustCompile("//photoshop:Headline"), + } + + // xmpDescriptionChain: dc:description Alt-language fallback. + xmpDescriptionChain = chainXPath{ + mustCompile("//dc:description/rdf:Alt/rdf:li[@xml:lang='x-default']"), + mustCompile("(//dc:description/rdf:Alt/rdf:li)[1]"), + mustCompile("//dc:description[not(rdf:Alt)]"), + } + + // xmpRightsChain: dc:rights Alt-language → xmpRights:WebStatement. + // WebStatement is a URL, not free text, but the embedded path uses + // the same fallback. + xmpRightsChain = chainXPath{ + mustCompile("//dc:rights/rdf:Alt/rdf:li[@xml:lang='x-default']"), + mustCompile("(//dc:rights/rdf:Alt/rdf:li)[1]"), + mustCompile("//dc:rights[not(rdf:Alt)]"), + mustCompile("//xmpRights:WebStatement"), + } + + // xmpLicenseChain: xmpRights:UsageTerms (lang-alt). + xmpLicenseChain = chainXPath{ + mustCompile("//xmpRights:UsageTerms/rdf:Alt/rdf:li[@xml:lang='x-default']"), + mustCompile("(//xmpRights:UsageTerms/rdf:Alt/rdf:li)[1]"), + mustCompile("//xmpRights:UsageTerms[not(rdf:Alt)]"), + } + + // xmpSoftwareChain: xmp:CreatorTool. + xmpSoftwareChain = chainXPath{elemOrAttr("xmp:CreatorTool")} + + // xmpDocumentIDChain: xmpMM:OriginalDocumentID (asset-stable) → + // xmpMM:DocumentID (per-derivative) → dc:identifier (legacy). + xmpDocumentIDChain = chainXPath{ + elemOrAttr("xmpMM:OriginalDocumentID"), + elemOrAttr("xmpMM:DocumentID"), + elemOrAttr("dc:identifier"), + } + + // xmpInstanceIDChain: xmpMM:InstanceID. + xmpInstanceIDChain = chainXPath{elemOrAttr("xmpMM:InstanceID")} + + // xmpArtistChain: first dc:creator/rdf:Seq entry. + xmpArtistChain = chainXPath{mustCompile("(//dc:creator/rdf:Seq/rdf:li)[1]")} + + // xmpCameraMakeChain: tiff:Make. + xmpCameraMakeChain = chainXPath{elemOrAttr("tiff:Make")} + + // xmpCameraModelChain: tiff:Model. No XMP fallback exists; aux + // and tiff UniqueCameraModel are not defined XMP properties. + xmpCameraModelChain = chainXPath{elemOrAttr("tiff:Model")} + + // xmpLensModelChain: exifEX:LensModel (modern) → aux:Lens → + // aux:LensID. aux is retained because Lightroom and Camera Raw + // still emit it. + xmpLensModelChain = chainXPath{ + elemOrAttr("exifEX:LensModel"), + elemOrAttr("aux:Lens"), + elemOrAttr("aux:LensID"), + } + + // xmpTakenAtChain: photoshop:DateCreated → exif:DateTimeOriginal → + // xmp:CreateDate. SubSecTimeOriginal is joined in TakenAt() when + // the parsed datetime carries no fractional component. + xmpTakenAtChain = chainXPath{ + elemOrAttr("photoshop:DateCreated"), + elemOrAttr("exif:DateTimeOriginal"), + elemOrAttr("xmp:CreateDate"), + } + + // xmpSubSecTimeChain: exif:SubSecTimeOriginal — fractional seconds + // of the capture time as a digit string. + xmpSubSecTimeChain = chainXPath{elemOrAttr("exif:SubSecTimeOriginal")} + + // xmpTimeOffsetChain: EXIF 2.31 cascade. + xmpTimeOffsetChain = chainXPath{ + elemOrAttr("exif:OffsetTimeOriginal"), + elemOrAttr("exif:OffsetTime"), + elemOrAttr("exif:OffsetTimeDigitized"), + } + + // xmpCreatedAtChain: xmp:CreateDate → xmpDM:CreationDate (video). + xmpCreatedAtChain = chainXPath{ + elemOrAttr("xmp:CreateDate"), + elemOrAttr("xmpDM:CreationDate"), + } + + // xmpCameraSerialChain: exifEX:SerialNumber (= EXIF + // BodySerialNumber) → aux:SerialNumber. + xmpCameraSerialChain = chainXPath{ + elemOrAttr("exifEX:SerialNumber"), + elemOrAttr("aux:SerialNumber"), + } + + // xmpLensMakeChain: exifEX:LensMake. + xmpLensMakeChain = chainXPath{elemOrAttr("exifEX:LensMake")} + + // xmpCameraOwnerChain: aux:OwnerName. + xmpCameraOwnerChain = chainXPath{elemOrAttr("aux:OwnerName")} + + // xmpProjectionChain: GPano:ProjectionType. + xmpProjectionChain = chainXPath{elemOrAttr("GPano:ProjectionType")} + + // xmpColorProfileChain: photoshop:ICCProfile. + xmpColorProfileChain = chainXPath{elemOrAttr("photoshop:ICCProfile")} + + // xmpApertureChain: exif:ApertureValue (APEX). + xmpApertureChain = chainXPath{elemOrAttr("exif:ApertureValue")} + + // xmpFNumberChain: exif:FNumber. + xmpFNumberChain = chainXPath{elemOrAttr("exif:FNumber")} + + // xmpFocalLengthChain: exif:FocalLength → exif:FocalLengthIn35mmFilm. + xmpFocalLengthChain = chainXPath{ + elemOrAttr("exif:FocalLength"), + elemOrAttr("exif:FocalLengthIn35mmFilm"), + } + + // xmpIsoChain: exifEX:PhotographicSensitivity (modern) → + // exif:ISOSpeedRatings/rdf:Seq[1] (deprecated but widely emitted). + xmpIsoChain = chainXPath{ + elemOrAttr("exifEX:PhotographicSensitivity"), + mustCompile("(//exif:ISOSpeedRatings/rdf:Seq/rdf:li)[1]"), + mustCompile("//rdf:Description/@exif:ISOSpeedRatings"), + } + + // xmpExposureTimeChain: exif:ExposureTime as rational seconds. + xmpExposureTimeChain = chainXPath{elemOrAttr("exif:ExposureTime")} + + // xmpShutterSpeedChain: exif:ShutterSpeedValue (APEX-encoded); + // converted to seconds in Exposure() via t = 2^(-Tv). + xmpShutterSpeedChain = chainXPath{elemOrAttr("exif:ShutterSpeedValue")} + + // xmpFlashFiredChain: only the Fired sub-field; other Flash + // sub-fields are intentionally ignored because data.Flash is bool. + xmpFlashFiredChain = chainXPath{ + mustCompile("//exif:Flash/exif:Fired"), + mustCompile("//exif:Flash/@exif:Fired"), + mustCompile("//exif:Flash/rdf:Description/@exif:Fired"), + } + + // xmpNotesChain: exif:UserComment (lang-alt). + xmpNotesChain = chainXPath{ + mustCompile("//exif:UserComment/rdf:Alt/rdf:li[@xml:lang='x-default']"), + mustCompile("(//exif:UserComment/rdf:Alt/rdf:li)[1]"), + mustCompile("//exif:UserComment[not(rdf:Alt)]"), + } + + // xmpKeywordsBag / xmpKeywordsSeq: dc:subject containers. Adobe, + // Darktable and digiKam emit Bag; Apple Photos and the previous + // reader emit Seq. + xmpKeywordsBag = mustCompile("//dc:subject/rdf:Bag/rdf:li") + xmpKeywordsSeq = mustCompile("//dc:subject/rdf:Seq/rdf:li") + + // xmpPersonBag / xmpPersonSeq: Iptc4xmpExt:PersonInImage containers + // (names of people depicted) — a Subject cascade fallback. + xmpPersonBag = mustCompile("//Iptc4xmpExt:PersonInImage/rdf:Bag/rdf:li") + xmpPersonSeq = mustCompile("//Iptc4xmpExt:PersonInImage/rdf:Seq/rdf:li") + + // xmpHierarchicalBag / xmpHierarchicalSeq: lr:hierarchicalSubject + // containers (Lightroom "Nature|Animals" paths) — a Subject fallback. + xmpHierarchicalBag = mustCompile("//lr:hierarchicalSubject/rdf:Bag/rdf:li") + xmpHierarchicalSeq = mustCompile("//lr:hierarchicalSubject/rdf:Seq/rdf:li") + + // xmpFavoriteAttr: F-Stop favorite attribute on rdf:Description. + xmpFavoriteAttr = mustCompile("//rdf:Description/@fstop:favorite") + + // xmpGPSLatitudeChain reads exif:GPSLatitude; sign composition + // with GPSLatitudeRef happens in Lat(). + xmpGPSLatitudeChain = chainXPath{elemOrAttr("exif:GPSLatitude")} + xmpGPSLatitudeRefChain = chainXPath{elemOrAttr("exif:GPSLatitudeRef")} + + // xmpGPSLongitudeChain: analogous to latitude. + xmpGPSLongitudeChain = chainXPath{elemOrAttr("exif:GPSLongitude")} + xmpGPSLongitudeRefChain = chainXPath{elemOrAttr("exif:GPSLongitudeRef")} + + // xmpGPSAltitudeChain: rationals like "3450/100" parsed in Altitude(). + xmpGPSAltitudeChain = chainXPath{elemOrAttr("exif:GPSAltitude")} + xmpGPSAltitudeRefChain = chainXPath{elemOrAttr("exif:GPSAltitudeRef")} + + // xmpGPSTimeStampChain: combined ISO 8601 datetime (canonical XMP). + xmpGPSTimeStampChain = chainXPath{elemOrAttr("exif:GPSTimeStamp")} + + // xmpGPSDateStampChain: legacy split-form fallback for TakenGps. + xmpGPSDateStampChain = chainXPath{elemOrAttr("exif:GPSDateStamp")} +) + +// XmpDocument represents a parsed XMP sidecar; populate via Load. +type XmpDocument struct { + doc *xmlquery.Node +} + +// Load reads an XMP sidecar and enforces the size and depth security +// limits. XXE/DTD attacks are mitigated by encoding/xml defaults +// (xmp_security_test.go guards). io.LimitReader caps Parse defensively +// in case the file grows between Stat and Read. +func (doc *XmpDocument) Load(filename string) error { + info, err := os.Stat(filename) if err != nil { return err } + if info.Size() > xmpMaxFileSize { + return fmt.Errorf("%w: %s (%d bytes)", ErrXmpFileTooLarge, clean.Log(filename), info.Size()) + } - return xml.Unmarshal(data, doc) + f, err := os.Open(filename) //nolint:gosec // sidecar reading is the documented purpose + if err != nil { + return err + } + defer f.Close() + + parsed, err := xmlquery.Parse(io.LimitReader(f, xmpMaxFileSize)) + if err != nil { + return fmt.Errorf("xmp: %s: %w", clean.Log(filename), err) + } + + if d := maxDepth(parsed, 0); d > xmpMaxDepth { + return fmt.Errorf("%w: %s (got %d)", ErrXmpTooDeep, clean.Log(filename), d) + } + + doc.doc = parsed + return nil +} + +// maxDepth returns the deepest element-nesting level reachable from n. +// Non-element nodes (text, comment, attribute) do not inflate the count. +func maxDepth(n *xmlquery.Node, current int) int { + if n == nil { + return current + } + deepest := current + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type != xmlquery.ElementNode { + continue + } + if d := maxDepth(c, current+1); d > deepest { + deepest = d + } + } + return deepest } // Title returns the XMP document title. +// Priority: dc:title (Alt/x-default → first rdf:Alt entry → bare text) → photoshop:Headline. func (doc *XmpDocument) Title() string { - t := doc.RDF.Description.Title.Alt.Li.Text - t2 := doc.RDF.Description.Title.Text - if t != "" { - return SanitizeTitle(t) - } else if t2 != "" { - return SanitizeTitle(t2) - } - return "" + return SanitizeTitle(xmpTitleChain.firstNonEmpty(doc.doc)) } -// Artist returns the XMP document artist. -func (doc *XmpDocument) Artist() string { - return SanitizeString(doc.RDF.Description.Creator.Seq.Li) -} - -// Description returns the XMP document description. +// Description returns the caption / image description. +// Priority: dc:description (Alt/x-default → first rdf:Alt entry → bare text). func (doc *XmpDocument) Description() string { - d := doc.RDF.Description.Description.Alt.Li.Text - d2 := doc.RDF.Description.Description.Text - if d != "" { - return SanitizeCaption(d) - } else if d2 != "" { - return SanitizeTitle(d2) + return SanitizeCaption(xmpDescriptionChain.firstNonEmpty(doc.doc)) +} + +// Copyright returns the rights statement. +// Priority: dc:rights (Alt/x-default → first rdf:Alt entry → bare text) → xmpRights:WebStatement. +// WebStatement is a URL approximation of the rights text but matches the embedded path's behavior. +func (doc *XmpDocument) Copyright() string { + return SanitizeString(xmpRightsChain.firstNonEmpty(doc.doc)) +} + +// Artist returns the first creator name. +// Priority: first entry of dc:creator/rdf:Seq. +func (doc *XmpDocument) Artist() string { + return SanitizeString(xmpArtistChain.firstNonEmpty(doc.doc)) +} + +// CameraMake returns the camera manufacturer. +// Priority: tiff:Make (single-link, namespace-bound). +func (doc *XmpDocument) CameraMake() string { + return SanitizeString(xmpCameraMakeChain.firstNonEmpty(doc.doc)) +} + +// CameraModel returns the camera model. +// Priority: tiff:Model (single-link; UniqueCameraModel is a DNG TIFF tag, +// not an XMP property). +func (doc *XmpDocument) CameraModel() string { + return SanitizeString(xmpCameraModelChain.firstNonEmpty(doc.doc)) +} + +// LensModel returns the lens model. +// Priority: exifEX:LensModel (modern EXIF 2.3) → aux:Lens → aux:LensID (legacy Adobe). +func (doc *XmpDocument) LensModel() string { + return SanitizeString(xmpLensModelChain.firstNonEmpty(doc.doc)) +} + +// TakenAt parses the capture timestamp. +// Priority: photoshop:DateCreated → exif:DateTimeOriginal → xmp:CreateDate. +// Composition: exif:SubSecTimeOriginal is joined when the primary date +// string lacks fractional seconds (never overrides an existing fraction). +// Diverges from the embedded path (DateTimeOriginal-first) because Adobe +// sidecars treat photoshop:DateCreated as authoritative; SrcXmp > SrcMeta +// at the entity layer resolves any cross-path disagreement. +func (doc *XmpDocument) TakenAt(timeZone string) time.Time { + s := SanitizeString(xmpTakenAtChain.firstNonEmpty(doc.doc)) + if s == "" { + return time.Time{} + } + t := txt.ParseTime(s, timeZone) + if t.IsZero() || t.Nanosecond() != 0 { + return t + } + if subSec := SanitizeString(xmpSubSecTimeChain.firstNonEmpty(doc.doc)); subSec != "" { + if ns := parseSubSec(subSec); ns > 0 { + t = t.Add(time.Duration(ns) * time.Nanosecond) + } + } + return t +} + +// TakenNs returns the nanosecond fraction of the capture timestamp. +// Priority: exif:SubSecTimeOriginal (single-link). Independent of TakenAt's +// join — callers that want only the sub-second component consume this. +func (doc *XmpDocument) TakenNs() int { + subSec := SanitizeString(xmpSubSecTimeChain.firstNonEmpty(doc.doc)) + if subSec == "" { + return 0 + } + return parseSubSec(subSec) +} + +// CreatedAt parses the file-creation timestamp (distinct from TakenAt: +// TakenAt = capture time, CreatedAt = first write of the digital file). +// Priority: xmp:CreateDate → xmpDM:CreationDate (Dynamic Media fallback). +func (doc *XmpDocument) CreatedAt(timeZone string) time.Time { + s := SanitizeString(xmpCreatedAtChain.firstNonEmpty(doc.doc)) + if s == "" { + return time.Time{} + } + return txt.ParseTime(s, timeZone) +} + +// TimeOffset returns the timezone offset string ("+02:00"). +// Priority: exif:OffsetTimeOriginal → exif:OffsetTime → exif:OffsetTimeDigitized (EXIF 2.31 cascade). +func (doc *XmpDocument) TimeOffset() string { + return SanitizeString(xmpTimeOffsetChain.firstNonEmpty(doc.doc)) +} + +// CameraSerial returns the camera body serial number. +// Priority: exifEX:SerialNumber (= EXIF BodySerialNumber 0xA431) → +// aux:SerialNumber (legacy Adobe, still emitted by Lightroom). +func (doc *XmpDocument) CameraSerial() string { + return SanitizeString(xmpCameraSerialChain.firstNonEmpty(doc.doc)) +} + +// LensMake returns the lens manufacturer. +// Priority: exifEX:LensMake (single-link; aux:LensMake is not defined). +func (doc *XmpDocument) LensMake() string { + return SanitizeString(xmpLensMakeChain.firstNonEmpty(doc.doc)) +} + +// CameraOwner returns the camera-owner name. +// Priority: aux:OwnerName (single-link). +func (doc *XmpDocument) CameraOwner() string { + return SanitizeString(xmpCameraOwnerChain.firstNonEmpty(doc.doc)) +} + +// Projection returns the panoramic projection type. +// Priority: GPano:ProjectionType (single-link). Google Photo Sphere +// defines only "equirectangular"; other values pass through unchanged. +func (doc *XmpDocument) Projection() string { + return SanitizeString(xmpProjectionChain.firstNonEmpty(doc.doc)) +} + +// ColorProfile returns the embedded ICC profile description. +// Priority: photoshop:ICCProfile (single-link). +func (doc *XmpDocument) ColorProfile() string { + return SanitizeString(xmpColorProfileChain.firstNonEmpty(doc.doc)) +} + +// Aperture returns the APEX-encoded aperture value. +// Priority: exif:ApertureValue (single-link, parsed as rational "180/100" → 1.8). +func (doc *XmpDocument) Aperture() float32 { + return float32(rationalAccessor(xmpApertureChain, doc.doc)) +} + +// FNumber returns the f-number (focal length / entrance-pupil diameter, e.g. 1.8 for f/1.8). +// Priority: exif:FNumber (single-link, parsed as rational). +func (doc *XmpDocument) FNumber() float32 { + return float32(rationalAccessor(xmpFNumberChain, doc.doc)) +} + +// FocalLength returns the focal length in millimetres (rounded; the +// data field is int and sub-mm precision is discarded). +// Priority: exif:FocalLength (native) → exif:FocalLengthIn35mmFilm. +func (doc *XmpDocument) FocalLength() int { + return int(math.Round(rationalAccessor(xmpFocalLengthChain, doc.doc))) +} + +// Iso returns the ISO sensitivity. +// Priority: exifEX:PhotographicSensitivity (EXIF 2.3) → first +// exif:ISOSpeedRatings/rdf:Seq entry (deprecated but widely emitted). +func (doc *XmpDocument) Iso() int { + val := xmpIsoChain.firstNonEmpty(doc.doc) + if n, err := strconv.Atoi(val); err == nil && n > 0 { + return n + } + return 0 +} + +// Exposure returns the exposure time as "1/250", "0.5", "30" etc. +// Priority: exif:ExposureTime (rational seconds) → exif:ShutterSpeedValue +// (APEX-encoded, converted via t = 2^(-Tv)). +func (doc *XmpDocument) Exposure() string { + if val := xmpExposureTimeChain.firstNonEmpty(doc.doc); val != "" { + if secs, ok := parseRational(val); ok && secs > 0 { + return formatExposure(secs) + } + } + if val := xmpShutterSpeedChain.firstNonEmpty(doc.doc); val != "" { + if apex, ok := parseRational(val); ok { + return formatExposure(apexToSeconds(apex)) + } } return "" } -// Copyright returns the XMP document copyright info. -func (doc *XmpDocument) Copyright() string { - return SanitizeString(doc.RDF.Description.Rights.Alt.Li.Text) +// Flash reports whether the flash fired. +// Composition: only exif:Flash/Fired is read; other sub-fields +// (Function, Mode, Return, RedEyeMode) are ignored because data.Flash +// is a single boolean. +func (doc *XmpDocument) Flash() bool { + return txt.Bool(xmpFlashFiredChain.firstNonEmpty(doc.doc)) } -// CameraMake returns the XMP document camera make name. -func (doc *XmpDocument) CameraMake() string { - return SanitizeString(doc.RDF.Description.Make) +// Notes returns the user-comment text. +// Priority: exif:UserComment (lang-alt: x-default → first rdf:Alt entry → bare text). +func (doc *XmpDocument) Notes() string { + return SanitizeString(xmpNotesChain.firstNonEmpty(doc.doc)) } -// CameraModel returns the XMP document camera model name. -func (doc *XmpDocument) CameraModel() string { - return SanitizeString(doc.RDF.Description.Model) -} - -// LensModel returns the XMP document lens model name. -func (doc *XmpDocument) LensModel() string { - return SanitizeString(doc.RDF.Description.LensModel) -} - -// TakenAt returns the XMP document taken date. -func (doc *XmpDocument) TakenAt(timeZone string) time.Time { - taken := time.Time{} // Unknown - - s := SanitizeString(doc.RDF.Description.DateCreated) - - if s == "" { - return taken +// joinBagOrSeq joins an rdf:Bag container's entries (Adobe/Darktable/digiKam) +// or, when no Bag is present, the rdf:Seq entries (Apple, older writers) with +// ", ". Bag wins when both are present. +func (doc *XmpDocument) joinBagOrSeq(bag, seq *xpath.Expr) string { + if v := queryAll(doc.doc, bag); len(v) > 0 { + return strings.Join(v, ", ") } - - if dateTime := txt.ParseTime(s, timeZone); !dateTime.IsZero() { - return dateTime - } - - return taken + return strings.Join(queryAll(doc.doc, seq), ", ") } -// Keywords returns the XMP document keywords. +// Keywords returns dc:subject entries joined with ", ". +// Priority: dc:subject/rdf:Bag (Adobe/Darktable/digiKam) → dc:subject/rdf:Seq +// (Apple, older writers). Bag wins when both are present. func (doc *XmpDocument) Keywords() string { - s := doc.RDF.Description.Subject.Seq.Li - - return strings.Join(s, ", ") + return doc.joinBagOrSeq(xmpKeywordsBag, xmpKeywordsSeq) } -// Favorite returns a favorite status in the XMP document. +// Subject returns descriptive subject text, mirroring the ExifTool Subject +// cascade so the XMP sidecar path fills meta.Data.Subject identically to the +// embedded/ExifTool JSON path. Priority: dc:subject (same source as Keywords, +// present in virtually all tagged files) → Iptc4xmpExt:PersonInImage → +// lr:hierarchicalSubject. The first non-empty container wins. +func (doc *XmpDocument) Subject() string { + if v := doc.Keywords(); v != "" { + return v + } + if v := doc.joinBagOrSeq(xmpPersonBag, xmpPersonSeq); v != "" { + return v + } + return doc.joinBagOrSeq(xmpHierarchicalBag, xmpHierarchicalSeq) +} + +// Favorite reports the F-Stop custom-namespace favorite flag. +// Priority: rdf:Description/@fstop:favorite (single-link, "1" → true). func (doc *XmpDocument) Favorite() bool { - fstop := doc.RDF.Description.FStopFavorite - return fstop == "1" + if doc.doc == nil { + return false + } + if n := xmlquery.QuerySelector(doc.doc, xmpFavoriteAttr); n != nil { + return strings.TrimSpace(n.InnerText()) == "1" + } + return false +} + +// License returns the XMP licence statement. +// Priority: xmpRights:UsageTerms (lang-alt: x-default → first rdf:Alt entry → bare text). +func (doc *XmpDocument) License() string { + return SanitizeString(xmpLicenseChain.firstNonEmpty(doc.doc)) +} + +// Software returns the application that wrote the file. +// Priority: xmp:CreatorTool (single-link). +func (doc *XmpDocument) Software() string { + return SanitizeString(xmpSoftwareChain.firstNonEmpty(doc.doc)) +} + +// DocumentID returns the XMP document identifier (asset-stable across +// derivatives; "xmp.did:" / "xmp.iid:" prefixes are preserved and +// stripped downstream when matching across embedded/sidecar paths). +// Priority: xmpMM:OriginalDocumentID → xmpMM:DocumentID → dc:identifier. +func (doc *XmpDocument) DocumentID() string { + return SanitizeString(xmpDocumentIDChain.firstNonEmpty(doc.doc)) +} + +// InstanceID returns the XMP instance identifier (per-derivative). +// Priority: xmpMM:InstanceID (single-link; each saved derivative gets a fresh ID). +func (doc *XmpDocument) InstanceID() string { + return SanitizeString(xmpInstanceIDChain.firstNonEmpty(doc.doc)) +} + +// Lat returns the GPS latitude as a decimal float. +// Priority: exif:GPSLatitude (single-link). +// Composition: cardinal in the value (Adobe form "52,30.4567N") wins; +// otherwise exif:GPSLatitudeRef supplies the sign. See gpsCoord. +func (doc *XmpDocument) Lat() float64 { + return gpsCoord(xmpGPSLatitudeChain, xmpGPSLatitudeRefChain, doc.doc) +} + +// Lng returns the GPS longitude as a decimal float. +// Priority: exif:GPSLongitude (single-link); composition mirrors Lat +// with E/W cardinal handling. +func (doc *XmpDocument) Lng() float64 { + return gpsCoord(xmpGPSLongitudeChain, xmpGPSLongitudeRefChain, doc.doc) +} + +// gpsCoord composes a single GPS coordinate, applying the cardinal +// from refChain when the value lacks an N/S/E/W suffix. +func gpsCoord(valueChain, refChain chainXPath, root *xmlquery.Node) float64 { + val := valueChain.firstNonEmpty(root) + if val == "" { + return 0 + } + decimal := GpsToDecimal(val) + if decimal == 0 || GpsRefRegexp.MatchString(val) { + return decimal + } + if isNegativeRef(refChain.firstNonEmpty(root)) { + return -decimal + } + return decimal +} + +// Altitude returns the GPS altitude in metres. +// Priority: exif:GPSAltitude (parsed as rational, e.g. "3450/100" → 34.5). +// Composition: exif:GPSAltitudeRef = "1" inverts the sign. +func (doc *XmpDocument) Altitude() float64 { + val := xmpGPSAltitudeChain.firstNonEmpty(doc.doc) + if val == "" { + return 0 + } + alt, ok := parseRational(val) + if !ok { + return 0 + } + if xmpGPSAltitudeRefChain.firstNonEmpty(doc.doc) == "1" { + return -alt + } + return alt +} + +// TakenGps returns the GPS timestamp as a UTC time.Time. +// Priority: exif:GPSTimeStamp (canonical combined ISO 8601 datetime). +// Composition: legacy writers split date/time across exif:GPSDateStamp +// and exif:GPSTimeStamp; the two are joined when the canonical fails. +func (doc *XmpDocument) TakenGps() time.Time { + ts := xmpGPSTimeStampChain.firstNonEmpty(doc.doc) + if ts == "" { + return time.Time{} + } + if t := txt.ParseTime(ts, ""); !t.IsZero() { + return t + } + ds := xmpGPSDateStampChain.firstNonEmpty(doc.doc) + if ds == "" { + return time.Time{} + } + return txt.ParseTime(ds+" "+ts, "") +} + +// rationalAccessor reads a rational-valued chain and returns the +// decimal (0 when absent or unparseable). Shared by Aperture/FNumber/FocalLength. +func rationalAccessor(chain chainXPath, root *xmlquery.Node) float64 { + val := chain.firstNonEmpty(root) + if val == "" { + return 0 + } + if v, ok := parseRational(val); ok { + return v + } + return 0 +} + +// isNegativeRef returns true when ref starts with S or W (case-insensitive), +// matching the GPSLatitudeRef / GPSLongitudeRef sign convention. +func isNegativeRef(ref string) bool { + if ref == "" { + return false + } + r := ref[0] + return r == 'S' || r == 's' || r == 'W' || r == 'w' +} + +// parseSubSec converts an EXIF SubSecTime string ("899614") to +// nanoseconds (899,614,000). Inputs longer than 9 digits or +// non-numeric values yield 0. +func parseSubSec(s string) int { + s = strings.TrimSpace(s) + if s == "" || len(s) > 9 { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil || n < 0 { + return 0 + } + for i := len(s); i < 9; i++ { + n *= 10 + } + return n +} + +// formatExposure renders a duration as "1/N" for sub-second values +// and as a decimal for one-second-or-longer durations. +func formatExposure(secs float64) string { + if secs <= 0 { + return "" + } + if secs >= 1 { + return strconv.FormatFloat(secs, 'f', -1, 64) + } + return fmt.Sprintf("1/%d", int(math.Round(1/secs))) +} + +// apexToSeconds converts an APEX shutter-speed value (Tv) to seconds +// via t = 2^(-Tv). Input is clamped to [-30, 30] to avoid ±Inf on +// pathological values; the range covers any realistic camera output. +func apexToSeconds(apex float64) float64 { + if apex < -30 { + apex = -30 + } else if apex > 30 { + apex = 30 + } + return math.Pow(2, -apex) +} + +// parseRational parses an XMP rational ("N/D") or plain float. +// The bool distinguishes "parsed as zero" from "could not parse". +func parseRational(s string) (float64, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + if i := strings.IndexByte(s, '/'); i >= 0 { + num, err1 := strconv.ParseFloat(strings.TrimSpace(s[:i]), 64) + den, err2 := strconv.ParseFloat(strings.TrimSpace(s[i+1:]), 64) + if err1 != nil || err2 != nil || den == 0 { + return 0, false + } + return num / den, true + } + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f, true + } + return 0, false } diff --git a/internal/meta/xmp_document_test.go b/internal/meta/xmp_document_test.go new file mode 100644 index 000000000..163db92c3 --- /dev/null +++ b/internal/meta/xmp_document_test.go @@ -0,0 +1,870 @@ +package meta + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/antchfx/xmlquery" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/fs" +) + +// parseXMPString parses an in-memory XMP string and returns the root +// xmlquery node. Test helper that keeps focused unit tests free of +// temp-file plumbing. +func parseXMPString(t *testing.T, xmp string) *xmlquery.Node { + t.Helper() + n, err := xmlquery.Parse(strings.NewReader(xmp)) + if err != nil { + t.Fatalf("parseXMPString: %v", err) + } + return n +} + +// loadXmp loads an XMP fixture from path and returns the populated +// XmpDocument; t.Fatal on error. Used by accessor tests that exercise +// the full Load → parse → DOM pipeline. +func loadXmp(t *testing.T, path string) *XmpDocument { + t.Helper() + var doc XmpDocument + if err := doc.Load(path); err != nil { + t.Fatalf("load %s: %v", path, err) + } + return &doc +} + +// loadXmpString writes body to a temp file in t.TempDir(), loads it +// through Load, and returns the populated XmpDocument. Avoids +// inline-fixture boilerplate in tests that synthesise XMP on the fly. +func loadXmpString(t *testing.T, body string) *XmpDocument { + t.Helper() + tmp := filepath.Join(t.TempDir(), "synthetic.xmp") + if err := os.WriteFile(tmp, []byte(body), fs.ModeFile); err != nil { + t.Fatalf("write temp xmp: %v", err) + } + return loadXmp(t, tmp) +} + +func TestMaxDepth(t *testing.T) { + t.Run("Nil", func(t *testing.T) { + assert.Equal(t, 0, maxDepth(nil, 0)) + }) + t.Run("FlatRoot", func(t *testing.T) { + n := parseXMPString(t, ``) + // xmlquery wraps the root in a document node; element is + // at depth 1 from the document. + assert.Equal(t, 1, maxDepth(n, 0)) + }) + t.Run("NestedFiveDeep", func(t *testing.T) { + n := parseXMPString(t, ``) + assert.Equal(t, 5, maxDepth(n, 0)) + }) + t.Run("MixedSiblingsTakesDeepestBranch", func(t *testing.T) { + // Left branch: depth 2; right branch: depth 4. Reader must + // return the deeper of the two, not the first encountered. + n := parseXMPString(t, ``) + assert.Equal(t, 5, maxDepth(n, 0)) + }) + t.Run("IgnoresTextAndCommentNodes", func(t *testing.T) { + // Comments and text must not inflate the count. + n := parseXMPString(t, `some text`) + assert.Equal(t, 2, maxDepth(n, 0)) + }) +} + +func TestMustCompile(t *testing.T) { + t.Run("ValidExpression", func(t *testing.T) { + expr := mustCompile("//dc:title") + assert.NotNil(t, expr) + }) + t.Run("PanicsOnGarbage", func(t *testing.T) { + assert.Panics(t, func() { + mustCompile("not [[ valid") + }) + }) + t.Run("PanicsOnUnknownPrefix", func(t *testing.T) { + assert.Panics(t, func() { + mustCompile("//bogus:Element") + }) + }) +} + +func TestChainXPath_FirstNonEmpty(t *testing.T) { + doc := parseXMPString(t, ` + + + + + + From dc:title + + + From Headline + + +`) + + t.Run("FirstLinkWins", func(t *testing.T) { + // First link in chain matches and returns its value. + c := chainXPath{ + mustCompile("//dc:title/rdf:Alt/rdf:li[@xml:lang='x-default']"), + mustCompile("//photoshop:Headline"), + } + assert.Equal(t, "From dc:title", c.firstNonEmpty(doc)) + }) + t.Run("FallsThroughOnNoMatch", func(t *testing.T) { + // First link does not match, second link wins. + c := chainXPath{ + mustCompile("//dc:nonexistent"), + mustCompile("//photoshop:Headline"), + } + assert.Equal(t, "From Headline", c.firstNonEmpty(doc)) + }) + t.Run("ReturnsEmptyWhenNoLinkMatches", func(t *testing.T) { + c := chainXPath{ + mustCompile("//dc:nonexistent"), + mustCompile("//tiff:Make"), + } + assert.Equal(t, "", c.firstNonEmpty(doc)) + }) + t.Run("HandlesNilRoot", func(t *testing.T) { + c := chainXPath{mustCompile("//dc:title")} + assert.Equal(t, "", c.firstNonEmpty(nil)) + }) + t.Run("HandlesEmptyChain", func(t *testing.T) { + c := chainXPath{} + assert.Equal(t, "", c.firstNonEmpty(doc)) + }) +} + +func TestQueryAll(t *testing.T) { + doc := parseXMPString(t, ` + + + + + + One + Two + + Three + + + + +`) + + t.Run("ReturnsAllMatches", func(t *testing.T) { + got := queryAll(doc, mustCompile("//dc:subject/rdf:Bag/rdf:li")) + assert.Equal(t, []string{"One", "Two", "Three"}, got) + }) + t.Run("DropsEmptyTrimmedMatches", func(t *testing.T) { + // The third contains only whitespace and is dropped. + got := queryAll(doc, mustCompile("//dc:subject/rdf:Bag/rdf:li")) + assert.NotContains(t, got, "") + assert.Len(t, got, 3) + }) + t.Run("ReturnsNilForNilRoot", func(t *testing.T) { + assert.Nil(t, queryAll(nil, mustCompile("//dc:subject"))) + }) + t.Run("ReturnsNilForNilExpression", func(t *testing.T) { + assert.Nil(t, queryAll(doc, nil)) + }) + t.Run("EmptyMatchSetReturnsEmptySlice", func(t *testing.T) { + got := queryAll(doc, mustCompile("//dc:nonexistent")) + assert.Empty(t, got) + }) +} + +func TestXmpDocument_Load(t *testing.T) { + t.Run("MissingFile", func(t *testing.T) { + var doc XmpDocument + err := doc.Load("testdata/does-not-exist.xmp") + assert.Error(t, err) + }) + t.Run("MalformedXML", func(t *testing.T) { + // Write a malformed XMP to a temp file and assert Load surfaces + // the parse error rather than silently succeeding. + tmp := filepath.Join(t.TempDir(), "broken.xmp") + if err := writeFile(t, tmp, ""); err != nil { + t.Fatal(err) + } + var doc XmpDocument + err := doc.Load(tmp) + assert.Error(t, err) + }) + t.Run("WellFormedSidecar", func(t *testing.T) { + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.NotNil(t, doc.doc) + }) +} + +// writeFile writes content to path with mode fs.ModeFile. Returns the +// underlying error so tests can assert on it. +func writeFile(t *testing.T, path, content string) error { + t.Helper() + return os.WriteFile(path, []byte(content), fs.ModeFile) +} + +func TestXmpDocument_TitleAltLanguageFallback(t *testing.T) { + t.Run("PrefersXDefault", func(t *testing.T) { + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.Equal(t, "Night Shift / Berlin / 2020", doc.Title()) + }) + t.Run("FallsBackToFirstLiWhenNoXDefault", func(t *testing.T) { + // alt-edge-cases.xmp has dc:title with only de + en (no x-default). + // The chain must take the first li (de = "Sonnenuntergang"). + doc := loadXmp(t, "testdata/xmp/synthetic/alt-edge-cases.xmp") + assert.Equal(t, "Sonnenuntergang", doc.Title()) + }) + t.Run("FallsBackToBareText", func(t *testing.T) { + // apple-test-2.xmp has Botanischer Garten + // without rdf:Alt; the chain falls through to the bare-text branch. + doc := loadXmp(t, "testdata/apple-test-2.xmp") + assert.Equal(t, "Botanischer Garten", doc.Title()) + }) + t.Run("FallsBackToPhotoshopHeadline", func(t *testing.T) { + // Synthetic case: no dc:title, only photoshop:Headline. The + // chain's last link must trigger. + doc := loadXmpString(t, ` + + + + Headline-only Title + + +`) + assert.Equal(t, "Headline-only Title", doc.Title()) + }) +} + +func TestXmpDocument_KeywordsBagAndSeq(t *testing.T) { + t.Run("BagForm", func(t *testing.T) { + // digikam fixture writes dc:subject as , which the old + // reader silently dropped. Keywords() must now read it. + doc := loadXmp(t, "testdata/xmp/digikam/aurora.jpg.xmp") + got := doc.Keywords() + assert.Contains(t, got, "Nature") + assert.Contains(t, got, "Iceland") + assert.Contains(t, got, "Aurora") + }) + t.Run("SeqForm", func(t *testing.T) { + // Synthetic Seq fixture confirms the legacy form still works. + doc := loadXmp(t, "testdata/xmp/synthetic/subject-seq.xmp") + assert.Equal(t, "Sequenced, Keywords, Should, Also, Parse", doc.Keywords()) + }) + t.Run("EmptyForMissingSubject", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, "", doc.Keywords()) + }) +} + +func TestXmpDocument_Subject(t *testing.T) { + t.Run("MirrorsKeywordsFromDcSubject", func(t *testing.T) { + // dc:subject is first in the ExifTool Subject cascade and present in + // most tagged files, so Subject equals the keyword list. + doc := loadXmp(t, "testdata/xmp/darktable/aurora.jpg.xmp") + assert.Equal(t, doc.Keywords(), doc.Subject()) + assert.Contains(t, doc.Subject(), "Aurora") + }) + t.Run("PersonInImageFallback", func(t *testing.T) { + // No dc:subject → Subject falls back to Iptc4xmpExt:PersonInImage. + doc := loadXmpString(t, ` + + + + + + Alice + Bob + + + + +`) + assert.Equal(t, "", doc.Keywords()) + assert.Equal(t, "Alice, Bob", doc.Subject()) + }) + t.Run("HierarchicalSubjectFallback", func(t *testing.T) { + // No dc:subject, no PersonInImage → lr:hierarchicalSubject. + doc := loadXmpString(t, ` + + + + + + Nature|Animals + Places + + + + +`) + assert.Equal(t, "Nature|Animals, Places", doc.Subject()) + }) + t.Run("EmptyWhenNoSource", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, "", doc.Subject()) + }) +} + +func TestXmpDocument_FavoriteAttribute(t *testing.T) { + t.Run("True", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.True(t, doc.Favorite()) + }) + t.Run("FalseWhenAttributeAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.False(t, doc.Favorite()) + }) + t.Run("FalseWhenDocumentNotLoaded", func(t *testing.T) { + var doc XmpDocument + assert.False(t, doc.Favorite()) + }) +} + +func TestXmpDocument_MultiRdfDescription(t *testing.T) { + // multi-rdf-description.xmp splits properties across four sibling + // blocks. The XPath reader must walk them all. + doc := loadXmp(t, "testdata/xmp/synthetic/multi-rdf-description.xmp") + assert.Equal(t, "Multi-Description Fixture", doc.Title()) + assert.Equal(t, "PhotoPrism", doc.Artist()) + assert.Equal(t, "SyntheticCam", doc.CameraMake()) + assert.Equal(t, "SC-1 Mark II", doc.CameraModel()) + assert.Equal(t, "00000000-0000-0000-0000-000000000000", doc.DocumentID()) + assert.Equal(t, "xmp.iid:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", doc.InstanceID()) +} + +func TestXmpDocument_License(t *testing.T) { + t.Run("DigikamFixture", func(t *testing.T) { + // digiKam writes xmpRights:UsageTerms as lang-alt with x-default. + doc := loadXmp(t, "testdata/xmp/digikam/aurora.jpg.xmp") + assert.Equal(t, "CC-BY-SA 4.0", doc.License()) + }) + t.Run("AdobeBridgeFixture", func(t *testing.T) { + doc := loadXmp(t, "testdata/xmp/adobe/bridge-2.xmp") + assert.NotEmpty(t, doc.License()) + }) + t.Run("EmptyWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.Equal(t, "", doc.License()) + }) +} + +func TestXmpDocument_Software(t *testing.T) { + t.Run("SyntheticFixture", func(t *testing.T) { + // software-only.xmp carries only xmp:CreatorTool. + doc := loadXmp(t, "testdata/xmp/synthetic/software-only.xmp") + assert.Equal(t, "PhotoPrism Synthetic Fixture 1.0.0", doc.Software()) + }) + t.Run("EmptyWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, "", doc.Software()) + }) +} + +func TestXmpDocument_DocumentID(t *testing.T) { + t.Run("DigikamPrefersOriginalDocumentID", func(t *testing.T) { + // digiKam writes all three IDs; the chain must pick + // xmpMM:OriginalDocumentID — the asset-stable identifier. + doc := loadXmp(t, "testdata/xmp/digikam/aurora.jpg.xmp") + assert.Equal(t, "254738CA43CD69C01101874D65B006B4", doc.DocumentID()) + }) + t.Run("EmptyWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, "", doc.DocumentID()) + }) +} + +func TestXmpDocument_InstanceID(t *testing.T) { + t.Run("DigikamFixture", func(t *testing.T) { + doc := loadXmp(t, "testdata/xmp/digikam/aurora.jpg.xmp") + assert.Equal(t, "xmp.iid:de0fb44d-bdc8-4cc3-aa50-9aefe4992b34", doc.InstanceID()) + }) + t.Run("EmptyWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, "", doc.InstanceID()) + }) +} + +func TestXmpDocument_GPSCoordinates(t *testing.T) { + t.Run("AdobeTwoComponentForm", func(t *testing.T) { + // bridge-2 carries lat/lng/alt in 2-component form (Adobe XMP). + doc := loadXmp(t, "testdata/xmp/adobe/bridge-2.xmp") + assert.InEpsilon(t, 52.44308166666666, doc.Lat(), 1e-6) + assert.InEpsilon(t, 13.576613333333333, doc.Lng(), 1e-6) + // 1500000/100 = 15000 m above sea level. + assert.InEpsilon(t, 15000.0, doc.Altitude(), 1e-6) + }) + t.Run("SouthernHemisphereInValue", func(t *testing.T) { + // bridge.xmp Lat is "27,20.4263S" — sign comes from cardinal in + // the value string, not GPSLatitudeRef. + doc := loadXmp(t, "testdata/xmp/adobe/bridge.xmp") + assert.Less(t, doc.Lat(), 0.0) + assert.InEpsilon(t, -27.340438333333333, doc.Lat(), 1e-6) + }) + t.Run("DecimalWithSeparateRef", func(t *testing.T) { + // Synthetic case: pure-decimal value with sign supplied by the + // GPSLatitudeRef element. The accessor must apply the sign. + doc := loadXmpString(t, ` + + + + 47.6754 + S + 122.3127 + W + + +`) + assert.Equal(t, -47.6754, doc.Lat()) + assert.Equal(t, -122.3127, doc.Lng()) + }) + t.Run("AltitudeBelowSeaLevel", func(t *testing.T) { + // GPSAltitudeRef="1" inverts the sign. + doc := loadXmpString(t, ` + + + + 10000/100 + 1 + + +`) + assert.Equal(t, -100.0, doc.Altitude()) + }) + t.Run("ZeroWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, 0.0, doc.Lat()) + assert.Equal(t, 0.0, doc.Lng()) + assert.Equal(t, 0.0, doc.Altitude()) + }) +} + +func TestXmpDocument_TakenGps(t *testing.T) { + t.Run("CombinedISO8601Form", func(t *testing.T) { + // Canonical XMP: GPSTimeStamp is the full ISO 8601 datetime. + doc := loadXmp(t, "testdata/xmp/synthetic/gps-time-combined.xmp") + got := doc.TakenGps() + assert.False(t, got.IsZero()) + assert.Equal(t, 2026, got.Year()) + assert.Equal(t, 5, int(got.Month())) + assert.Equal(t, 6, got.Day()) + }) + t.Run("LegacySplitForm", func(t *testing.T) { + // Older writers split date and time across two elements. + doc := loadXmp(t, "testdata/xmp/synthetic/gps-time-split.xmp") + got := doc.TakenGps() + assert.False(t, got.IsZero(), "split-form GPSTimeStamp should still parse") + assert.Equal(t, 2026, got.Year()) + assert.Equal(t, 5, int(got.Month())) + assert.Equal(t, 6, got.Day()) + }) + t.Run("ZeroWhenAbsent", func(t *testing.T) { + // canon_eos_6d carries timestamps but no GPS time. + doc := loadXmp(t, "testdata/canon_eos_6d.xmp") + assert.True(t, doc.TakenGps().IsZero()) + }) +} + +func TestXmpDocument_CopyrightWebStatementFallback(t *testing.T) { + // When dc:rights is absent but xmpRights:WebStatement is present, + // the chain falls through to the URL. + doc := loadXmpString(t, ` + + + + https://example.org/license + + +`) + assert.Equal(t, "https://example.org/license", doc.Copyright()) +} + +func TestParseRational(t *testing.T) { + t.Run("RationalWithDenominator", func(t *testing.T) { + v, ok := parseRational("3450/100") + assert.True(t, ok) + assert.Equal(t, 34.5, v) + }) + t.Run("PureFloat", func(t *testing.T) { + v, ok := parseRational("47.6754") + assert.True(t, ok) + assert.Equal(t, 47.6754, v) + }) + t.Run("ZeroNumerator", func(t *testing.T) { + v, ok := parseRational("0/100") + assert.True(t, ok) + assert.Equal(t, 0.0, v) + }) + t.Run("DivisionByZero", func(t *testing.T) { + _, ok := parseRational("1/0") + assert.False(t, ok) + }) + t.Run("Empty", func(t *testing.T) { + _, ok := parseRational("") + assert.False(t, ok) + }) + t.Run("Garbage", func(t *testing.T) { + _, ok := parseRational("abc/def") + assert.False(t, ok) + }) + t.Run("Whitespace", func(t *testing.T) { + v, ok := parseRational(" 100/4 ") + assert.True(t, ok) + assert.Equal(t, 25.0, v) + }) +} + +func TestIsNegativeRef(t *testing.T) { + assert.True(t, isNegativeRef("S")) + assert.True(t, isNegativeRef("s")) + assert.True(t, isNegativeRef("W")) + assert.True(t, isNegativeRef("w")) + assert.True(t, isNegativeRef("South"), "prefix-based check accepts 'South'") + assert.False(t, isNegativeRef("N")) + assert.False(t, isNegativeRef("E")) + assert.False(t, isNegativeRef("")) + assert.False(t, isNegativeRef(" ")) +} + +func TestParseSubSec(t *testing.T) { + t.Run("SixDigits", func(t *testing.T) { + // "899614" represents 0.899614 s = 899,614,000 ns. + assert.Equal(t, 899614000, parseSubSec("899614")) + }) + t.Run("NineDigits", func(t *testing.T) { + // Nanosecond-precision input is returned unchanged. + assert.Equal(t, 729626112, parseSubSec("729626112")) + }) + t.Run("ThreeDigits", func(t *testing.T) { + // "123" represents 0.123 s = 123,000,000 ns. + assert.Equal(t, 123000000, parseSubSec("123")) + }) + t.Run("Whitespace", func(t *testing.T) { + assert.Equal(t, 500000000, parseSubSec(" 5 ")) + }) + t.Run("Empty", func(t *testing.T) { + assert.Equal(t, 0, parseSubSec("")) + }) + t.Run("TooLongRejected", func(t *testing.T) { + // >9 digits would overflow nanosecond precision. + assert.Equal(t, 0, parseSubSec("1234567890")) + }) + t.Run("NonNumericRejected", func(t *testing.T) { + assert.Equal(t, 0, parseSubSec("abc")) + }) +} + +func TestFormatExposure(t *testing.T) { + t.Run("FractionalSeconds", func(t *testing.T) { + assert.Equal(t, "1/250", formatExposure(0.004)) + assert.Equal(t, "1/50", formatExposure(0.02)) + assert.Equal(t, "1/2", formatExposure(0.5)) + }) + t.Run("OneSecondAndAbove", func(t *testing.T) { + assert.Equal(t, "1", formatExposure(1.0)) + assert.Equal(t, "2.5", formatExposure(2.5)) + assert.Equal(t, "30", formatExposure(30)) + }) + t.Run("ZeroOrNegative", func(t *testing.T) { + assert.Equal(t, "", formatExposure(0)) + assert.Equal(t, "", formatExposure(-1)) + }) +} + +func TestApexToSeconds(t *testing.T) { + // EXIF spec: Tv = 0 → 1 s, Tv = 5 → 1/32 s. + assert.InDelta(t, 1.0, apexToSeconds(0), 1e-9) + assert.InDelta(t, 0.5, apexToSeconds(1), 1e-9) + assert.InDelta(t, 1.0/32.0, apexToSeconds(5), 1e-9) + // Negative APEX = exposures longer than 1 second. + assert.InDelta(t, 2.0, apexToSeconds(-1), 1e-9) +} + +func TestXmpDocument_CameraSerial(t *testing.T) { + t.Run("PrefersExifEXSerialNumber", func(t *testing.T) { + // Synthetic exifEX fixture writes both exifEX:SerialNumber and + // (no aux:SerialNumber). Modern fallback wins. + doc := loadXmp(t, "testdata/xmp/synthetic/exifex-camera-lens.xmp") + assert.Equal(t, "SC1-BODY-123456", doc.CameraSerial()) + }) + t.Run("FallsBackToAuxSerialNumber", func(t *testing.T) { + // canon_eos_6d has aux:SerialNumber but no exifEX:SerialNumber. + doc := loadXmp(t, "testdata/xmp/synthetic/aux-only.xmp") + assert.Equal(t, "BODY-SN-123456", doc.CameraSerial()) + }) + t.Run("EmptyWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, "", doc.CameraSerial()) + }) +} + +func TestXmpDocument_LensMake(t *testing.T) { + t.Run("ExifEXFixture", func(t *testing.T) { + doc := loadXmp(t, "testdata/xmp/synthetic/exifex-camera-lens.xmp") + assert.Equal(t, "SyntheticLens Co.", doc.LensMake()) + }) + t.Run("IphoneSeven", func(t *testing.T) { + // iphone_7 fixture writes Apple. + doc := loadXmp(t, "testdata/iphone_7.xmp") + assert.Equal(t, "Apple", doc.LensMake()) + }) +} + +func TestXmpDocument_CameraOwner(t *testing.T) { + doc := loadXmp(t, "testdata/xmp/synthetic/aux-only.xmp") + assert.Equal(t, "Synthetic Photographer", doc.CameraOwner()) +} + +func TestXmpDocument_Projection(t *testing.T) { + t.Run("Equirectangular", func(t *testing.T) { + doc := loadXmp(t, "testdata/xmp/synthetic/gpano-360.xmp") + assert.Equal(t, "equirectangular", doc.Projection()) + }) + t.Run("EmptyWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.Equal(t, "", doc.Projection()) + }) +} + +func TestXmpDocument_ColorProfile(t *testing.T) { + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.Equal(t, "sRGB IEC61966-2.1", doc.ColorProfile()) +} + +func TestXmpDocument_Aperture(t *testing.T) { + t.Run("Photoshop", func(t *testing.T) { + // photoshop.xmp has 1695994/1000000. + doc := loadXmp(t, "testdata/photoshop.xmp") + // 1695994/1000000 ≈ 1.696 + assert.InDelta(t, 1.696, doc.Aperture(), 0.01) + }) + t.Run("ZeroWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, float32(0), doc.Aperture()) + }) +} + +func TestXmpDocument_FNumber(t *testing.T) { + // photoshop.xmp has 180/100 = 1.8. + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.InDelta(t, float32(1.8), doc.FNumber(), 0.01) +} + +func TestXmpDocument_FocalLength(t *testing.T) { + // photoshop.xmp has 5580/1000 ≈ 5.58 mm → 6. + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.Equal(t, 6, doc.FocalLength()) +} + +func TestXmpDocument_Iso(t *testing.T) { + t.Run("FromISOSpeedRatingsSeq", func(t *testing.T) { + // photoshop.xmp has 200... + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.Equal(t, 200, doc.Iso()) + }) + t.Run("FromExifEXPhotographicSensitivity", func(t *testing.T) { + // Synthetic exifEX fixture exposes the modern element. + doc := loadXmp(t, "testdata/xmp/synthetic/exifex-camera-lens.xmp") + assert.Equal(t, 800, doc.Iso()) + }) +} + +func TestXmpDocument_Exposure(t *testing.T) { + t.Run("ExposureTimeRational", func(t *testing.T) { + // photoshop.xmp has 20000000/1000000000 = 1/50 s. + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.Equal(t, "1/50", doc.Exposure()) + }) + t.Run("ShutterSpeedFallback", func(t *testing.T) { + // Synthetic case: only ShutterSpeedValue present. APEX 5 → 1/32 s. + doc := loadXmpString(t, ` + + + + 5/1 + + +`) + assert.Equal(t, "1/32", doc.Exposure()) + }) +} + +func TestXmpDocument_Flash(t *testing.T) { + t.Run("FalseFromStruct", func(t *testing.T) { + // photoshop.xmp has False... + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.False(t, doc.Flash()) + }) + t.Run("TrueFromStruct", func(t *testing.T) { + doc := loadXmpString(t, ` + + + + + True + 1 + + + +`) + assert.True(t, doc.Flash()) + }) + t.Run("FalseWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.False(t, doc.Flash()) + }) +} + +func TestXmpDocument_Notes(t *testing.T) { + t.Run("LangAlt", func(t *testing.T) { + doc := loadXmp(t, "testdata/xmp/synthetic/notes-usercomment.xmp") + assert.Contains(t, doc.Notes(), "Notes accessor") + }) + t.Run("EmptyWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, "", doc.Notes()) + }) +} + +func TestXmpDocument_TakenNs(t *testing.T) { + t.Run("FromSubSecTimeOriginal", func(t *testing.T) { + // photoshop.xmp has 899614. + doc := loadXmp(t, "testdata/photoshop.xmp") + assert.Equal(t, 899614000, doc.TakenNs()) + }) + t.Run("ZeroWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, 0, doc.TakenNs()) + }) +} + +func TestXmpDocument_TakenAtChainAndSubSecJoin(t *testing.T) { + t.Run("PreservesFractionalFromDateString", func(t *testing.T) { + // photoshop.xmp's photoshop:DateCreated already has fractional + // seconds; the SubSec join must NOT override. + doc := loadXmp(t, "testdata/photoshop.xmp") + got := doc.TakenAt("") + assert.Equal(t, 729626112, got.Nanosecond()) + }) + t.Run("JoinsSubSecWhenDateLacksFraction", func(t *testing.T) { + // Synthetic case: DateCreated without fractional + SubSec. + doc := loadXmpString(t, ` + + + + 2020-01-01T17:28:23 + 123456 + + +`) + got := doc.TakenAt("") + assert.Equal(t, 123456000, got.Nanosecond()) + }) + t.Run("FallsBackToDateTimeOriginal", func(t *testing.T) { + // No photoshop:DateCreated; the chain must move to + // exif:DateTimeOriginal. + doc := loadXmpString(t, ` + + + + 2019-08-15T10:00:00 + + +`) + got := doc.TakenAt("") + assert.Equal(t, 2019, got.Year()) + assert.Equal(t, 8, int(got.Month())) + }) +} + +func TestXmpDocument_CreatedAt(t *testing.T) { + t.Run("FromXmpCreateDate", func(t *testing.T) { + // photoshop.xmp has 2020-01-01T17:28:23. + doc := loadXmp(t, "testdata/photoshop.xmp") + got := doc.CreatedAt("") + assert.Equal(t, 2020, got.Year()) + }) + t.Run("FallsBackToXmpDMCreationDate", func(t *testing.T) { + // xmpDM:CreationDate is only used for video/audio sidecars. + doc := loadXmp(t, "testdata/xmp/synthetic/xmpdm-creationdate.xmp") + got := doc.CreatedAt("") + assert.Equal(t, 2026, got.Year()) + assert.Equal(t, 5, int(got.Month())) + assert.Equal(t, 6, got.Day()) + }) +} + +func TestXmpDocument_TimeOffset(t *testing.T) { + t.Run("OffsetTimeOriginal", func(t *testing.T) { + doc := loadXmp(t, "testdata/xmp/synthetic/time-offsets-subsec.xmp") + assert.Equal(t, "+02:00", doc.TimeOffset()) + }) + t.Run("EmptyWhenAbsent", func(t *testing.T) { + doc := loadXmp(t, "testdata/fstop-favorite.xmp") + assert.Equal(t, "", doc.TimeOffset()) + }) +} + +// TestXmpDocument_DarktableFixture covers the Darktable sidecar end to +// end — descriptive metadata, Bag-form keywords, and the 2-component +// Adobe GPS form that motivates the GpsToDecimal extension. The +// fixture also carries ``, `lr:hierarchicalSubject`, +// and `xmp:Rating`, all of which are out of scope for the current +// reader; the test asserts they are silently ignored rather than +// surfacing as garbage on any in-scope field. +func TestXmpDocument_DarktableFixture(t *testing.T) { + doc := loadXmp(t, "testdata/xmp/darktable/aurora.jpg.xmp") + + t.Run("DescriptiveMetadata", func(t *testing.T) { + assert.Equal(t, "XMP Test - Aurora", doc.Title()) + assert.Equal(t, "Test fixture for darktable XMP sidecar", doc.Description()) + assert.Equal(t, "PhotoPrism", doc.Artist()) + assert.Equal(t, "CC-BY-SA 4.0", doc.Copyright()) + }) + t.Run("BagFormKeywords", func(t *testing.T) { + // Darktable writes . The old reader + // dropped this entirely (it only handled ); the new + // reader must produce a non-empty list. + got := doc.Keywords() + assert.Contains(t, got, "Aurora") + assert.Contains(t, got, "Iceland") + assert.Contains(t, got, "Nature") + }) + t.Run("TwoComponentGPSForm", func(t *testing.T) { + // The fixture carries lat="87,21.291962N" / lng="179,59.546814W" — + // the canonical regression case for the GpsToDecimal extension. + // 87° 21.291962'N → 87 + 21.291962/60 ≈ 87.3549. + assert.InDelta(t, 87.3549, doc.Lat(), 0.001) + // W cardinal inverts the sign for longitude. + assert.Less(t, doc.Lng(), 0.0) + assert.InDelta(t, -179.9924, doc.Lng(), 0.001) + }) + t.Run("OutOfScopeTagsIgnored", func(t *testing.T) { + // xmp:Rating is gap-analysis section 2 (rating/triage feature + // epic). The reader has no accessor for it, so no assertion is + // possible directly — we instead confirm that none of the + // in-scope accessors leak the rating value into their output. + assert.NotContains(t, doc.Title(), "1") + assert.NotContains(t, doc.Description(), "1") + // darktable:history blocks must not pollute Software either. + assert.Equal(t, "", doc.Software()) + }) +} diff --git a/internal/meta/xmp_migration_test.go b/internal/meta/xmp_migration_test.go new file mode 100644 index 000000000..dd6bca3ec --- /dev/null +++ b/internal/meta/xmp_migration_test.go @@ -0,0 +1,112 @@ +package meta + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestXMP_OverridesPreExistingMetaValues covers the meta-layer half of +// the SrcXmp > SrcMeta contract: every value the sidecar supplies +// overwrites the stale embedded-path value. Per-field SrcMeta → SrcXmp +// transitions are enforced by the entity setters that consume meta.Data. +func TestXMP_OverridesPreExistingMetaValues(t *testing.T) { + data := Data{ + Title: "Stale Embedded Title", + Caption: "Stale Embedded Caption", + Lat: 40.0, + Lng: -74.0, + Altitude: 99.9, + CameraMake: "WrongMake", + CameraModel: "WrongModel", + } + + // multi-rdf-description.xmp spreads ten fields across four sibling + // rdf:Description blocks. + if err := data.XMP("testdata/xmp/synthetic/multi-rdf-description.xmp"); err != nil { + t.Fatalf("data.XMP: %v", err) + } + + // Sidecar values must win. + assert.Equal(t, "Multi-Description Fixture", data.Title) + assert.Equal(t, "PhotoPrism", data.Artist) + assert.Equal(t, "SyntheticCam", data.CameraMake) + assert.Equal(t, "SC-1 Mark II", data.CameraModel) + assert.InDelta(t, 52.5076, data.Lat, 1e-3) + assert.InDelta(t, 13.4095, data.Lng, 1e-3) + assert.InDelta(t, 34.5, data.Altitude, 1e-3) + assert.Equal(t, "00000000-0000-0000-0000-000000000000", data.DocumentID) + assert.Equal(t, "xmp.iid:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", data.InstanceID) + + // Caption was absent from the fixture; the reader only overwrites + // when it has a non-empty replacement, so the stale value stays. + assert.Equal(t, "Stale Embedded Caption", data.Caption) +} + +// TestXMP_FillsPreviouslyEmptyFields covers the partial-sidecar → +// full-sidecar migration: a DB written by the old reader had only the +// four ✅ preserved fields set; the new reader fills in License, GPS, +// xmpMM IDs and the rest of the high-priority fields on re-index. +func TestXMP_FillsPreviouslyEmptyFields(t *testing.T) { + data := Data{ + Title: "XMP Test - Aurora", + Caption: "Test fixture for digiKam XMP sidecar", + } + + if err := data.XMP("testdata/xmp/digikam/aurora.jpg.xmp"); err != nil { + t.Fatalf("data.XMP: %v", err) + } + + // Previously-set values must round-trip without corruption. + assert.Equal(t, "XMP Test - Aurora", data.Title) + assert.Equal(t, "Test fixture for digiKam XMP sidecar", data.Caption) + + // Previously-empty fields must now be populated by the new reader. + assert.Equal(t, "CC-BY-SA 4.0", data.License) + assert.Equal(t, "254738CA43CD69C01101874D65B006B4", data.DocumentID) + assert.Equal(t, "xmp.iid:de0fb44d-bdc8-4cc3-aa50-9aefe4992b34", data.InstanceID) + assert.Equal(t, "(C) 2026 PhotoPrism — Test fixture", data.Copyright) + assert.Equal(t, "sRGB IEC61966-2.1", data.ColorProfile) + assert.NotEmpty(t, data.Keywords, "dc:subject Bag must populate Keywords") +} + +// TestXMP_GpsCompositionEndToEnd asserts that a 2-component Adobe-form +// sidecar populates Lat/Lng/Altitude as floats while leaving the +// ExifTool-format GPSLatitude/GPSLongitude string fields untouched. +func TestXMP_GpsCompositionEndToEnd(t *testing.T) { + // Pre-populated ExifTool-format strings must survive the XMP path. + data := Data{ + GPSLatitude: `40 deg 25' 12.0" N`, + GPSLongitude: `74 deg 0' 21.6" W`, + } + + tmp := filepath.Join(t.TempDir(), "adobe-gps.xmp") + if err := writeFile(t, tmp, ` + + + + 52,30.4567N + 13,24.5678E + 3450/100 + 0 + + +`); err != nil { + t.Fatal(err) + } + if err := data.XMP(tmp); err != nil { + t.Fatalf("data.XMP: %v", err) + } + + // Float fields populated from the XMP path. + assert.InDelta(t, 52.5076, data.Lat, 1e-3) + assert.InDelta(t, 13.4095, data.Lng, 1e-3) + assert.InDelta(t, 34.5, data.Altitude, 1e-3) + + // String fields keep the ExifTool DMS format — the XMP path + // populates Lat/Lng/Altitude floats only. + assert.Equal(t, `40 deg 25' 12.0" N`, data.GPSLatitude) + assert.Equal(t, `74 deg 0' 21.6" W`, data.GPSLongitude) +} diff --git a/internal/meta/xmp_security_test.go b/internal/meta/xmp_security_test.go new file mode 100644 index 000000000..7eed767e3 --- /dev/null +++ b/internal/meta/xmp_security_test.go @@ -0,0 +1,91 @@ +package meta + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestXmpSecurity_OversizeFileRejected covers the size-cap branch of +// Load: a 1 MiB + 1 byte file must error with ErrXmpFileTooLarge +// before any parsing happens. +func TestXmpSecurity_OversizeFileRejected(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "oversize.xmp") + payload := strings.Repeat("A", xmpMaxFileSize+1) + if err := writeFile(t, tmp, payload); err != nil { + t.Fatal(err) + } + var doc XmpDocument + err := doc.Load(tmp) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrXmpFileTooLarge), "got %v, want ErrXmpFileTooLarge", err) +} + +// TestXmpSecurity_DepthBombRejected covers the depth-cap branch: +// nesting one level deeper than xmpMaxDepth errors with ErrXmpTooDeep; +// documents exactly at the limit are still accepted. +func TestXmpSecurity_DepthBombRejected(t *testing.T) { + t.Run("ExceedsLimit", func(t *testing.T) { + nesting := xmpMaxDepth + 1 + body := strings.Repeat("", nesting) + strings.Repeat("", nesting) + tmp := filepath.Join(t.TempDir(), "deep.xmp") + if err := writeFile(t, tmp, ``+body); err != nil { + t.Fatal(err) + } + var doc XmpDocument + err := doc.Load(tmp) + assert.Error(t, err) + assert.True(t, errors.Is(err, ErrXmpTooDeep), "got %v, want ErrXmpTooDeep", err) + }) + t.Run("AtLimitAllowed", func(t *testing.T) { + // xmpMaxDepth-1 elements puts the deepest one at xmpMaxDepth + // counting the document node — the highest accepted value. + nesting := xmpMaxDepth - 1 + body := strings.Repeat("", nesting) + strings.Repeat("", nesting) + tmp := filepath.Join(t.TempDir(), "at-limit.xmp") + if err := writeFile(t, tmp, ``+body); err != nil { + t.Fatal(err) + } + var doc XmpDocument + err := doc.Load(tmp) + assert.NoError(t, err) + }) +} + +// TestXmpSecurity_XXENotResolved guards against a future loader change +// that switches to a less-defensive parser. encoding/xml does not +// resolve external entities by default; this asserts &xxe; is never +// expanded to the contents of /etc/hostname or any other file. +func TestXmpSecurity_XXENotResolved(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "xxe.xmp") + body := ` + ]> + + + + &xxe; + + +` + if err := writeFile(t, tmp, body); err != nil { + t.Fatal(err) + } + var doc XmpDocument + if err := doc.Load(tmp); err != nil { + // encoding/xml errors on unresolved entity references in strict + // mode — acceptable, proves the entity was not fetched. + t.Logf("Load rejected XXE document with %v", err) + return + } + // If Load succeeded, the title must be empty or the literal "&xxe;"; + // anything resembling a hostname or path is a regression. + title := doc.Title() + assert.NotContains(t, title, "/etc/") + if title != "" && title != "&xxe;" { + t.Errorf("unexpected Title %q after XXE attempt — entity may have resolved", title) + } +} diff --git a/internal/meta/xmp_test.go b/internal/meta/xmp_test.go index c850cdb93..c972604b2 100644 --- a/internal/meta/xmp_test.go +++ b/internal/meta/xmp_test.go @@ -1,10 +1,14 @@ package meta import ( + "os" + "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/pkg/fs" ) func TestXMP(t *testing.T) { @@ -17,8 +21,15 @@ func TestXMP(t *testing.T) { assert.Equal(t, "Botanischer Garten", data.Title) assert.Equal(t, time.Date(2021, 3, 24, 13, 07, 29, 0, time.FixedZone("", +3600)).UTC(), data.TakenAt.UTC()) + // GPS resolves to Europe/Berlin; March 24 2021 is still CET (DST starts March 28), + // so wall-clock 13:07:29 +01:00 = 12:07:29 UTC. + assert.Equal(t, "Europe/Berlin", data.TimeZone) + assert.Equal(t, "2021-03-24 13:07:29", data.TakenAtLocal.Format("2006-01-02 15:04:05")) assert.Equal(t, "Tulpen am See", data.Caption) assert.Equal(t, Keywords{"blume", "krokus", "schöne", "wiese"}, data.Keywords) + // Apple GPS — pure-decimal value with separate *Ref. + assert.InDelta(t, 52.525082, data.Lat, 1e-4) + assert.InDelta(t, 13.369367, data.Lng, 1e-4) }) t.Run("Photoshop", func(t *testing.T) { data, err := XMP("testdata/photoshop.xmp") @@ -28,13 +39,26 @@ func TestXMP(t *testing.T) { } assert.Equal(t, "Night Shift / Berlin / 2020", data.Title) - assert.Equal(t, time.Date(2020, 1, 1, 17, 28, 25, 729626112, time.UTC), data.TakenAt) + // GPS resolves to Europe/Berlin. Wall-clock from photoshop:DateCreated is + // "2020-01-01T17:28:25.729626112" — the resolver re-parses it in Berlin + // (CET / +01:00) and re-joins the sub-second fraction from + // exif:SubSecTimeOriginal (899614 → 899614000ns), matching the EXIF flow + // where SubSecTimeOriginal is authoritative for the nanosecond component. + assert.Equal(t, time.Date(2020, 1, 1, 16, 28, 25, 899614000, time.UTC), data.TakenAt) + assert.Equal(t, "Europe/Berlin", data.TimeZone) + assert.Equal(t, "2020-01-01 17:28:25", data.TakenAtLocal.Format("2006-01-02 15:04:05")) assert.Equal(t, "Michael Mayer", data.Artist) assert.Equal(t, "Example file for development", data.Caption) assert.Equal(t, "This is an (edited) legal notice", data.Copyright) + // dc:subject mirrors into Subject for ExifTool parity (the cascade's + // first source), so XMP-sidecar photos get the same details.Subject. + assert.Equal(t, "desk, coffee, computer", data.Subject) assert.Equal(t, "HUAWEI", data.CameraMake) assert.Equal(t, "ELE-L29", data.CameraModel) assert.Equal(t, "HUAWEI P30 Rear Main Camera", data.LensModel) + // Adobe 2-component GPS form (degrees + decimal-minutes + cardinal). + assert.InDelta(t, 52.459690, data.Lat, 1e-4) + assert.InDelta(t, 13.321832, data.Lng, 1e-4) }) t.Run("CanonEosSixD", func(t *testing.T) { data, err := XMP("testdata/canon_eos_6d.xmp") @@ -66,6 +90,9 @@ func TestXMP(t *testing.T) { assert.Equal(t, "iPhone 7", data.CameraModel) assert.Equal(t, "iPhone 7 back camera 3.99mm f/1.8", data.LensModel) assert.Equal(t, false, data.Favorite) + // iPhone 7 sidecar uses Adobe 2-component GPS form. + assert.InDelta(t, 34.797450, data.Lat, 1e-4) + assert.InDelta(t, 134.764633, data.Lng, 1e-4) }) t.Run("Fstop", func(t *testing.T) { data, err := XMP("testdata/fstop-favorite.xmp") @@ -83,8 +110,139 @@ func TestXMP(t *testing.T) { t.Fatal(err) } + // photoshop:DateCreated = "2022-09-03T17:48:26-07:00" → 00:48:26 UTC the + // next day. GPS resolves to America/Los_Angeles, so TakenAtLocal carries + // the Seattle-area wall-clock (17:48:26 on Sept 3). assert.Equal(t, time.Date(2022, 9, 4, 0, 48, 26, 0, time.UTC), data.TakenAt.UTC()) - assert.True(t, data.TakenAtLocal.IsZero()) - assert.Equal(t, "UTC", data.TimeZone) + assert.Equal(t, "America/Los_Angeles", data.TimeZone) + assert.Equal(t, "2022-09-03 17:48:26", data.TakenAtLocal.Format("2006-01-02 15:04:05")) + // Apple HEIC: pure-decimal GPS with W cardinal → negative Lng. + assert.InDelta(t, 47.675403, data.Lat, 1e-4) + assert.InDelta(t, -122.317392, data.Lng, 1e-4) + assert.InDelta(t, 63.63, data.Altitude, 0.01) + }) + t.Run("SyntheticTimeOffsetsSubsec", func(t *testing.T) { + // No GPS — resolver derives the time zone from exif:OffsetTimeOriginal + // ("+02:00") and applies exif:SubSecTimeOriginal (123456 → 123456000ns). + data, err := XMP("testdata/xmp/synthetic/time-offsets-subsec.xmp") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "+02:00", data.TimeOffset) + assert.Equal(t, "UTC+2", data.TimeZone) + // photoshop:DateCreated already carries an inline .123456 fraction; the + // resolver preserves it because TakenAt.Nanosecond() != 0 short-circuits + // the TakenNs re-application. + assert.Equal(t, 123456000, data.TakenAt.Nanosecond()) + // The +02:00 offset is applied to the offset-less capture timestamp, so + // the wall-clock 15:42:18 local resolves to 13:42:18 UTC. + assert.Equal(t, "2026-05-06 13:42:18", data.TakenAt.UTC().Format("2006-01-02 15:04:05")) + assert.Equal(t, "2026-05-06 15:42:18", data.TakenAtLocal.Format("2006-01-02 15:04:05")) + }) + t.Run("SyntheticDateCreatedPriority", func(t *testing.T) { + // photoshop:DateCreated (capture time) must win over a later, disagreeing + // xmp:CreateDate (file write). No GPS, so nothing re-derives TakenAt — a + // regression guard for CreatedAt clobbering the higher-priority capture + // instant in the shared resolver. + data, err := XMP("testdata/xmp/synthetic/datecreated-priority.xmp") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "2026-01-15 10:00:00", data.TakenAt.Format("2006-01-02 15:04:05")) + assert.Equal(t, "2026-01-15 10:00:00", data.TakenAtLocal.Format("2006-01-02 15:04:05")) + }) + t.Run("SyntheticGpsTimeCombined", func(t *testing.T) { + // GPS coordinates + combined GPS timestamp ("2026-05-06T15:42:18Z"), no + // other capture timestamp. Resolver falls back to GPS UTC time first, + // then promotes the IANA zone from coordinates. + data, err := XMP("testdata/xmp/synthetic/gps-time-combined.xmp") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "Europe/Berlin", data.TimeZone) + assert.Equal(t, time.Date(2026, 5, 6, 15, 42, 18, 0, time.UTC), data.TakenAt.UTC()) + // May 6 is CEST in Berlin (+02:00); 15:42:18 UTC → 17:42:18 wall-clock. + assert.Equal(t, "2026-05-06 17:42:18", data.TakenAtLocal.Format("2006-01-02 15:04:05")) + }) + t.Run("SyntheticGpsTimeSplit", func(t *testing.T) { + // Same payload as Combined but expressed as split GPSDateStamp/GPSTimeStamp. + // Doc reader must reassemble them and the resolver must produce identical + // entity state to the combined case. + data, err := XMP("testdata/xmp/synthetic/gps-time-split.xmp") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "Europe/Berlin", data.TimeZone) + assert.Equal(t, time.Date(2026, 5, 6, 15, 42, 18, 0, time.UTC), data.TakenAt.UTC()) + assert.Equal(t, "2026-05-06 17:42:18", data.TakenAtLocal.Format("2006-01-02 15:04:05")) + }) + t.Run("SyntheticPanoramaKeyword", func(t *testing.T) { + // GPano:ProjectionType=equirectangular must auto-add the "panorama" + // keyword for parity with the EXIF/ExifTool paths + // (exif.go:327, json_exiftool.go:282-284). + data, err := XMP("testdata/xmp/synthetic/gpano-360.xmp") + + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "equirectangular", data.Projection) + assert.Contains(t, data.Keywords.String(), "panorama") + }) + t.Run("SyntheticAutoKeywordsFromCaption", func(t *testing.T) { + // dc:description containing "HDR" must trigger AutoAddKeywords — + // the "hdr" keyword is added and data.ImageType is set to + // ImageTypeHDR for parity with json_exiftool.go:287. + body := ` + + + + HDR sunset + + +` + tmp := filepath.Join(t.TempDir(), "caption-hdr.xmp") + if err := os.WriteFile(tmp, []byte(body), fs.ModeFile); err != nil { + t.Fatal(err) + } + + data, err := XMP(tmp) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "HDR sunset", data.Caption) + assert.Contains(t, data.Keywords.String(), "hdr") + assert.Equal(t, ImageTypeHDR, data.ImageType) + }) +} + +func TestApplyTimeOffset(t *testing.T) { + base := time.Date(2026, 5, 6, 15, 42, 18, 123456000, time.UTC) + + t.Run("PositiveOffset", func(t *testing.T) { + got := applyTimeOffset(base, "+02:00") + assert.Equal(t, "2026-05-06 15:42:18", got.Format("2006-01-02 15:04:05")) + assert.Equal(t, "2026-05-06 13:42:18", got.UTC().Format("2006-01-02 15:04:05")) + assert.Equal(t, 123456000, got.Nanosecond()) + }) + + t.Run("NegativeOffset", func(t *testing.T) { + got := applyTimeOffset(base, "-05:30") + assert.Equal(t, "2026-05-06 15:42:18", got.Format("2006-01-02 15:04:05")) + assert.Equal(t, "2026-05-06 21:12:18", got.UTC().Format("2006-01-02 15:04:05")) + }) + + t.Run("InvalidOffsetReturnsInput", func(t *testing.T) { + got := applyTimeOffset(base, "not-an-offset") + assert.Equal(t, base, got) }) } diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index 5001684b4..1445a34dc 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -484,6 +484,7 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot photo.SetCaption(data.Caption, entity.SrcXmp) photo.SetTakenAt(data.TakenAt, data.TakenAtLocal, data.TimeZone, entity.SrcXmp) photo.SetCoordinates(data.Lat, data.Lng, data.Altitude, entity.SrcXmp) + photo.SetCameraSerial(data.CameraSerial) // Update metadata details. details.SetKeywords(data.Keywords.String(), entity.SrcXmp) @@ -494,6 +495,53 @@ func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, phot details.SetLicense(data.License, entity.SrcXmp) details.SetSoftware(data.Software, entity.SrcXmp) + // Adopt the XMP DocumentID as the photo UUID. SrcXmp wins + // over an auto-generated UUID assigned in the SrcMeta branch. + // Real-world XMP DocumentIDs are often non-canonical (no dashes, + // `adobe:docid:` or `xmp.did:` prefixes), so the strict UUID + // check from data.HasDocumentID() is too narrow here. + if data.DocumentID != "" { + log.Infof("index: %s has document_id %s", logName, clean.Log(data.DocumentID)) + photo.UUID = data.DocumentID + } + + // Update camera, lens, and exposure from the sidecar. + photo.SetCamera(entity.FirstOrCreateCamera(entity.NewCamera(data.CameraMake, data.CameraModel)), entity.SrcXmp) + photo.SetLens(entity.FirstOrCreateLens(entity.NewLens(data.LensMake, data.LensModel)), entity.SrcXmp) + photo.SetExposure(data.FocalLength, data.FNumber, data.Iso, data.Exposure, entity.SrcXmp) + + // Mirror file-level identity metadata to the primary file so the + // UI surfaces it (per-file fields render the primary JPEG/HEIC). + // ColorProfile and Projection are not mirrored — they describe + // physical container properties, not user-supplied metadata. + // Only the changed columns are written: a full Save() would also + // re-resolve the primary flag and regenerate the search index, + // which neither InstanceID nor Software affects, and would issue + // those writes on every re-index pass even when nothing changed. + if primary, primaryErr := photo.PrimaryFile(); primaryErr == nil && primary != nil { + prevInstanceID, prevSoftware := primary.InstanceID, primary.FileSoftware + + if data.InstanceID != "" { + primary.InstanceID = data.InstanceID + } + primary.SetSoftware(data.Software) + + values := entity.Values{} + if primary.InstanceID != prevInstanceID { + log.Infof("index: %s has instance_id %s", logName, clean.Log(primary.InstanceID)) + values["instance_id"] = primary.InstanceID + } + if primary.FileSoftware != prevSoftware { + values["file_software"] = primary.FileSoftware + } + if len(values) > 0 { + values["updated_at"] = entity.Now() + if saveErr := primary.Updates(values); saveErr != nil { + log.Warnf("index: %s could not save primary file metadata (%s)", logName, saveErr) + } + } + } + // Update externally marked as favorite. if data.Favorite { _ = photo.SetFavorite(data.Favorite) diff --git a/internal/photoprism/index_related_test.go b/internal/photoprism/index_related_test.go index e8d8fb4e7..776fc2b4a 100644 --- a/internal/photoprism/index_related_test.go +++ b/internal/photoprism/index_related_test.go @@ -1,13 +1,16 @@ package photoprism import ( + "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/entity/query" + "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/rnd" ) @@ -138,4 +141,414 @@ func TestIndexRelated(t *testing.T) { assert.Equal(t, "xmp", photo.TakenSrc) } }) + t.Run("XmpCameraLensExposureMapping", func(t *testing.T) { + // Verifies that camera, lens, and exposure values from an XMP sidecar + // reach entity.Photo via the IsXMP indexer branch. + cfg := newIndexRelatedTestConfig(t, "index-related-xmp-camera") + + baseFile, err := NewMediaFile("testdata/apple-test-2.jpg") + if err != nil { + t.Fatal(err) + } + + testToken := rnd.Base36(8) + testPath := filepath.Join(cfg.OriginalsPath(), testToken) + baseName := "xmp-camera-mapping" + + jpegDest := filepath.Join(testPath, baseName+".jpg") + if copyErr := baseFile.Copy(jpegDest, false); copyErr != nil { + t.Fatalf("copying test file failed: %s", copyErr) + } + + xmpContent := ` + + + + SyntheticCam + SC-1 Mark II + SyntheticLens Co. + SL 50mm f/1.4 + BODY-XMP-9001 + + 800 + + 14/10 + 50/1 + 1/250 + + + +` + xmpDest := filepath.Join(testPath, baseName+".xmp") + if writeErr := os.WriteFile(xmpDest, []byte(xmpContent), fs.ModeFile); writeErr != nil { + t.Fatalf("writing xmp sidecar failed: %s", writeErr) + } + + mainFile, err := NewMediaFile(jpegDest) + if err != nil { + t.Fatal(err) + } + + related, err := mainFile.RelatedFiles(true) + if err != nil { + t.Fatal(err) + } + + convert := NewConvert(cfg) + ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) + opt := IndexOptionsAll(cfg) + + result := IndexRelated(related, ind, opt) + + assert.False(t, result.Failed()) + assert.True(t, result.Success()) + + photo, err := query.PhotoByUID(result.PhotoUID) + if err != nil { + t.Fatal(err) + } + + // Camera from XMP wiring (IsXMP branch). Re-resolve by Make/Model + // from the cache and assert the photo references the same row. + expectedCamera := entity.FirstOrCreateCamera(entity.NewCamera("SyntheticCam", "SC-1 Mark II")) + if assert.NotNil(t, expectedCamera) { + assert.Equal(t, expectedCamera.ID, photo.CameraID) + assert.NotEqual(t, entity.UnknownCamera.ID, photo.CameraID) + } + assert.Equal(t, entity.SrcXmp, photo.CameraSrc) + + // Lens from XMP wiring. + expectedLens := entity.FirstOrCreateLens(entity.NewLens("SyntheticLens Co.", "SL 50mm f/1.4")) + if assert.NotNil(t, expectedLens) { + assert.Equal(t, expectedLens.ID, photo.LensID) + assert.NotEqual(t, entity.UnknownLens.ID, photo.LensID) + } + + // Exposure values from XMP wiring. + assert.Equal(t, 800, photo.PhotoIso) + assert.InDelta(t, 1.4, float64(photo.PhotoFNumber), 0.001) + assert.Equal(t, 50, photo.PhotoFocalLength) + assert.Equal(t, "1/250", photo.PhotoExposure) + + // Camera serial from XMP wiring. + assert.Equal(t, "BODY-XMP-9001", photo.CameraSerial) + }) + t.Run("XmpMirrorsIdentityToPrimaryFile", func(t *testing.T) { + // InstanceID and Software from an XMP sidecar must reach the primary + // JPEG file row (per-file UI fields render the primary) — the IsXMP + // branch writes only the changed columns instead of a full File.Save(). + cfg := newIndexRelatedTestConfig(t, "index-related-xmp-primary-mirror") + + baseFile, err := NewMediaFile("testdata/apple-test-2.jpg") + if err != nil { + t.Fatal(err) + } + + testToken := rnd.Base36(8) + testPath := filepath.Join(cfg.OriginalsPath(), testToken) + baseName := "xmp-primary-mirror" + + jpegDest := filepath.Join(testPath, baseName+".jpg") + if copyErr := baseFile.Copy(jpegDest, false); copyErr != nil { + t.Fatalf("copying test file failed: %s", copyErr) + } + + xmpContent := ` + + + + SyntheticEditor 3.2 + xmp.iid:INSTANCE-XMP-7777 + + + +` + xmpDest := filepath.Join(testPath, baseName+".xmp") + if writeErr := os.WriteFile(xmpDest, []byte(xmpContent), fs.ModeFile); writeErr != nil { + t.Fatalf("writing xmp sidecar failed: %s", writeErr) + } + + mainFile, err := NewMediaFile(jpegDest) + if err != nil { + t.Fatal(err) + } + + related, err := mainFile.RelatedFiles(true) + if err != nil { + t.Fatal(err) + } + + convert := NewConvert(cfg) + ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) + opt := IndexOptionsAll(cfg) + + result := IndexRelated(related, ind, opt) + assert.False(t, result.Failed()) + assert.True(t, result.Success()) + + primary, primaryErr := entity.PrimaryFile(result.PhotoUID) + if primaryErr != nil { + t.Fatal(primaryErr) + } + assert.Equal(t, "xmp.iid:INSTANCE-XMP-7777", primary.InstanceID) + assert.Equal(t, "SyntheticEditor 3.2", primary.FileSoftware) + }) + t.Run("XmpSidecarTimezoneFromGps", func(t *testing.T) { + // Apple sidecar timestamp "2021-03-24T13:07:29+01:00" with Berlin GPS + // (52.525, 13.369) must reach the entity as Europe/Berlin time zone + // with the wall-clock preserved on TakenAtLocal — proves the shared + // ResolveTimeZone helper runs on the IsXMP indexer branch. + cfg := newIndexRelatedTestConfig(t, "index-related-xmp-tz-gps") + + baseFile, err := NewMediaFile("testdata/apple-test-2.jpg") + if err != nil { + t.Fatal(err) + } + + testRelated, err := baseFile.RelatedFiles(true) + if err != nil { + t.Fatal(err) + } + + testToken := rnd.Base36(8) + testPath := filepath.Join(cfg.OriginalsPath(), testToken) + + for _, f := range testRelated.Files { + dest := filepath.Join(testPath, f.BaseName()) + if copyErr := f.Copy(dest, false); copyErr != nil { + t.Fatalf("copying test file failed: %s", copyErr) + } + } + + mainFile, err := NewMediaFile(filepath.Join(testPath, "apple-test-2.jpg")) + if err != nil { + t.Fatal(err) + } + + related, err := mainFile.RelatedFiles(true) + if err != nil { + t.Fatal(err) + } + + convert := NewConvert(cfg) + ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) + opt := IndexOptionsAll(cfg) + + result := IndexRelated(related, ind, opt) + assert.True(t, result.Success()) + + photo, err := query.PhotoByUID(result.PhotoUID) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "Europe/Berlin", photo.TimeZone) + assert.Equal(t, "2021-03-24 12:07:29 +0000 UTC", photo.TakenAt.String()) + assert.Equal(t, "2021-03-24 13:07:29", photo.TakenAtLocal.Format("2006-01-02 15:04:05")) + assert.Equal(t, entity.SrcXmp, photo.TakenSrc) + }) + t.Run("XmpSidecarNoGpsNoOffset", func(t *testing.T) { + // Sidecar without GPS coordinates and without OffsetTime* — the + // resolver leaves data.TimeZone empty, and the entity layer's + // SetTakenAt maps the empty value to "Local" (its default for a + // timestamp with no derivable zone). The wall-clock is preserved + // verbatim on TakenAtLocal. + cfg := newIndexRelatedTestConfig(t, "index-related-xmp-tz-utc") + + baseFile, err := NewMediaFile("testdata/apple-test-2.jpg") + if err != nil { + t.Fatal(err) + } + + testToken := rnd.Base36(8) + testPath := filepath.Join(cfg.OriginalsPath(), testToken) + baseName := "xmp-tz-utc" + + jpegDest := filepath.Join(testPath, baseName+".jpg") + if copyErr := baseFile.Copy(jpegDest, false); copyErr != nil { + t.Fatalf("copying test file failed: %s", copyErr) + } + + xmpContent := ` + + + + 2024-06-15T12:00:00 + + + +` + xmpDest := filepath.Join(testPath, baseName+".xmp") + if writeErr := os.WriteFile(xmpDest, []byte(xmpContent), fs.ModeFile); writeErr != nil { + t.Fatalf("writing xmp sidecar failed: %s", writeErr) + } + + mainFile, err := NewMediaFile(jpegDest) + if err != nil { + t.Fatal(err) + } + + related, err := mainFile.RelatedFiles(true) + if err != nil { + t.Fatal(err) + } + + convert := NewConvert(cfg) + ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) + opt := IndexOptionsAll(cfg) + + result := IndexRelated(related, ind, opt) + assert.True(t, result.Success()) + + photo, err := query.PhotoByUID(result.PhotoUID) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "Local", photo.TimeZone) + assert.Equal(t, "2024-06-15 12:00:00 +0000 UTC", photo.TakenAt.String()) + assert.Equal(t, "2024-06-15 12:00:00", photo.TakenAtLocal.Format("2006-01-02 15:04:05")) + }) + t.Run("XmpSidecarGpsOverridesEmbedded", func(t *testing.T) { + // digikam.jpg carries embedded EXIF GPS in Berlin (52.46, 13.33). + // The synthesised XMP sidecar declares Tokyo coordinates so the + // override is unambiguous: photo.PhotoLat/PhotoLng must match the + // sidecar and photo.PlaceSrc must be tagged SrcXmp because + // SrcPriority[SrcXmp]=32 > SrcPriority[SrcMeta]=16. + cfg := newIndexRelatedTestConfig(t, "index-related-xmp-gps-override") + + baseFile, err := NewMediaFile("testdata/digikam.jpg") + if err != nil { + t.Fatal(err) + } + + testToken := rnd.Base36(8) + testPath := filepath.Join(cfg.OriginalsPath(), testToken) + baseName := "xmp-gps-override" + + jpegDest := filepath.Join(testPath, baseName+".jpg") + if copyErr := baseFile.Copy(jpegDest, false); copyErr != nil { + t.Fatalf("copying test file failed: %s", copyErr) + } + + xmpContent := ` + + + + 35.6586 + N + 139.7454 + E + + + +` + xmpDest := filepath.Join(testPath, baseName+".xmp") + if writeErr := os.WriteFile(xmpDest, []byte(xmpContent), fs.ModeFile); writeErr != nil { + t.Fatalf("writing xmp sidecar failed: %s", writeErr) + } + + mainFile, err := NewMediaFile(jpegDest) + if err != nil { + t.Fatal(err) + } + + related, err := mainFile.RelatedFiles(true) + if err != nil { + t.Fatal(err) + } + + convert := NewConvert(cfg) + ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) + opt := IndexOptionsAll(cfg) + + result := IndexRelated(related, ind, opt) + assert.True(t, result.Success()) + + photo, err := query.PhotoByUID(result.PhotoUID) + if err != nil { + t.Fatal(err) + } + + // Sidecar GPS (Tokyo) overrides embedded EXIF GPS (Berlin). + assert.InDelta(t, 35.6586, photo.PhotoLat, 1e-3) + assert.InDelta(t, 139.7454, photo.PhotoLng, 1e-3) + assert.Equal(t, entity.SrcXmp, photo.PlaceSrc) + }) + t.Run("XmpSidecarMalformedFileMarkedAndJpegIndexed", func(t *testing.T) { + // A malformed XMP sidecar must not block JPEG indexing. The IsXMP + // branch logs a warning, sets FileError on the XMP file row, and + // the indexer proceeds with the remaining related files. + cfg := newIndexRelatedTestConfig(t, "index-related-xmp-malformed") + + baseFile, err := NewMediaFile("testdata/apple-test-2.jpg") + if err != nil { + t.Fatal(err) + } + + testToken := rnd.Base36(8) + testPath := filepath.Join(cfg.OriginalsPath(), testToken) + baseName := "xmp-malformed" + + jpegDest := filepath.Join(testPath, baseName+".jpg") + if copyErr := baseFile.Copy(jpegDest, false); copyErr != nil { + t.Fatalf("copying test file failed: %s", copyErr) + } + + // Truncated XML — opening tag never closed. + malformedXmp := ` + + + + +` + xmpDest := filepath.Join(testPath, baseName+".xmp") + if writeErr := os.WriteFile(xmpDest, []byte(malformedXmp), fs.ModeFile); writeErr != nil { + t.Fatalf("writing xmp sidecar failed: %s", writeErr) + } + + mainFile, err := NewMediaFile(jpegDest) + if err != nil { + t.Fatal(err) + } + + related, err := mainFile.RelatedFiles(true) + if err != nil { + t.Fatal(err) + } + + convert := NewConvert(cfg) + ind := NewIndex(cfg, convert, NewFiles(), NewPhotos()) + opt := IndexOptionsAll(cfg) + + result := IndexRelated(related, ind, opt) + + // JPEG indexing must succeed even though the sidecar is broken. + assert.True(t, result.Success()) + photo, err := query.PhotoByUID(result.PhotoUID) + if err != nil { + t.Fatal(err) + } + + // Locate the XMP file row and assert FileError is populated. + var xmpFile *entity.File + for _, f := range photo.AllFiles() { + if filepath.Ext(f.FileName) == ".xmp" { + file := f + xmpFile = &file + break + } + } + if assert.NotNil(t, xmpFile, "malformed XMP file row must exist") { + assert.NotEmpty(t, xmpFile.FileError, "FileError must record the parse failure") + } + }) }