Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc37b8d8d | |||
| 32a5819c86 |
@@ -2,6 +2,20 @@
|
||||
|
||||
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.4
|
||||
|
||||
Strict XMP sidecar verification release.
|
||||
|
||||
- Add `verify --sidecar --strict` to require photoscli XMP schema metadata, sidecar generator metadata, and matching exported filename metadata.
|
||||
- Keep existing `verify --sidecar` behavior unchanged for backup-wide existence, readability, and asset-ID checks.
|
||||
|
||||
## 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
|
||||
|
||||
Metadata-only XMP refresh release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
BINARY := ./bin/photoscli
|
||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||
VERSION := 0.8.2
|
||||
VERSION := 0.8.4
|
||||
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
||||
RELEASE_NOTES := RELEASE_NOTES.md
|
||||
BRIDGE_DIR := bridge
|
||||
|
||||
@@ -363,6 +363,19 @@ Verify generated sidecars with:
|
||||
photoscli verify --out ./backup --sidecar
|
||||
```
|
||||
|
||||
For stricter checks against recent photoscli-generated XMP sidecars:
|
||||
|
||||
```bash
|
||||
photoscli verify --out ./backup --sidecar --strict
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
+6
-8
@@ -1,19 +1,17 @@
|
||||
# v0.8.2
|
||||
# v0.8.4
|
||||
|
||||
This release adds metadata-only XMP sidecar refresh for existing manifest-backed exports.
|
||||
This release adds strict XMP sidecar verification.
|
||||
|
||||
## 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 `verify --sidecar --strict` to require photoscli schema metadata, sidecar generator metadata, and matching exported filename metadata.
|
||||
- Keep existing `verify --sidecar` behavior unchanged for basic sidecar checks.
|
||||
- Use strict mode when validating sidecars generated by recent photoscli versions.
|
||||
|
||||
## 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.4-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.
|
||||
|
||||
@@ -573,6 +573,21 @@ Verify sidecars after an export:
|
||||
|
||||
This reports missing, zero-byte, unreadable, or asset-ID mismatched `.xmp` files.
|
||||
|
||||
Use strict verification for sidecars generated by recent photoscli versions:
|
||||
|
||||
```bash
|
||||
./bin/photoscli verify --out ./PhotosBackup --sidecar --strict
|
||||
```
|
||||
|
||||
Strict mode also checks photoscli schema metadata, generator metadata, and the exported filename recorded inside the sidecar.
|
||||
|
||||
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
|
||||
|
||||
+89
-3
@@ -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 <dir> --clear-on-success
|
||||
photoscli failures list --out <dir>
|
||||
photoscli failures clear --out <dir>
|
||||
photoscli sidecar inspect <file.xmp> [--json]
|
||||
photoscli status --out <dir> [--json]
|
||||
photoscli version
|
||||
photoscli help
|
||||
@@ -168,7 +172,8 @@ COMMANDS
|
||||
verify --out <dir> [--manifest jsonl|sqlite]
|
||||
Verify that manifest entries point to files that exist on disk. Missing
|
||||
files are printed as <asset-id><TAB><filename>. Exits 2 on missing files.
|
||||
Add --sidecar to verify expected XMP sidecars too.
|
||||
Add --sidecar to verify expected XMP sidecars too. Add --strict with
|
||||
--sidecar to require photoscli schema/generator and exported filename metadata.
|
||||
|
||||
retry-failed --out <dir>
|
||||
Retry assets previously written to failures.jsonl.
|
||||
@@ -176,6 +181,9 @@ COMMANDS
|
||||
failures list|clear --out <dir>
|
||||
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]
|
||||
Show manifest type, entry count, and failure count for a backup.
|
||||
|
||||
@@ -2110,6 +2118,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int
|
||||
func cmdVerify(args []string, stdout, stderr io.Writer) int {
|
||||
outDir := flagVal(args, "--out")
|
||||
checkSidecar := hasFlag(args, "--sidecar")
|
||||
strictSidecar := hasFlag(args, "--strict")
|
||||
if outDir == "" {
|
||||
fmt.Fprintln(stderr, "error: --out is required")
|
||||
return exitErr
|
||||
@@ -2149,7 +2158,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stdout, "%s\t%s\tsize-mismatch\tmanifest=%d\tdisk=%d\n", id, checkPath, e.Size, info.Size())
|
||||
}
|
||||
if checkSidecar {
|
||||
bad += verifySidecar(stdout, outDir, id, checkPath)
|
||||
bad += verifySidecar(stdout, outDir, id, checkPath, strictSidecar)
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
@@ -2159,7 +2168,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
|
||||
func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool) int {
|
||||
xmpPath := sidecarPath(filepath.Join(outDir, checkPath))
|
||||
rel, err := filepath.Rel(outDir, xmpPath)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
@@ -2184,9 +2193,86 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
|
||||
fmt.Fprintf(stdout, "%s\t%s\tsidecar-asset-mismatch\n", id, rel)
|
||||
return 1
|
||||
}
|
||||
if strict {
|
||||
meta := inspectXMP(data)
|
||||
if meta["xmpSchemaVersion"] != "2" {
|
||||
fmt.Fprintf(stdout, "%s\t%s\tsidecar-schema-missing\n", id, rel)
|
||||
return 1
|
||||
}
|
||||
if meta["sidecarGenerator"] == "" {
|
||||
fmt.Fprintf(stdout, "%s\t%s\tsidecar-generator-missing\n", id, rel)
|
||||
return 1
|
||||
}
|
||||
if meta["exportedFilename"] != filepath.Base(checkPath) {
|
||||
fmt.Fprintf(stdout, "%s\t%s\tsidecar-filename-mismatch\n", id, rel)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
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 {
|
||||
outDir := flagVal(args, "--out")
|
||||
clearOnSuccess := hasFlag(args, "--clear-on-success")
|
||||
|
||||
@@ -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 }
|
||||
@@ -4213,12 +4217,92 @@ func TestVerifySidecarBranches(t *testing.T) {
|
||||
oldRead := readFileFunc
|
||||
readFileFunc = func(string) ([]byte, error) { return nil, fmt.Errorf("read") }
|
||||
var out bytes.Buffer
|
||||
if got := verifySidecar(&out, subdir, "x1", "../asset.jpg"); got != 1 || !strings.Contains(out.String(), "sidecar-unreadable") {
|
||||
if got := verifySidecar(&out, subdir, "x1", "../asset.jpg", false); got != 1 || !strings.Contains(out.String(), "sidecar-unreadable") {
|
||||
t.Fatalf("expected unreadable with rel fallback, got=%d out=%q", got, out.String())
|
||||
}
|
||||
readFileFunc = oldRead
|
||||
}
|
||||
|
||||
func TestVerifySidecarStrict(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
media := filepath.Join(dir, "photo.jpg")
|
||||
if err := os.WriteFile(media, []byte("data"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeXMPSidecar(sidecarPath(media), xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 0 || out.Len() != 0 {
|
||||
t.Fatalf("strict valid got=%d out=%q", got, out.String())
|
||||
}
|
||||
if err := os.WriteFile(sidecarPath(media), []byte(`photoscli:assetID="x1"`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-schema-missing") {
|
||||
t.Fatalf("strict schema got=%d out=%q", got, out.String())
|
||||
}
|
||||
if err := os.WriteFile(sidecarPath(media), []byte(`<?xpacket begin=""?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:photoscli="https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/" photoscli:assetID="x1" photoscli:xmpSchemaVersion="2" photoscli:exportedFilename="photo.jpg" /></rdf:RDF></x:xmpmeta>`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-generator-missing") {
|
||||
t.Fatalf("strict generator got=%d out=%q", got, out.String())
|
||||
}
|
||||
if err := os.WriteFile(sidecarPath(media), []byte(`<?xpacket begin=""?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:photoscli="https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/" photoscli:assetID="x1" photoscli:xmpSchemaVersion="2" photoscli:sidecarGenerator="photoscli" photoscli:exportedFilename="other.jpg" /></rdf:RDF></x:xmpmeta>`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out.Reset()
|
||||
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-filename-mismatch") {
|
||||
t.Fatalf("strict filename got=%d out=%q", got, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
|
||||
t.Fatalf("sidecar path = %q", got)
|
||||
|
||||
Reference in New Issue
Block a user