Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d909d30b87 |
@@ -2,6 +2,14 @@
|
||||
|
||||
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.7
|
||||
|
||||
JSON sidecar release.
|
||||
|
||||
- Add `--sidecar json` for structured JSON metadata sidecars.
|
||||
- Add `--sidecar xmp,json` to write both XMP and JSON sidecars from the same metadata.
|
||||
- Keep `--sidecar none` as the default.
|
||||
|
||||
## v0.8.6
|
||||
|
||||
XMP keyword and rating controls release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
BINARY := ./bin/photoscli
|
||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||
VERSION := 0.8.6
|
||||
VERSION := 0.8.7
|
||||
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
||||
RELEASE_NOTES := RELEASE_NOTES.md
|
||||
BRIDGE_DIR := bridge
|
||||
|
||||
@@ -344,6 +344,8 @@ Write archival metadata sidecars with:
|
||||
|
||||
```bash
|
||||
photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
||||
photoscli export --album-id "Vacation" --out ./Vacation --sidecar json
|
||||
photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp,json
|
||||
```
|
||||
|
||||
Sidecars are opt-in and use the exported file basename:
|
||||
@@ -351,6 +353,7 @@ Sidecars are opt-in and use the exported file basename:
|
||||
```text
|
||||
IMG_0001.jpg -> IMG_0001.xmp
|
||||
IMG_0001.HEIC -> IMG_0001.xmp
|
||||
IMG_0001.jpg -> IMG_0001.json
|
||||
```
|
||||
|
||||
The XMP contains photoscli metadata such as asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported timestamp, size, and creation date when available. If `--sidecar xmp` is explicitly selected and the sidecar cannot be written, that asset is treated as failed.
|
||||
|
||||
+7
-7
@@ -1,18 +1,18 @@
|
||||
# v0.8.6
|
||||
# v0.8.7
|
||||
|
||||
This release adds keyword and rating controls for generated XMP sidecars.
|
||||
This release adds JSON sidecars alongside XMP sidecars.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Add `--xmp-keywords album-path|album|none`.
|
||||
- Add `--xmp-rating favorite|none`.
|
||||
- Keep existing behavior by default with album-path keywords and favorite-to-rating mapping.
|
||||
- Use these flags when another DAM or metadata tool should own keywords or ratings.
|
||||
- Add `--sidecar json` for structured JSON metadata sidecars.
|
||||
- Add `--sidecar xmp,json` to write both XMP and JSON sidecars.
|
||||
- Keep `--sidecar none` as the default.
|
||||
- JSON sidecars use the same metadata as XMP sidecars and the exported file basename.
|
||||
|
||||
## Assets
|
||||
|
||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
||||
- `photoscli-0.8.6-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
||||
- `photoscli-0.8.7-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.
|
||||
|
||||
@@ -546,6 +546,8 @@ Use XMP sidecars when you want portable metadata next to exported files:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
||||
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar json
|
||||
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp,json
|
||||
```
|
||||
|
||||
Sidecars are disabled by default. When enabled, they use the exported file basename:
|
||||
@@ -553,8 +555,11 @@ Sidecars are disabled by default. When enabled, they use the exported file basen
|
||||
```text
|
||||
IMG_0001.jpg -> IMG_0001.xmp
|
||||
IMG_0001.HEIC -> IMG_0001.xmp
|
||||
IMG_0001.jpg -> IMG_0001.json
|
||||
```
|
||||
|
||||
Use XMP for standards-oriented metadata workflows and JSON when you want structured photoscli metadata for scripts or audits.
|
||||
|
||||
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, hidden state, cloud state, export mode, version, exported time, size, creation date, modification date, duration, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP date fields, EXIF GPS fields, favorite rating, and album/folder keywords when PhotoKit exposes them.
|
||||
|
||||
Control location privacy in generated sidecars:
|
||||
|
||||
+67
-10
@@ -228,9 +228,9 @@ COMMON EXPORT FLAGS
|
||||
--verify
|
||||
Run manifest/file verification after export or backup-all.
|
||||
|
||||
--sidecar none|xmp
|
||||
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
||||
none. If XMP writing fails, the asset is counted as failed.
|
||||
--sidecar none|xmp|json|xmp,json
|
||||
Write opt-in metadata sidecars next to each exported file. Default: none.
|
||||
If sidecar writing fails, the asset is counted as failed.
|
||||
|
||||
--xmp-privacy keep|strip-location|strip-address
|
||||
Control location/address metadata in generated XMP sidecars. Default: keep.
|
||||
@@ -901,6 +901,20 @@ func sidecarPath(exportedPath string) string {
|
||||
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
|
||||
}
|
||||
|
||||
func jsonSidecarPath(exportedPath string) string {
|
||||
ext := filepath.Ext(exportedPath)
|
||||
return strings.TrimSuffix(exportedPath, ext) + ".json"
|
||||
}
|
||||
|
||||
func sidecarEnabled(sidecar, format string) bool {
|
||||
for _, part := range strings.Split(sidecar, ",") {
|
||||
if strings.TrimSpace(part) == format {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderXMP(d xmpSidecarData) []byte {
|
||||
attrs := []struct{ key, val string }{
|
||||
{"photoscli:xmpSchemaVersion", "2"},
|
||||
@@ -1074,8 +1088,31 @@ func writeXMPSidecar(path string, data xmpSidecarData) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSONSidecar(path string, data xmpSidecarData) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := createTempFunc(filepath.Dir(path), ".*.json.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
_ = f.Close()
|
||||
payload, _ := json.MarshalIndent(data, "", " ")
|
||||
payload = append(payload, '\n')
|
||||
if err := writeFileFunc(tmp, payload, 0644); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := renameFunc(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
|
||||
if opts.sidecar != "xmp" {
|
||||
if opts.sidecar == "none" || opts.sidecar == "" {
|
||||
return nil
|
||||
}
|
||||
mode := "preview"
|
||||
@@ -1138,7 +1175,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
||||
if xmpRating == "none" {
|
||||
isFavorite = false
|
||||
}
|
||||
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
|
||||
data := xmpSidecarData{
|
||||
AssetID: pa.asset.ID,
|
||||
OriginalFilename: pa.asset.Filename,
|
||||
ExportedFilename: result.Filename,
|
||||
@@ -1170,7 +1207,18 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
||||
BurstSelectionTypes: pa.asset.BurstSelectionTypes,
|
||||
AdjustmentInfo: pa.asset.AdjustmentInfo,
|
||||
Resources: pa.asset.Resources,
|
||||
})
|
||||
}
|
||||
if sidecarEnabled(opts.sidecar, "xmp") {
|
||||
if err := writeXMPSidecar(sidecarPath(fullPath), data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if sidecarEnabled(opts.sidecar, "json") {
|
||||
if err := writeJSONSidecar(jsonSidecarPath(fullPath), data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
|
||||
@@ -1933,10 +1981,19 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
||||
fmt.Fprintf(stderr, "error: --format must be jpeg, heic, or png, got %q\n", opts.format)
|
||||
return opts, false
|
||||
}
|
||||
if opts.sidecar != "none" && opts.sidecar != "xmp" {
|
||||
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
|
||||
if opts.sidecar != "none" && !sidecarEnabled(opts.sidecar, "xmp") && !sidecarEnabled(opts.sidecar, "json") {
|
||||
fmt.Fprintf(stderr, "error: --sidecar must be none, xmp, json, or xmp,json, got %q\n", opts.sidecar)
|
||||
return opts, false
|
||||
}
|
||||
if opts.sidecar != "none" {
|
||||
for _, part := range strings.Split(opts.sidecar, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "xmp" && part != "json" {
|
||||
fmt.Fprintf(stderr, "error: --sidecar must be none, xmp, json, or xmp,json, got %q\n", opts.sidecar)
|
||||
return opts, false
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.xmpPrivacy != "keep" && opts.xmpPrivacy != "strip-location" && opts.xmpPrivacy != "strip-address" {
|
||||
fmt.Fprintf(stderr, "error: --xmp-privacy must be keep, strip-location, or strip-address, got %q\n", opts.xmpPrivacy)
|
||||
return opts, false
|
||||
@@ -1949,8 +2006,8 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
||||
fmt.Fprintf(stderr, "error: --xmp-rating must be favorite or none, got %q\n", opts.xmpRating)
|
||||
return opts, false
|
||||
}
|
||||
if opts.metadataOnly && opts.sidecar != "xmp" {
|
||||
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
|
||||
if opts.metadataOnly && opts.sidecar == "none" {
|
||||
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp, json, or xmp,json")
|
||||
return opts, false
|
||||
}
|
||||
if v := flagVal(args, "--retry"); v != "" {
|
||||
|
||||
@@ -4385,6 +4385,52 @@ func TestWriteXMPSidecar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONSidecar(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "photo.json")
|
||||
if got := jsonSidecarPath(filepath.Join(dir, "photo.jpg")); got != path {
|
||||
t.Fatalf("json sidecar path=%q", got)
|
||||
}
|
||||
if !sidecarEnabled("xmp,json", "json") || sidecarEnabled("xmp", "json") {
|
||||
t.Fatal("sidecarEnabled mismatch")
|
||||
}
|
||||
if err := writeJSONSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"AssetID": "x1"`) {
|
||||
t.Fatalf("unexpected json sidecar: %s", string(data))
|
||||
}
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeJSONSidecar(filepath.Join(badParent, "bad.json"), xmpSidecarData{}); err == nil {
|
||||
t.Fatal("expected mkdir error")
|
||||
}
|
||||
oldCreate := createTempFunc
|
||||
createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("create") }
|
||||
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
|
||||
t.Fatal("expected create temp error")
|
||||
}
|
||||
createTempFunc = oldCreate
|
||||
oldWrite := writeFileFunc
|
||||
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("write") }
|
||||
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
|
||||
t.Fatal("expected write error")
|
||||
}
|
||||
writeFileFunc = oldWrite
|
||||
oldRename := renameFunc
|
||||
renameFunc = func(string, string) error { return fmt.Errorf("rename") }
|
||||
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
|
||||
t.Fatal("expected rename error")
|
||||
}
|
||||
renameFunc = oldRename
|
||||
}
|
||||
|
||||
func TestSidecarExportIntegration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
date := "2024-01-02T03:04:05Z"
|
||||
@@ -4395,7 +4441,7 @@ func TestSidecarExportIntegration(t *testing.T) {
|
||||
}
|
||||
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
|
||||
}
|
||||
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
|
||||
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp,json"})
|
||||
if exported != 1 || failed != 0 {
|
||||
t.Fatalf("exported=%d failed=%d", exported, failed)
|
||||
}
|
||||
@@ -4412,6 +4458,13 @@ func TestSidecarExportIntegration(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
|
||||
t.Fatal("sidecar should use basename, not double extension")
|
||||
}
|
||||
jsonData, err := os.ReadFile(filepath.Join(dir, "photo.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(jsonData), `"AssetID": "x1"`) {
|
||||
t.Fatalf("json sidecar missing asset ID: %s", string(jsonData))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidecarReverseGeocodeCache(t *testing.T) {
|
||||
@@ -4585,6 +4638,14 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
||||
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
|
||||
}
|
||||
stderr.Reset()
|
||||
if opts, ok := parseExportOptions([]string{"--sidecar", "json"}, &stderr); !ok || opts.sidecar != "json" || stderr.Len() != 0 {
|
||||
t.Fatalf("expected json sidecar option, opts=%+v ok=%v stderr=%q", opts, ok, stderr.String())
|
||||
}
|
||||
stderr.Reset()
|
||||
if _, ok := parseExportOptions([]string{"--sidecar", "xmp,bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
||||
t.Fatalf("expected mixed sidecar validation error, stderr=%q", stderr.String())
|
||||
}
|
||||
stderr.Reset()
|
||||
if _, ok := parseExportOptions([]string{"--xmp-privacy", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-privacy") {
|
||||
t.Fatalf("expected xmp privacy validation error, stderr=%q", stderr.String())
|
||||
}
|
||||
@@ -4603,10 +4664,14 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
||||
}
|
||||
oldRename := renameFunc
|
||||
renameFunc = func(string, string) error { return fmt.Errorf("sidecar rename") }
|
||||
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
|
||||
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "json"})
|
||||
if exported != 0 || failed != 1 {
|
||||
t.Fatalf("expected json sidecar failure, exported=%d failed=%d", exported, failed)
|
||||
}
|
||||
exported, failed = exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
|
||||
renameFunc = oldRename
|
||||
if exported != 0 || failed != 1 {
|
||||
t.Fatalf("expected sidecar failure, exported=%d failed=%d", exported, failed)
|
||||
t.Fatalf("expected xmp sidecar failure, exported=%d failed=%d", exported, failed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4725,7 +4790,7 @@ func TestMetadataOnlyExportErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b)
|
||||
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar xmp") {
|
||||
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar") {
|
||||
t.Fatalf("expected sidecar requirement rc=%d stderr=%q", rc, stderr)
|
||||
}
|
||||
_, stderr, rc = runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, b)
|
||||
|
||||
Reference in New Issue
Block a user