v0.7.0: add XMP sidecars
This commit is contained in:
@@ -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