From 32a5819c8668efa31bd9402e5e95cf9132720fa7 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Mon, 15 Jun 2026 01:55:31 +0200 Subject: [PATCH] v0.8.3: add sidecar inspection --- CHANGELOG.md | 7 ++++ Makefile | 2 +- README.md | 7 ++++ RELEASE_NOTES.md | 14 ++++---- USERGUIDE.md | 7 ++++ cmd/photoscli/main.go | 69 ++++++++++++++++++++++++++++++++++++++ cmd/photoscli/main_test.go | 48 ++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23675fe..f9604fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## v0.8.3 + +XMP sidecar inspection release. + +- Add `sidecar inspect ` to print key photoscli metadata from generated XMP sidecars. +- Add `sidecar inspect --json` for scriptable inspection output. + ## v0.8.2 Metadata-only XMP refresh release. diff --git a/Makefile b/Makefile index e9bc806..15ed6b5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BINARY := ./bin/photoscli MODULE := gitea.k3s.k0.nu/tools/photocli -VERSION := 0.8.2 +VERSION := 0.8.3 RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_NOTES := RELEASE_NOTES.md BRIDGE_DIR := bridge diff --git a/README.md b/README.md index 39fcf3d..0ad8e2a 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,13 @@ Verify generated sidecars with: 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: ```bash diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8caa030..3a4c5e6 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 -- Add `--metadata-only` for `export` and `backup-all`. -- Use manifest paths to find existing media files and write or refresh `.xmp` sidecars without re-exporting media. -- Require `--sidecar xmp` and an enabled manifest for metadata-only mode. -- Leave media files untouched; only generated XMP sidecars are written. -- Support `--reverse-geocode` during metadata-only refresh. +- Add `sidecar inspect ` to print photoscli metadata embedded in a generated sidecar. +- Add `sidecar inspect --json` for scriptable output. +- Keep `verify --sidecar` for backup-wide checks and use `sidecar inspect` for one-file investigation. ## Assets - `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. Intel Macs are not currently a supported release target. diff --git a/USERGUIDE.md b/USERGUIDE.md index f3af7c0..080aa04 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -573,6 +573,13 @@ Verify sidecars after an export: 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: ```bash diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go index 5d45a57..edffaf2 100644 --- a/cmd/photoscli/main.go +++ b/cmd/photoscli/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/json" "encoding/xml" "fmt" @@ -91,6 +92,8 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { return cmdFailures(args[1:], stdout, stderr) case "status": return cmdStatus(args[1:], stdout, stderr) + case "sidecar": + return cmdSidecar(args[1:], stdout, stderr) case "version", "--version", "-v": fmt.Fprintln(stdout, version) return exitOK @@ -132,6 +135,7 @@ USAGE photoscli retry-failed --out --clear-on-success photoscli failures list --out photoscli failures clear --out + photoscli sidecar inspect [--json] photoscli status --out [--json] photoscli version photoscli help @@ -176,6 +180,9 @@ COMMANDS failures list|clear --out List or clear deduplicated failure records. + sidecar inspect [--json] + Read a generated XMP sidecar and print key photoscli metadata. + status --out [--manifest jsonl|sqlite] [--json] 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 } +func cmdSidecar(args []string, stdout, stderr io.Writer) int { + if len(args) < 1 || args[0] != "inspect" { + fmt.Fprintln(stderr, "error: expected sidecar inspect ") + return exitErr + } + if len(args) < 2 { + fmt.Fprintln(stderr, "error: sidecar inspect requires ") + 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 { outDir := flagVal(args, "--out") clearOnSuccess := hasFlag(args, "--clear-on-success") diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go index 8f094fe..31f9649 100644 --- a/cmd/photoscli/main_test.go +++ b/cmd/photoscli/main_test.go @@ -35,6 +35,10 @@ type mockBridge struct { cancelled atomic.Bool } +type errWriter struct{} + +func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") } + type noEntryManifest struct{} func (noEntryManifest) Has(string) bool { return false } @@ -4219,6 +4223,50 @@ func TestVerifySidecarBranches(t *testing.T) { 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 ") { + 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(``), 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(``)) + 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) { if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" { t.Fatalf("sidecar path = %q", got)