v0.8.2: add metadata-only sidecars
This commit is contained in:
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
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.
|
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.2
|
||||||
|
|
||||||
|
Metadata-only XMP refresh release.
|
||||||
|
|
||||||
|
- Add `--metadata-only` for manifest-based XMP sidecar generation without re-exporting media files.
|
||||||
|
- Support metadata-only refresh for both `export` and `backup-all` when used with `--sidecar xmp`.
|
||||||
|
- Require a manifest for metadata-only mode so existing media paths are resolved safely.
|
||||||
|
- Keep media files untouched while overwriting/regenerating generated XMP sidecars.
|
||||||
|
- Support `--reverse-geocode` during metadata-only sidecar refresh.
|
||||||
|
|
||||||
## v0.8.1
|
## v0.8.1
|
||||||
|
|
||||||
XMP standards and sidecar verification release.
|
XMP standards and sidecar verification release.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
BINARY := ./bin/photoscli
|
BINARY := ./bin/photoscli
|
||||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||||
VERSION := 0.8.1
|
VERSION := 0.8.2
|
||||||
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
||||||
RELEASE_NOTES := RELEASE_NOTES.md
|
RELEASE_NOTES := RELEASE_NOTES.md
|
||||||
BRIDGE_DIR := bridge
|
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`.
|
- Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`.
|
||||||
- Verification, reporting, and diff commands for backup integrity.
|
- Verification, reporting, and diff commands for backup integrity.
|
||||||
- Optional XMP sidecar verification with `verify --sidecar`.
|
- Optional XMP sidecar verification with `verify --sidecar`.
|
||||||
|
- Metadata-only XMP refresh for manifest-backed exports with `--metadata-only`.
|
||||||
- Status command for quick backup summaries.
|
- Status command for quick backup summaries.
|
||||||
- Opt-in rich XMP sidecar metadata with `--sidecar xmp`.
|
- Opt-in rich XMP sidecar metadata with `--sidecar xmp`.
|
||||||
- Optional Apple MapKit reverse geocoding for GPS assets on macOS 26+ with `--reverse-geocode`.
|
- Optional Apple MapKit reverse geocoding for GPS assets on macOS 26+ with `--reverse-geocode`.
|
||||||
@@ -283,6 +284,7 @@ Common flags for `export` and `backup-all`:
|
|||||||
- `--max-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.
|
- `--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.
|
- `--sidecar none|xmp`: write opt-in XMP metadata sidecars next to exported files.
|
||||||
|
- `--metadata-only`: with `--sidecar xmp`, refresh XMP sidecars for manifest-backed files without exporting media.
|
||||||
- `--reverse-geocode`: with `--sidecar xmp`, add cached Apple MapKit address metadata for GPS assets on macOS 26+.
|
- `--reverse-geocode`: with `--sidecar xmp`, add cached Apple MapKit address metadata for GPS assets on macOS 26+.
|
||||||
- `--date-template <template>`: append date folders based on creation date, for example `YYYY/MM/DD`.
|
- `--date-template <template>`: append date folders based on creation date, for example `YYYY/MM/DD`.
|
||||||
|
|
||||||
@@ -361,6 +363,12 @@ Verify generated sidecars with:
|
|||||||
photoscli verify --out ./backup --sidecar
|
photoscli verify --out ./backup --sidecar
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Refresh sidecars for files already present in a manifest-backed export without rewriting media files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
photoscli backup-all --out ./backup --sidecar xmp --metadata-only
|
||||||
|
```
|
||||||
|
|
||||||
## Failure Tracking
|
## Failure Tracking
|
||||||
|
|
||||||
Failed exports are deduplicated by asset ID and stored in:
|
Failed exports are deduplicated by asset ID and stored in:
|
||||||
|
|||||||
+8
-9
@@ -1,20 +1,19 @@
|
|||||||
# v0.8.1
|
# v0.8.2
|
||||||
|
|
||||||
This release improves XMP sidecar standards compatibility and adds sidecar verification.
|
This release adds metadata-only XMP sidecar refresh for existing manifest-backed exports.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Add `photoscli:xmpSchemaVersion="2"` to generated XMP sidecars.
|
- Add `--metadata-only` for `export` and `backup-all`.
|
||||||
- Add standard XMP mappings for favorite rating, metadata date, Photoshop date created, and EXIF GPS coordinates.
|
- Use manifest paths to find existing media files and write or refresh `.xmp` sidecars without re-exporting media.
|
||||||
- Add `dc:subject` keywords from album/folder context.
|
- Require `--sidecar xmp` and an enabled manifest for metadata-only mode.
|
||||||
- Add sidecar generator and generated timestamp metadata.
|
- Leave media files untouched; only generated XMP sidecars are written.
|
||||||
- Add `verify --sidecar` for missing, zero-byte, unreadable, and asset-ID mismatched XMP sidecars.
|
- Support `--reverse-geocode` during metadata-only refresh.
|
||||||
- Keep Vision/Core ML people, animal, object, and scene analysis out of this release.
|
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
||||||
- `photoscli-0.8.1-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
- `photoscli-0.8.2-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
||||||
- `USERGUIDE.md`: standalone user guide.
|
- `USERGUIDE.md`: standalone user guide.
|
||||||
|
|
||||||
Intel Macs are not currently a supported release target.
|
Intel Macs are not currently a supported release target.
|
||||||
|
|||||||
@@ -573,6 +573,14 @@ Verify sidecars after an export:
|
|||||||
|
|
||||||
This reports missing, zero-byte, unreadable, or asset-ID mismatched `.xmp` files.
|
This reports missing, zero-byte, unreadable, or asset-ID mismatched `.xmp` files.
|
||||||
|
|
||||||
|
Refresh metadata only for an existing manifest-backed backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli backup-all --out ./PhotosBackup --sidecar xmp --metadata-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Metadata-only mode does not re-export media files. It uses manifest paths to find existing files and rewrites generated `.xmp` sidecars next to them. It requires `--sidecar xmp` and an enabled manifest.
|
||||||
|
|
||||||
If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed.
|
If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed.
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
|
|||||||
+111
-6
@@ -41,6 +41,7 @@ type exportOptions struct {
|
|||||||
verify bool
|
verify bool
|
||||||
format string
|
format string
|
||||||
sidecar string
|
sidecar string
|
||||||
|
metadataOnly bool
|
||||||
reverseGeocode bool
|
reverseGeocode bool
|
||||||
minSize int64
|
minSize int64
|
||||||
maxSize int64
|
maxSize int64
|
||||||
@@ -220,6 +221,10 @@ COMMON EXPORT FLAGS
|
|||||||
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
||||||
none. If XMP writing fails, the asset is counted as failed.
|
none. If XMP writing fails, the asset is counted as failed.
|
||||||
|
|
||||||
|
--metadata-only
|
||||||
|
With --sidecar xmp, write or refresh XMP sidecars for files already in
|
||||||
|
the manifest without exporting media files. Requires a manifest.
|
||||||
|
|
||||||
--reverse-geocode
|
--reverse-geocode
|
||||||
With --sidecar xmp, use Apple MapKit on macOS 26+ to add address metadata
|
With --sidecar xmp, use Apple MapKit on macOS 26+ to add address metadata
|
||||||
for assets with GPS coordinates. Results are cached under .photoscli.
|
for assets with GPS coordinates. Results are cached under .photoscli.
|
||||||
@@ -444,6 +449,10 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
fmt.Fprintln(stderr, "error: --out is required")
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
if opts.metadataOnly && noManifest {
|
||||||
|
fmt.Fprintln(stderr, "error: --metadata-only requires a manifest")
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
|
||||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
||||||
if mfErr != nil {
|
if mfErr != nil {
|
||||||
@@ -546,8 +555,17 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir)
|
fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
var exported, failed int
|
||||||
exported, failed := exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
|
if opts.metadataOnly {
|
||||||
|
m, _ := manifest.Open(outDir, mf)
|
||||||
|
defer m.Close()
|
||||||
|
entries := manifestEntries(m)
|
||||||
|
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
|
||||||
|
exported, failed = metadataOnlyAssets(assets, outDir, originals, "", entries, opts, bridge)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
||||||
|
exported, failed = exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
|
||||||
|
}
|
||||||
if opts.jsonOut {
|
if opts.jsonOut {
|
||||||
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
|
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
|
||||||
}
|
}
|
||||||
@@ -562,7 +580,9 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if originals {
|
if opts.metadataOnly {
|
||||||
|
fmt.Fprintf(stderr, "\nwrote %d metadata sidecars to %s", exported, outDir)
|
||||||
|
} else if originals {
|
||||||
fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir)
|
fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
|
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
|
||||||
@@ -602,6 +622,10 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
fmt.Fprintln(stderr, "error: --out is required")
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
if opts.metadataOnly && noManifest {
|
||||||
|
fmt.Fprintln(stderr, "error: --metadata-only requires a manifest")
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
|
||||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
||||||
if mfErr != nil {
|
if mfErr != nil {
|
||||||
@@ -675,13 +699,25 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
fmt.Fprintf(stderr, "dry-run: %d assets would be exported (%d skipped)\n", len(pending), skipped)
|
fmt.Fprintf(stderr, "dry-run: %d assets would be exported (%d skipped)\n", len(pending), skipped)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
totalAssets, failed, err := backupTree(nodes, outDir, size, quality, concurrency, originals, skipVideos, stderr, bridge, noManifest, mf, sortNewest, excludeAlbums, sinceTime, enableLog, opts)
|
var totalAssets, failed int
|
||||||
|
if opts.metadataOnly {
|
||||||
|
m, _ := manifest.Open(outDir, mf)
|
||||||
|
entries := manifestEntries(m)
|
||||||
|
m.Close()
|
||||||
|
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
|
||||||
|
fmt.Fprintf(stderr, " indexed %d metadata entries (%d skipped), writing sidecars to %s...\n", len(pending), skipped, outDir)
|
||||||
|
totalAssets, failed = metadataOnlyPending(pending, entries, originals, opts, bridge)
|
||||||
|
} else {
|
||||||
|
totalAssets, failed, err = backupTree(nodes, outDir, size, quality, concurrency, originals, skipVideos, stderr, bridge, noManifest, mf, sortNewest, excludeAlbums, sinceTime, enableLog, opts)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if originals {
|
if opts.metadataOnly {
|
||||||
|
fmt.Fprintf(stderr, "\nwrote %d metadata sidecars across %d albums to %s", totalAssets, albumCount, outDir)
|
||||||
|
} else if originals {
|
||||||
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir)
|
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
|
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
|
||||||
@@ -1086,6 +1122,46 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
|
||||||
|
checkPath := entry.Path
|
||||||
|
if checkPath == "" {
|
||||||
|
checkPath = entry.Filename
|
||||||
|
}
|
||||||
|
if checkPath == "" {
|
||||||
|
return fmt.Errorf("manifest entry has no path")
|
||||||
|
}
|
||||||
|
root := pa.root
|
||||||
|
if root == "" {
|
||||||
|
root = pa.path
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(root, checkPath)
|
||||||
|
info, err := statFunc(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("metadata target missing: %s", checkPath)
|
||||||
|
}
|
||||||
|
if info.Size() == 0 {
|
||||||
|
return fmt.Errorf("metadata target zero-byte: %s", checkPath)
|
||||||
|
}
|
||||||
|
pa.path = filepath.Dir(fullPath)
|
||||||
|
result := photos.ExportResult{Filename: filepath.Base(fullPath), Size: info.Size(), Cloud: entry.Cloud}
|
||||||
|
return writeSidecarIfNeeded(pa, result, originals, opts, cache, bridge)
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
|
||||||
|
if r, ok := m.(manifest.EntryReader); ok {
|
||||||
|
return r.Entries()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
|
||||||
|
pending := make([]pendingAsset, 0, len(assets))
|
||||||
|
for _, a := range assets {
|
||||||
|
pending = append(pending, pendingAsset{asset: a, root: outDir, path: outDir, album: dirPrefix})
|
||||||
|
}
|
||||||
|
return metadataOnlyPending(pending, entries, originals, opts, bridge)
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
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 items []pendingAsset
|
||||||
var skipped int
|
var skipped int
|
||||||
@@ -1123,6 +1199,30 @@ func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge p
|
|||||||
return items, skipped
|
return items, skipped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func metadataOnlyPending(pending []pendingAsset, entries map[string]manifest.Entry, originals bool, opts exportOptions, bridge photos.Bridge) (int, int) {
|
||||||
|
var cache *geocodeCache
|
||||||
|
if opts.reverseGeocode && len(pending) > 0 {
|
||||||
|
root := pending[0].root
|
||||||
|
if root == "" {
|
||||||
|
root = pending[0].path
|
||||||
|
}
|
||||||
|
cache = newGeocodeCache(root)
|
||||||
|
}
|
||||||
|
written, failed := 0, 0
|
||||||
|
for _, pa := range pending {
|
||||||
|
entry, ok := entries[pa.asset.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := writeMetadataOnlySidecar(pa, entry, originals, opts, cache, bridge); err != nil {
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
written++
|
||||||
|
}
|
||||||
|
return written, failed
|
||||||
|
}
|
||||||
|
|
||||||
func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, onProgress func(collectProgress), m manifest.Manifest, exclude []string, opts exportOptions) {
|
func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, onProgress func(collectProgress), m manifest.Manifest, exclude []string, opts exportOptions) {
|
||||||
names := make(map[string]int)
|
names := make(map[string]int)
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
@@ -1160,7 +1260,7 @@ func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Br
|
|||||||
}
|
}
|
||||||
assets = applyAssetFilters(assets, opts)
|
assets = applyAssetFilters(assets, opts)
|
||||||
for _, a := range assets {
|
for _, a := range assets {
|
||||||
if m != nil && m.Has(a.ID) {
|
if m != nil && m.Has(a.ID) && !opts.metadataOnly {
|
||||||
*skipped++
|
*skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1767,6 +1867,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
|||||||
verify: hasFlag(args, "--verify"),
|
verify: hasFlag(args, "--verify"),
|
||||||
format: flagValWithDefault(args, "--format", "jpeg"),
|
format: flagValWithDefault(args, "--format", "jpeg"),
|
||||||
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
||||||
|
metadataOnly: hasFlag(args, "--metadata-only"),
|
||||||
reverseGeocode: hasFlag(args, "--reverse-geocode"),
|
reverseGeocode: hasFlag(args, "--reverse-geocode"),
|
||||||
dateTemplate: flagVal(args, "--date-template"),
|
dateTemplate: flagVal(args, "--date-template"),
|
||||||
}
|
}
|
||||||
@@ -1782,6 +1883,10 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
|||||||
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
|
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
|
||||||
return opts, false
|
return opts, false
|
||||||
}
|
}
|
||||||
|
if opts.metadataOnly && opts.sidecar != "xmp" {
|
||||||
|
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
if v := flagVal(args, "--retry"); v != "" {
|
if v := flagVal(args, "--retry"); v != "" {
|
||||||
n, err := strconv.Atoi(v)
|
n, err := strconv.Atoi(v)
|
||||||
if err != nil || n < 0 {
|
if err != nil || n < 0 {
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ type mockBridge struct {
|
|||||||
cancelled atomic.Bool
|
cancelled atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type noEntryManifest struct{}
|
||||||
|
|
||||||
|
func (noEntryManifest) Has(string) bool { return false }
|
||||||
|
func (noEntryManifest) Add(string, string, int64, string) {}
|
||||||
|
func (noEntryManifest) AddEntry(manifest.Entry) {}
|
||||||
|
func (noEntryManifest) Save() error { return nil }
|
||||||
|
func (noEntryManifest) Close() {}
|
||||||
|
func (noEntryManifest) OpenAppend() error { return nil }
|
||||||
|
|
||||||
func (m *mockBridge) RequestAccess() error { return m.accessErr }
|
func (m *mockBridge) RequestAccess() error { return m.accessErr }
|
||||||
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
|
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
|
||||||
func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
|
func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
|
||||||
@@ -4505,6 +4514,117 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMetadataOnlyExport(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := manifest.LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig.heic", MediaType: "image", IsFavorite: true}}}
|
||||||
|
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
|
||||||
|
t.Fatal("metadata-only must not export media")
|
||||||
|
return photos.ExportResult{}, nil
|
||||||
|
}
|
||||||
|
out, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
|
||||||
|
if rc != exitOK || out != "" || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
|
||||||
|
t.Fatalf("metadata-only rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") || !strings.Contains(string(data), "xmp:Rating=\"5\"") {
|
||||||
|
t.Fatalf("unexpected metadata sidecar: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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") {
|
||||||
|
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)
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
|
||||||
|
t.Fatalf("expected manifest requirement rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
|
||||||
|
t.Fatalf("expected backup manifest requirement rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataOnlyHelperBranches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, path: dir}
|
||||||
|
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "no path") {
|
||||||
|
t.Fatalf("expected no path error, got %v", err)
|
||||||
|
}
|
||||||
|
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "missing.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "missing") {
|
||||||
|
t.Fatalf("expected missing error, got %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "zero.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "zero-byte") {
|
||||||
|
t.Fatalf("expected zero-byte error, got %v", err)
|
||||||
|
}
|
||||||
|
if entries := manifestEntries(noEntryManifest{}); entries != nil {
|
||||||
|
t.Fatalf("expected nil entries, got %#v", entries)
|
||||||
|
}
|
||||||
|
written, failed := metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x1"}, path: dir}}, map[string]manifest.Entry{"x1": {ID: "x1", Path: "missing.jpg"}}, false, exportOptions{sidecar: "xmp"}, &mockBridge{})
|
||||||
|
if written != 0 || failed != 1 {
|
||||||
|
t.Fatalf("expected failed metadata pending, written=%d failed=%d", written, failed)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
written, failed = metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x2", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, path: dir}}, map[string]manifest.Entry{"x2": {ID: "x2", Path: "photo.jpg"}}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
|
||||||
|
return photos.Placemark{Country: "Sweden"}, nil
|
||||||
|
}})
|
||||||
|
if written != 1 || failed != 0 {
|
||||||
|
t.Fatalf("expected reverse geocode metadata success, written=%d failed=%d", written, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataOnlyBackupAll(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := manifest.LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "Album/photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
albumDir := filepath.Join(dir, "Album")
|
||||||
|
if err := os.Mkdir(albumDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(albumDir, "photo.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := &mockBridge{
|
||||||
|
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
|
||||||
|
assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "orig.heic", MediaType: "image"}, {ID: "x2", Filename: "missing.heic", MediaType: "image"}}},
|
||||||
|
}
|
||||||
|
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
|
||||||
|
t.Fatal("metadata-only backup-all must not export media")
|
||||||
|
return photos.ExportResult{}, nil
|
||||||
|
}
|
||||||
|
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
|
||||||
|
if rc != exitOK || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
|
||||||
|
t.Fatalf("metadata-only backup rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(albumDir, "photo.xmp")); err != nil {
|
||||||
|
t.Fatalf("expected metadata-only sidecar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSidecarAdditionalBranches(t *testing.T) {
|
func TestSidecarAdditionalBranches(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
oldCreateTemp := createTempFunc
|
oldCreateTemp := createTempFunc
|
||||||
|
|||||||
Reference in New Issue
Block a user