v0.8.3: add sidecar inspection
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 01:55:31 +02:00
parent a51db37fdb
commit 32a5819c86
7 changed files with 145 additions and 9 deletions
+7
View File
@@ -2,6 +2,13 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page. This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
## v0.8.3
XMP sidecar inspection release.
- Add `sidecar inspect <file.xmp>` to print key photoscli metadata from generated XMP sidecars.
- Add `sidecar inspect <file.xmp> --json` for scriptable inspection output.
## v0.8.2 ## v0.8.2
Metadata-only XMP refresh release. Metadata-only XMP refresh release.
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.8.2 VERSION := 0.8.3
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge BRIDGE_DIR := bridge
+7
View File
@@ -363,6 +363,13 @@ Verify generated sidecars with:
photoscli verify --out ./backup --sidecar photoscli verify --out ./backup --sidecar
``` ```
Inspect one generated sidecar with:
```bash
photoscli sidecar inspect ./backup/IMG_0001.xmp
photoscli sidecar inspect ./backup/IMG_0001.xmp --json
```
Refresh sidecars for files already present in a manifest-backed export without rewriting media files: Refresh sidecars for files already present in a manifest-backed export without rewriting media files:
```bash ```bash
+6 -8
View File
@@ -1,19 +1,17 @@
# v0.8.2 # v0.8.3
This release adds metadata-only XMP sidecar refresh for existing manifest-backed exports. This release adds XMP sidecar inspection for quick validation and scripting.
## Highlights ## Highlights
- Add `--metadata-only` for `export` and `backup-all`. - Add `sidecar inspect <file.xmp>` to print photoscli metadata embedded in a generated sidecar.
- Use manifest paths to find existing media files and write or refresh `.xmp` sidecars without re-exporting media. - Add `sidecar inspect <file.xmp> --json` for scriptable output.
- Require `--sidecar xmp` and an enabled manifest for metadata-only mode. - Keep `verify --sidecar` for backup-wide checks and use `sidecar inspect` for one-file investigation.
- Leave media files untouched; only generated XMP sidecars are written.
- Support `--reverse-geocode` during metadata-only refresh.
## Assets ## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.8.2-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. - `photoscli-0.8.3-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `USERGUIDE.md`: standalone user guide. - `USERGUIDE.md`: standalone user guide.
Intel Macs are not currently a supported release target. Intel Macs are not currently a supported release target.
+7
View File
@@ -573,6 +573,13 @@ Verify sidecars after an export:
This reports missing, zero-byte, unreadable, or asset-ID mismatched `.xmp` files. This reports missing, zero-byte, unreadable, or asset-ID mismatched `.xmp` files.
Inspect one generated sidecar when troubleshooting or scripting:
```bash
./bin/photoscli sidecar inspect ./PhotosBackup/IMG_0001.xmp
./bin/photoscli sidecar inspect ./PhotosBackup/IMG_0001.xmp --json
```
Refresh metadata only for an existing manifest-backed backup: Refresh metadata only for an existing manifest-backed backup:
```bash ```bash
+69
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
@@ -91,6 +92,8 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
return cmdFailures(args[1:], stdout, stderr) return cmdFailures(args[1:], stdout, stderr)
case "status": case "status":
return cmdStatus(args[1:], stdout, stderr) return cmdStatus(args[1:], stdout, stderr)
case "sidecar":
return cmdSidecar(args[1:], stdout, stderr)
case "version", "--version", "-v": case "version", "--version", "-v":
fmt.Fprintln(stdout, version) fmt.Fprintln(stdout, version)
return exitOK return exitOK
@@ -132,6 +135,7 @@ USAGE
photoscli retry-failed --out <dir> --clear-on-success photoscli retry-failed --out <dir> --clear-on-success
photoscli failures list --out <dir> photoscli failures list --out <dir>
photoscli failures clear --out <dir> photoscli failures clear --out <dir>
photoscli sidecar inspect <file.xmp> [--json]
photoscli status --out <dir> [--json] photoscli status --out <dir> [--json]
photoscli version photoscli version
photoscli help photoscli help
@@ -176,6 +180,9 @@ COMMANDS
failures list|clear --out <dir> failures list|clear --out <dir>
List or clear deduplicated failure records. List or clear deduplicated failure records.
sidecar inspect <file.xmp> [--json]
Read a generated XMP sidecar and print key photoscli metadata.
status --out <dir> [--manifest jsonl|sqlite] [--json] status --out <dir> [--manifest jsonl|sqlite] [--json]
Show manifest type, entry count, and failure count for a backup. Show manifest type, entry count, and failure count for a backup.
@@ -2187,6 +2194,68 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
return 0 return 0
} }
func cmdSidecar(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 || args[0] != "inspect" {
fmt.Fprintln(stderr, "error: expected sidecar inspect <file.xmp>")
return exitErr
}
if len(args) < 2 {
fmt.Fprintln(stderr, "error: sidecar inspect requires <file.xmp>")
return exitErr
}
path := args[1]
data, err := readFileFunc(path)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
meta := inspectXMP(data)
if len(meta) == 0 {
fmt.Fprintln(stderr, "error: no photoscli metadata found")
return exitErr
}
if hasFlag(args[2:], "--json") {
if err := json.NewEncoder(stdout).Encode(meta); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
return exitOK
}
keys := make([]string, 0, len(meta))
for k := range meta {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(stdout, "%s\t%s\n", k, meta[k])
}
return exitOK
}
func inspectXMP(data []byte) map[string]string {
attrs := map[string]string{}
dec := xml.NewDecoder(bytes.NewReader(data))
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return attrs
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
for _, a := range start.Attr {
if a.Name.Space == "photoscli" || a.Name.Space == "https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/" {
attrs[a.Name.Local] = a.Value
}
}
}
return attrs
}
func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
outDir := flagVal(args, "--out") outDir := flagVal(args, "--out")
clearOnSuccess := hasFlag(args, "--clear-on-success") clearOnSuccess := hasFlag(args, "--clear-on-success")
+48
View File
@@ -35,6 +35,10 @@ type mockBridge struct {
cancelled atomic.Bool cancelled atomic.Bool
} }
type errWriter struct{}
func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") }
type noEntryManifest struct{} type noEntryManifest struct{}
func (noEntryManifest) Has(string) bool { return false } func (noEntryManifest) Has(string) bool { return false }
@@ -4219,6 +4223,50 @@ func TestVerifySidecarBranches(t *testing.T) {
readFileFunc = oldRead readFileFunc = oldRead
} }
func TestSidecarInspect(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.xmp")
if err := writeXMPSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg", Album: "Trips"}); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"sidecar", "inspect", path}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "assetID\tx1") || !strings.Contains(out, "album\tTrips") {
t.Fatalf("inspect rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"sidecar", "inspect", path, "--json"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, `"assetID":"x1"`) || !strings.Contains(out, `"exportedFilename":"photo.jpg"`) {
t.Fatalf("inspect json rc=%d out=%q stderr=%q", rc, out, stderr)
}
_, stderr, rc = runWith([]string{"sidecar"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "expected sidecar inspect") {
t.Fatalf("inspect missing subcommand rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "requires <file.xmp>") {
t.Fatalf("inspect missing path rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect", filepath.Join(dir, "missing.xmp")}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("inspect missing file rc=%d stderr=%q", rc, stderr)
}
plain := filepath.Join(dir, "plain.xmp")
if err := os.WriteFile(plain, []byte(`<x:xmpmeta xmlns:x="adobe:ns:meta/"></x:xmpmeta>`), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect", plain}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "no photoscli metadata") {
t.Fatalf("inspect no metadata rc=%d stderr=%q", rc, stderr)
}
bad := inspectXMP([]byte(`<x:xmpmeta><rdf:RDF>`))
if len(bad) != 0 {
t.Fatalf("expected empty metadata on malformed XML, got %#v", bad)
}
stderrBuf := &bytes.Buffer{}
if rc := cmdSidecar([]string{"inspect", path, "--json"}, errWriter{}, stderrBuf); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") {
t.Fatalf("expected json encoder error rc=%d stderr=%q", rc, stderrBuf.String())
}
}
func TestXMPSidecarHelpers(t *testing.T) { func TestXMPSidecarHelpers(t *testing.T) {
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" { if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
t.Fatalf("sidecar path = %q", got) t.Fatalf("sidecar path = %q", got)