v0.7.0: add XMP sidecars
This commit is contained in:
@@ -11,4 +11,5 @@
|
||||
- Keep README concise; put practical user workflows in `USERGUIDE.md`.
|
||||
- Manifest backends: JSONL default, SQLite optional via `modernc.org/sqlite`.
|
||||
- Preserve manifest compatibility and migration behavior.
|
||||
- XMP sidecars are opt-in via `--sidecar xmp`; default must remain `none`.
|
||||
- Do not commit generated artifacts from `bin/` or coverage files.
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
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.7.0
|
||||
|
||||
XMP sidecar metadata release.
|
||||
|
||||
- Add `--sidecar none|xmp` with default `none`.
|
||||
- Add config support for `sidecar = "xmp"`.
|
||||
- Write XMP sidecars next to exported files using basename `.xmp` filenames.
|
||||
- Include asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, photoscli version, exported timestamp, size, and creation date in XMP.
|
||||
- Write XMP files atomically.
|
||||
- Treat sidecar write failure as asset failure when XMP sidecars are explicitly requested.
|
||||
|
||||
## v0.6.0
|
||||
|
||||
Backup integrity and recovery release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
BINARY := ./bin/photoscli
|
||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||
VERSION := 0.6.0
|
||||
VERSION := 0.7.0
|
||||
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
||||
RELEASE_NOTES := RELEASE_NOTES.md
|
||||
BRIDGE_DIR := bridge
|
||||
|
||||
@@ -21,6 +21,7 @@ For a practical step-by-step manual with recommended backup workflows, recovery
|
||||
- Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`.
|
||||
- Verification, reporting, and diff commands for backup integrity.
|
||||
- Status command for quick backup summaries.
|
||||
- Opt-in XMP sidecar metadata with `--sidecar xmp`.
|
||||
- Script-friendly exit codes and optional JSON summaries.
|
||||
- 100% test coverage for the Go CLI and parsing layers.
|
||||
|
||||
@@ -276,6 +277,7 @@ Common flags for `export` and `backup-all`:
|
||||
- `--min-size <n>`: filter by estimated pixel count.
|
||||
- `--max-size <n>`: filter by estimated pixel count.
|
||||
- `--format jpeg|heic|png`: preview format hint. Current bridge output is still the existing preview path; non-JPEG bridge output is future work.
|
||||
- `--sidecar none|xmp`: write opt-in XMP metadata sidecars next to exported files.
|
||||
- `--date-template <template>`: append date folders based on creation date, for example `YYYY/MM/DD`.
|
||||
|
||||
`backup-all` also supports:
|
||||
@@ -328,6 +330,23 @@ With SQLite manifest mode, logs are written to the `logs` table in `downloads.db
|
||||
|
||||
Logged events include session start/end, completed exports, skipped assets, and failures. Each event includes timestamp, level, event name, asset ID, album, filename, size, cloud state, duration, and message where available.
|
||||
|
||||
## XMP Sidecars
|
||||
|
||||
Write archival metadata sidecars with:
|
||||
|
||||
```bash
|
||||
photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
||||
```
|
||||
|
||||
Sidecars are opt-in and use the exported file basename:
|
||||
|
||||
```text
|
||||
IMG_0001.jpg -> IMG_0001.xmp
|
||||
IMG_0001.HEIC -> IMG_0001.xmp
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Failure Tracking
|
||||
|
||||
Failed exports are deduplicated by asset ID and stored in:
|
||||
|
||||
+9
-18
@@ -1,29 +1,20 @@
|
||||
# v0.6.0
|
||||
# v0.7.0
|
||||
|
||||
This release focuses on backup integrity, recovery workflows, and clearer operational status.
|
||||
This release adds opt-in XMP sidecar metadata for archival exports.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Manifest entries now include relative paths, so tree backups can be verified accurately.
|
||||
- SQLite manifests migrate automatically to include the new path column.
|
||||
- `verify` now detects missing files, zero-byte files, and size mismatches by default.
|
||||
- Exports use per-asset staging directories and rename completed files into place when possible.
|
||||
- Failure tracking is deduplicated by asset ID and records attempts and latest error details.
|
||||
- New `failures list` and `failures clear` commands.
|
||||
- `retry-failed --clear-on-success` removes successfully retried failures.
|
||||
- New `status` command with text and JSON output.
|
||||
- Release workflow now publishes these release notes to the release page.
|
||||
|
||||
## Recommended Upgrade Notes
|
||||
|
||||
- Existing JSONL manifests continue to load; entries without `path` fall back to `filename`.
|
||||
- Existing SQLite manifests are migrated with `ALTER TABLE downloads ADD COLUMN path ...` during open.
|
||||
- `verify` is stricter now and may report problems that older versions ignored.
|
||||
- Add `--sidecar none|xmp` with default `none`.
|
||||
- Write XMP sidecars next to exported files when `--sidecar xmp` is selected.
|
||||
- XMP files use the exported file basename, for example `IMG_0001.jpg` -> `IMG_0001.xmp`.
|
||||
- Sidecars include 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.
|
||||
- XMP writes are atomic and fail the asset when explicitly requested sidecar output cannot be written.
|
||||
- Config files can set `sidecar = "xmp"`.
|
||||
|
||||
## Assets
|
||||
|
||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
||||
- `photoscli-0.6.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
||||
- `photoscli-0.7.0-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.
|
||||
|
||||
@@ -22,6 +22,7 @@ It is especially useful when you want to:
|
||||
- Verify that files referenced by a manifest exist on disk.
|
||||
- Detect missing, zero-byte, and size-mismatched manifest files.
|
||||
- Inspect or clear deduplicated failure records.
|
||||
- Write optional XMP sidecar metadata for archival workflows.
|
||||
- Script Photos exports with stable exit codes.
|
||||
|
||||
It is not intended to replace Apple Photos, iCloud Photos, or Time Machine. Think of it as an additional file-based export and backup tool.
|
||||
@@ -538,6 +539,25 @@ Supported tokens:
|
||||
|
||||
Assets without parseable creation dates stay in the base output path.
|
||||
|
||||
## XMP Sidecars
|
||||
|
||||
Use XMP sidecars when you want portable metadata next to exported files:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
||||
```
|
||||
|
||||
Sidecars are disabled by default. When enabled, they use the exported file basename:
|
||||
|
||||
```text
|
||||
IMG_0001.jpg -> IMG_0001.xmp
|
||||
IMG_0001.HEIC -> IMG_0001.xmp
|
||||
```
|
||||
|
||||
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported time, size, and creation date when available.
|
||||
|
||||
If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed.
|
||||
|
||||
## Configuration File
|
||||
|
||||
You can store default values in:
|
||||
@@ -557,6 +577,7 @@ sort = "newest"
|
||||
media = "photos"
|
||||
retry = 3
|
||||
log = true
|
||||
sidecar = "xmp"
|
||||
```
|
||||
|
||||
Use a custom config path:
|
||||
|
||||
+158
-4
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -22,6 +23,8 @@ var (
|
||||
configValues map[string]string
|
||||
configLoaded bool
|
||||
mkdirTempFunc = os.MkdirTemp
|
||||
createTempFunc = os.CreateTemp
|
||||
writeFileFunc = os.WriteFile
|
||||
renameFunc = os.Rename
|
||||
openFileFunc = os.OpenFile
|
||||
removeFunc = os.Remove
|
||||
@@ -35,6 +38,7 @@ type exportOptions struct {
|
||||
jsonOut bool
|
||||
verify bool
|
||||
format string
|
||||
sidecar string
|
||||
minSize int64
|
||||
maxSize int64
|
||||
dateTemplate string
|
||||
@@ -208,6 +212,10 @@ 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.
|
||||
|
||||
FILTERING AND SELECTION
|
||||
--since <date>
|
||||
Include only assets on or after a date. Accepts YYYY-MM-DD or RFC3339.
|
||||
@@ -731,6 +739,133 @@ func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.Export
|
||||
m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud))
|
||||
}
|
||||
|
||||
type xmpSidecarData struct {
|
||||
AssetID string
|
||||
OriginalFilename string
|
||||
ExportedFilename string
|
||||
Album string
|
||||
AlbumPath string
|
||||
ManifestPath string
|
||||
MediaType string
|
||||
PixelWidth int
|
||||
PixelHeight int
|
||||
IsFavorite bool
|
||||
Cloud string
|
||||
ExportMode string
|
||||
PhotoscliVersion string
|
||||
ExportedAt string
|
||||
Size int64
|
||||
CreateDate string
|
||||
}
|
||||
|
||||
func sidecarPath(exportedPath string) string {
|
||||
ext := filepath.Ext(exportedPath)
|
||||
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
|
||||
}
|
||||
|
||||
func renderXMP(d xmpSidecarData) []byte {
|
||||
attrs := []struct{ key, val string }{
|
||||
{"photoscli:assetID", d.AssetID},
|
||||
{"photoscli:originalFilename", d.OriginalFilename},
|
||||
{"photoscli:exportedFilename", d.ExportedFilename},
|
||||
{"photoscli:album", d.Album},
|
||||
{"photoscli:albumPath", d.AlbumPath},
|
||||
{"photoscli:manifestPath", d.ManifestPath},
|
||||
{"photoscli:mediaType", d.MediaType},
|
||||
{"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)},
|
||||
{"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)},
|
||||
{"photoscli:isFavorite", fmt.Sprintf("%t", d.IsFavorite)},
|
||||
{"photoscli:cloud", d.Cloud},
|
||||
{"photoscli:exportMode", d.ExportMode},
|
||||
{"photoscli:photoscliVersion", d.PhotoscliVersion},
|
||||
{"photoscli:exportedAt", d.ExportedAt},
|
||||
{"photoscli:size", fmt.Sprintf("%d", d.Size)},
|
||||
{"dc:title", d.ExportedFilename},
|
||||
}
|
||||
if d.CreateDate != "" {
|
||||
attrs = append(attrs, struct{ key, val string }{"xmp:CreateDate", d.CreateDate})
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
|
||||
sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n")
|
||||
sb.WriteString(" <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n")
|
||||
sb.WriteString(" <rdf:Description xmlns:photoscli=\"https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\"")
|
||||
for _, a := range attrs {
|
||||
sb.WriteString("\n ")
|
||||
sb.WriteString(a.key)
|
||||
sb.WriteString("=\"")
|
||||
xml.EscapeText(&sb, []byte(a.val))
|
||||
sb.WriteString("\"")
|
||||
}
|
||||
sb.WriteString(" />\n")
|
||||
sb.WriteString(" </rdf:RDF>\n")
|
||||
sb.WriteString("</x:xmpmeta>\n")
|
||||
sb.WriteString("<?xpacket end=\"w\"?>\n")
|
||||
return []byte(sb.String())
|
||||
}
|
||||
|
||||
func writeXMPSidecar(path string, data xmpSidecarData) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := createTempFunc(filepath.Dir(path), ".*.xmp.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
_ = f.Close()
|
||||
if err := writeFileFunc(tmp, renderXMP(data), 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) error {
|
||||
if opts.sidecar != "xmp" {
|
||||
return nil
|
||||
}
|
||||
mode := "preview"
|
||||
if originals {
|
||||
mode = "original"
|
||||
}
|
||||
root := pa.root
|
||||
if root == "" {
|
||||
root = pa.path
|
||||
}
|
||||
fullPath := filepath.Join(pa.path, result.Filename)
|
||||
relPath, err := filepath.Rel(root, fullPath)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
relPath = result.Filename
|
||||
}
|
||||
createDate := ""
|
||||
if pa.asset.CreationDate != nil {
|
||||
createDate = *pa.asset.CreationDate
|
||||
}
|
||||
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
|
||||
AssetID: pa.asset.ID,
|
||||
OriginalFilename: pa.asset.Filename,
|
||||
ExportedFilename: result.Filename,
|
||||
Album: pa.album,
|
||||
AlbumPath: pa.path,
|
||||
ManifestPath: relPath,
|
||||
MediaType: pa.asset.MediaType,
|
||||
PixelWidth: pa.asset.PixelWidth,
|
||||
PixelHeight: pa.asset.PixelHeight,
|
||||
IsFavorite: pa.asset.IsFavorite,
|
||||
Cloud: result.Cloud,
|
||||
ExportMode: mode,
|
||||
PhotoscliVersion: version,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Size: result.Size,
|
||||
CreateDate: createDate,
|
||||
})
|
||||
}
|
||||
|
||||
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) {
|
||||
var items []pendingAsset
|
||||
var skipped int
|
||||
@@ -904,8 +1039,15 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
|
||||
} else if isSkipped {
|
||||
addManifestEntry(m, pa, result)
|
||||
} else {
|
||||
done++
|
||||
addManifestEntry(m, pa, result)
|
||||
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts); sidecarErr != nil {
|
||||
failed++
|
||||
exportErr = sidecarErr
|
||||
isErr = true
|
||||
appendFailure(pa.path, pa, sidecarErr)
|
||||
} else {
|
||||
done++
|
||||
addManifestEntry(m, pa, result)
|
||||
}
|
||||
}
|
||||
avgSpeed := float64(0)
|
||||
if totalDur > 0 {
|
||||
@@ -1033,8 +1175,15 @@ func exportPendingParallel(pending []pendingAsset, targetSize, quality int, orig
|
||||
} else if isSkipped {
|
||||
addManifestEntry(m, entry.pa, entry.result)
|
||||
} else {
|
||||
done++
|
||||
addManifestEntry(m, entry.pa, entry.result)
|
||||
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts); sidecarErr != nil {
|
||||
failed++
|
||||
entry.err = sidecarErr
|
||||
isErr = true
|
||||
appendFailure(entry.pa.path, entry.pa, sidecarErr)
|
||||
} else {
|
||||
done++
|
||||
addManifestEntry(m, entry.pa, entry.result)
|
||||
}
|
||||
}
|
||||
avgSpeed := float64(0)
|
||||
if totalDur > 0 {
|
||||
@@ -1381,6 +1530,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
||||
jsonOut: hasFlag(args, "--json"),
|
||||
verify: hasFlag(args, "--verify"),
|
||||
format: flagValWithDefault(args, "--format", "jpeg"),
|
||||
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
||||
dateTemplate: flagVal(args, "--date-template"),
|
||||
}
|
||||
if opts.media != "photos" && opts.media != "videos" && opts.media != "all" {
|
||||
@@ -1391,6 +1541,10 @@ 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)
|
||||
return opts, false
|
||||
}
|
||||
if v := flagVal(args, "--retry"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
|
||||
@@ -4155,6 +4155,199 @@ func TestMoreIntegrityBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestXMPSidecarHelpers(t *testing.T) {
|
||||
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
|
||||
t.Fatalf("sidecar path = %q", got)
|
||||
}
|
||||
xmp := string(renderXMP(xmpSidecarData{
|
||||
AssetID: `id&<>"`,
|
||||
OriginalFilename: "IMG_0001.HEIC",
|
||||
ExportedFilename: "IMG_0001.jpg",
|
||||
Album: "A&B",
|
||||
AlbumPath: "/tmp/A&B",
|
||||
ManifestPath: "A&B/IMG_0001.jpg",
|
||||
MediaType: "image",
|
||||
PixelWidth: 10,
|
||||
PixelHeight: 20,
|
||||
IsFavorite: true,
|
||||
Cloud: "local",
|
||||
ExportMode: "preview",
|
||||
PhotoscliVersion: "test",
|
||||
ExportedAt: "2026-01-01T00:00:00Z",
|
||||
Size: 123,
|
||||
CreateDate: "2024-01-01T00:00:00Z",
|
||||
}))
|
||||
for _, want := range []string{"photoscli:assetID=\"id&<>"\"", "photoscli:isFavorite=\"true\"", "xmp:CreateDate=\"2024-01-01T00:00:00Z\""} {
|
||||
if !strings.Contains(xmp, want) {
|
||||
t.Fatalf("XMP missing %q in %s", want, xmp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteXMPSidecar(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "photo.xmp")
|
||||
if err := writeXMPSidecar(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), "photoscli:assetID=\"x1\"") {
|
||||
t.Fatalf("unexpected xmp: %s", string(data))
|
||||
}
|
||||
badParent := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeXMPSidecar(filepath.Join(badParent, "bad.xmp"), xmpSidecarData{}); err == nil {
|
||||
t.Fatal("expected mkdir error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidecarExportIntegration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
date := "2024-01-02T03:04:05Z"
|
||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig&.HEIC", MediaType: "image", PixelWidth: 10, PixelHeight: 20, IsFavorite: true, CreationDate: &date}}}
|
||||
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||
if err := os.WriteFile(filepath.Join(out, "photo.jpg"), []byte("data"), 0644); err != nil {
|
||||
return photos.ExportResult{}, err
|
||||
}
|
||||
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"})
|
||||
if exported != 1 || failed != 0 {
|
||||
t.Fatalf("exported=%d failed=%d", exported, failed)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
for _, want := range []string{"photoscli:assetID=\"x1\"", "photoscli:originalFilename=\"orig&.HEIC\"", "photoscli:album=\"Album\"", "xmp:CreateDate=\"2024-01-02T03:04:05Z\""} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("sidecar missing %q in %s", want, content)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
|
||||
t.Fatal("sidecar should use basename, not double extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidecarConfigAndErrors(t *testing.T) {
|
||||
oldConfigValues, oldConfigLoaded := configValues, configLoaded
|
||||
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
|
||||
dir := t.TempDir()
|
||||
cfg := filepath.Join(dir, "config.toml")
|
||||
if err := os.WriteFile(cfg, []byte("sidecar = \"xmp\"\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PHOTOSCLI_CONFIG", cfg)
|
||||
configValues, configLoaded = nil, false
|
||||
opts, ok := parseExportOptions(nil, io.Discard)
|
||||
if !ok || opts.sidecar != "xmp" {
|
||||
t.Fatalf("expected sidecar config, opts=%+v ok=%v", opts, ok)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
||||
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
|
||||
}
|
||||
|
||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
||||
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||
return photos.ExportResult{Filename: "photo.jpg", Size: 4}, nil
|
||||
}
|
||||
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"})
|
||||
renameFunc = oldRename
|
||||
if exported != 0 || failed != 1 {
|
||||
t.Fatalf("expected sidecar failure, exported=%d failed=%d", exported, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSidecarAdditionalBranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldCreateTemp := createTempFunc
|
||||
createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("createtemp") }
|
||||
if err := writeXMPSidecar(filepath.Join(dir, "bad.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "createtemp") {
|
||||
t.Fatalf("expected create temp error, got %v", err)
|
||||
}
|
||||
createTempFunc = oldCreateTemp
|
||||
oldWriteFile := writeFileFunc
|
||||
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("writefile") }
|
||||
if err := writeXMPSidecar(filepath.Join(dir, "badwrite.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "writefile") {
|
||||
t.Fatalf("expected write file error, got %v", err)
|
||||
}
|
||||
writeFileFunc = oldWriteFile
|
||||
|
||||
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"}
|
||||
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "out.xmp"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "photoscli:exportMode=\"original\"") || !strings.Contains(content, "photoscli:manifestPath=\"out.jpg\"") {
|
||||
t.Fatalf("unexpected sidecar: %s", content)
|
||||
}
|
||||
otherRoot := filepath.Join(dir, "other")
|
||||
if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "photoscli:manifestPath=\"fallback.jpg\"") {
|
||||
t.Fatalf("expected fallback manifest path, got %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSidecarExport(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assets := []photos.Asset{
|
||||
{ID: "x1", Filename: "one.jpg"},
|
||||
{ID: "x2", Filename: "two.jpg"},
|
||||
{ID: "x3", Filename: "three.jpg"},
|
||||
{ID: "x4", Filename: "four.jpg"},
|
||||
}
|
||||
b := &mockBridge{assets: assets}
|
||||
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||
name := fmt.Sprintf("%s.jpg", assetID)
|
||||
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
|
||||
return photos.ExportResult{}, err
|
||||
}
|
||||
return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil
|
||||
}
|
||||
exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
|
||||
if exported != 4 || failed != 0 {
|
||||
t.Fatalf("exported=%d failed=%d", exported, failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "x4.xmp")); err != nil {
|
||||
t.Fatalf("expected parallel sidecar: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSidecarFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assets := []photos.Asset{{ID: "x1", Filename: "one.jpg"}, {ID: "x2", Filename: "two.jpg"}, {ID: "x3", Filename: "three.jpg"}, {ID: "x4", Filename: "four.jpg"}}
|
||||
b := &mockBridge{assets: assets}
|
||||
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||
return photos.ExportResult{Filename: assetID + ".jpg", Size: 4, Cloud: "local"}, nil
|
||||
}
|
||||
oldWriteFile := writeFileFunc
|
||||
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("parallel sidecar") }
|
||||
exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
|
||||
writeFileFunc = oldWriteFile
|
||||
if exported != 0 || failed != 4 {
|
||||
t.Fatalf("expected sidecar failures, exported=%d failed=%d", exported, failed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryFailedClearOnSuccess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: dir, album: "Album"}, fmt.Errorf("boom"))
|
||||
|
||||
Reference in New Issue
Block a user