Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf922fee03 | |||
| c9ac014473 | |||
| 9cd048d9f3 | |||
| 98320c8235 | |||
| 84e70bda76 | |||
| bed3ea7bd0 | |||
| 7555b561bd | |||
| d909d30b87 | |||
| 5c40b1d3ba | |||
| 700d8ef05a |
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
bin/
|
bin/
|
||||||
bridge/*.o
|
bridge/*.o
|
||||||
bridge/*.a
|
bridge/*.a
|
||||||
coverage.out
|
coverage.out
|
||||||
|
ROADMAP.md
|
||||||
@@ -2,6 +2,88 @@
|
|||||||
|
|
||||||
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.10.0
|
||||||
|
|
||||||
|
Ports and adapters refactor.
|
||||||
|
|
||||||
|
- Extract shared manifest types into `internal/manifest/types` leaf package.
|
||||||
|
- Extract SQLite adapter into `internal/manifest/sqlite`: Store, Adapter, LogWriter.
|
||||||
|
- Extract JSONL adapter into `internal/manifest/jsonl`: Store, Adapter.
|
||||||
|
- `modernc.org/sqlite` import isolated to `internal/manifest/sqlite/adapter.go`.
|
||||||
|
- Registry pattern: `manifest.Default` provides adapter-backed `ParseFormat`/`Open`/`OpenLogWriter`.
|
||||||
|
- Adapter-agnostic `ConvertManifest` in `types/` for JSONL↔SQLite conversion.
|
||||||
|
- `MemoryAdapter` for in-memory manifest testing.
|
||||||
|
- CLI uses `manifest.Default` registry directly; zero concrete adapter references.
|
||||||
|
- SQLite `LogWriter` type assertion moved from central code into `SQLiteAdapter`.
|
||||||
|
- `Manifest` interface now includes `Entries()`; `EntryReader` removed.
|
||||||
|
- No behavior changes, no new features.
|
||||||
|
- 100% statement coverage across all 6 packages.
|
||||||
|
|
||||||
|
## v0.9.4
|
||||||
|
|
||||||
|
Doctor release.
|
||||||
|
|
||||||
|
- Add `doctor` to check Photos access and optional backup health.
|
||||||
|
- Add `doctor --out <dir>` to report backup directory, manifest entries, and failure count.
|
||||||
|
- Add `doctor --json` for scriptable diagnostics.
|
||||||
|
|
||||||
|
## v0.9.3
|
||||||
|
|
||||||
|
Cleanup release.
|
||||||
|
|
||||||
|
- Add `cleanup --out <dir>` to remove files not referenced by the manifest.
|
||||||
|
- Add `cleanup --dry-run` to preview orphaned files before deletion.
|
||||||
|
- Preserve manifest/log/failure files, `.photoscli`, and sidecars for manifest-referenced media.
|
||||||
|
|
||||||
|
## v0.9.2
|
||||||
|
|
||||||
|
Manifest repair release.
|
||||||
|
|
||||||
|
- Add `manifest repair --out <dir>` to fill missing manifest size metadata from files on disk.
|
||||||
|
- Add `manifest repair --checksum sha256` to fill missing manifest checksums.
|
||||||
|
- Add `manifest repair --dry-run` to preview repairs without writing manifest updates.
|
||||||
|
|
||||||
|
## v0.9.1
|
||||||
|
|
||||||
|
Deep checksum verification release.
|
||||||
|
|
||||||
|
- Add `verify --deep` to recompute SHA-256 checksums for manifest entries that have checksum metadata.
|
||||||
|
- Report `checksum-mismatch` when disk contents differ from the manifest checksum.
|
||||||
|
- Keep normal `verify` unchanged unless `--deep` is selected.
|
||||||
|
|
||||||
|
## v0.9.0
|
||||||
|
|
||||||
|
Manifest checksum release.
|
||||||
|
|
||||||
|
- Add opt-in `--checksum sha256` to store SHA-256 file checksums in JSONL and SQLite manifests.
|
||||||
|
- Add SQLite migration support for the manifest `checksum` column.
|
||||||
|
- Keep checksum collection disabled by default with `--checksum none`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
- Add `--xmp-keywords album-path|album|none` for generated sidecars.
|
||||||
|
- Add `--xmp-rating favorite|none` for generated sidecars.
|
||||||
|
- Keep existing keyword/rating behavior as defaults with `album-path` and `favorite`.
|
||||||
|
|
||||||
|
## v0.8.5
|
||||||
|
|
||||||
|
XMP privacy controls release.
|
||||||
|
|
||||||
|
- Add `--xmp-privacy keep|strip-location|strip-address` for generated sidecars.
|
||||||
|
- Keep existing XMP location/address behavior as the default with `keep`.
|
||||||
|
- Allow GPS coordinates to be kept while reverse-geocoded address fields are omitted with `strip-address`.
|
||||||
|
- Allow both GPS coordinates and address fields to be omitted with `strip-location`.
|
||||||
|
|
||||||
## v0.8.4
|
## v0.8.4
|
||||||
|
|
||||||
Strict XMP sidecar verification release.
|
Strict XMP 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.4
|
VERSION := 0.10.0
|
||||||
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`.
|
||||||
|
- Optional SHA-256 manifest checksums with `--checksum sha256`.
|
||||||
- Metadata-only XMP refresh for manifest-backed exports with `--metadata-only`.
|
- 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`.
|
||||||
@@ -116,6 +117,10 @@ Verify a backup later:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./bin/photoscli verify --out ./photos-backup --manifest sqlite
|
./bin/photoscli verify --out ./photos-backup --manifest sqlite
|
||||||
|
./bin/photoscli verify --out ./photos-backup --deep
|
||||||
|
./bin/photoscli manifest repair --out ./photos-backup --checksum sha256 --dry-run
|
||||||
|
./bin/photoscli cleanup --out ./photos-backup --dry-run
|
||||||
|
./bin/photoscli doctor --out ./photos-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -186,6 +191,7 @@ Useful examples:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
photoscli backup-all --out ./backup --manifest sqlite --log
|
photoscli backup-all --out ./backup --manifest sqlite --log
|
||||||
|
photoscli backup-all --out ./backup --checksum sha256
|
||||||
photoscli backup-all --out ./backup --exclude-album "Recently Deleted" --exclude-album "Temp*"
|
photoscli backup-all --out ./backup --exclude-album "Recently Deleted" --exclude-album "Temp*"
|
||||||
photoscli backup-all --out ./backup --since 2024-01-01 --sort newest
|
photoscli backup-all --out ./backup --since 2024-01-01 --sort newest
|
||||||
photoscli backup-all --out ./backup --concurrency 8 --retry 3
|
photoscli backup-all --out ./backup --concurrency 8 --retry 3
|
||||||
@@ -344,6 +350,8 @@ Write archival metadata sidecars with:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
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:
|
Sidecars are opt-in and use the exported file basename:
|
||||||
@@ -351,12 +359,17 @@ Sidecars are opt-in and use the exported file basename:
|
|||||||
```text
|
```text
|
||||||
IMG_0001.jpg -> IMG_0001.xmp
|
IMG_0001.jpg -> IMG_0001.xmp
|
||||||
IMG_0001.HEIC -> 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.
|
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.
|
||||||
|
|
||||||
Sidecars also include richer public PhotoKit metadata where available: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP dates, EXIF GPS coordinates, favorite rating, and album/folder keywords. Add `--reverse-geocode` to include cached address fields from Apple MapKit for assets with GPS coordinates. Reverse geocoding requires macOS 26 or newer; on older macOS versions the export continues and XMP still includes GPS coordinates.
|
Sidecars also include richer public PhotoKit metadata where available: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP dates, EXIF GPS coordinates, favorite rating, and album/folder keywords. Add `--reverse-geocode` to include cached address fields from Apple MapKit for assets with GPS coordinates. Reverse geocoding requires macOS 26 or newer; on older macOS versions the export continues and XMP still includes GPS coordinates.
|
||||||
|
|
||||||
|
Control XMP location metadata with `--xmp-privacy keep|strip-location|strip-address`. The default is `keep`. Use `strip-address` to omit reverse-geocoded address fields while keeping GPS coordinates, or `strip-location` to omit both GPS and address fields.
|
||||||
|
|
||||||
|
Control generated XMP keywords and ratings with `--xmp-keywords album-path|album|none` and `--xmp-rating favorite|none`. Defaults preserve existing behavior: album/folder keywords and favorite assets mapped to `xmp:Rating="5"`.
|
||||||
|
|
||||||
Verify generated sidecars with:
|
Verify generated sidecars with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+14
-6
@@ -1,17 +1,25 @@
|
|||||||
# v0.8.4
|
# v0.10.0
|
||||||
|
|
||||||
This release adds strict XMP sidecar verification.
|
Ports and adapters refactor. No user-visible changes.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Add `verify --sidecar --strict` to require photoscli schema metadata, sidecar generator metadata, and matching exported filename metadata.
|
- Extract manifest types, registry, and conversion logic into `internal/manifest/types`.
|
||||||
- Keep existing `verify --sidecar` behavior unchanged for basic sidecar checks.
|
- Extract SQLite adapter into `internal/manifest/sqlite` with its own store, adapter, and log writer.
|
||||||
- Use strict mode when validating sidecars generated by recent photoscli versions.
|
- Extract JSONL adapter into `internal/manifest/jsonl` with its own store and adapter.
|
||||||
|
- Isolate `modernc.org/sqlite` import to the SQLite adapter package only.
|
||||||
|
- Replace central `Open`/`ParseFormat`/`OpenLogWriter` with adapter-backed registry.
|
||||||
|
- Adapter-agnostic manifest conversion via `ConvertManifest`.
|
||||||
|
- SQLite log writer selection moved from central code into `SQLiteAdapter`.
|
||||||
|
- CLI uses `manifest.Default` registry; no direct references to concrete JSONL or SQLite types.
|
||||||
|
- `MemoryAdapter` available for in-memory manifest tests.
|
||||||
|
- 100% statement coverage across all 6 packages.
|
||||||
|
- No behavior changes.
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
||||||
- `photoscli-0.8.4-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
- `photoscli-0.10.0-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.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ It is especially useful when you want to:
|
|||||||
- Retry transient iCloud failures.
|
- Retry transient iCloud failures.
|
||||||
- Verify that files referenced by a manifest exist on disk.
|
- Verify that files referenced by a manifest exist on disk.
|
||||||
- Detect missing, zero-byte, and size-mismatched manifest files.
|
- Detect missing, zero-byte, and size-mismatched manifest files.
|
||||||
|
- Store optional SHA-256 checksums in manifests.
|
||||||
- Inspect or clear deduplicated failure records.
|
- Inspect or clear deduplicated failure records.
|
||||||
- Write optional XMP sidecar metadata for archival workflows.
|
- Write optional XMP sidecar metadata for archival workflows.
|
||||||
- Add optional reverse-geocoded address metadata to XMP sidecars for GPS assets.
|
- Add optional reverse-geocoded address metadata to XMP sidecars for GPS assets.
|
||||||
@@ -546,6 +547,8 @@ Use XMP sidecars when you want portable metadata next to exported files:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
./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:
|
Sidecars are disabled by default. When enabled, they use the exported file basename:
|
||||||
@@ -553,10 +556,31 @@ Sidecars are disabled by default. When enabled, they use the exported file basen
|
|||||||
```text
|
```text
|
||||||
IMG_0001.jpg -> IMG_0001.xmp
|
IMG_0001.jpg -> IMG_0001.xmp
|
||||||
IMG_0001.HEIC -> 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.
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-privacy strip-address
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-privacy strip-location
|
||||||
|
```
|
||||||
|
|
||||||
|
`keep` is the default. `strip-address` omits reverse-geocoded address fields while preserving GPS coordinates. `strip-location` omits both GPS coordinates and address fields.
|
||||||
|
|
||||||
|
Control generated keywords and ratings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-keywords album
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-keywords none --xmp-rating none
|
||||||
|
```
|
||||||
|
|
||||||
|
`--xmp-keywords album-path` is the default and writes album/folder keywords. `album` writes only the album name. `none` omits generated `dc:subject` keywords. `--xmp-rating favorite` is the default and maps favorite assets to `xmp:Rating="5"`; `none` omits that generated rating.
|
||||||
|
|
||||||
For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
|
For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -798,6 +822,50 @@ Use structured logs:
|
|||||||
./bin/photoscli backup-all --out ./PhotosBackup --manifest sqlite --log
|
./bin/photoscli backup-all --out ./PhotosBackup --manifest sqlite --log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Store SHA-256 checksums in the manifest when exporting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli backup-all --out ./PhotosBackup --checksum sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Checksums are opt-in and stored in JSONL or SQLite manifests. The default is `--checksum none`.
|
||||||
|
|
||||||
|
Deep-verify checksum-backed manifests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli verify --out ./PhotosBackup --deep
|
||||||
|
```
|
||||||
|
|
||||||
|
`--deep` recomputes SHA-256 only for manifest entries that already have checksum metadata.
|
||||||
|
|
||||||
|
Repair missing manifest metadata from files already present on disk:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli manifest repair --out ./PhotosBackup --checksum sha256 --dry-run
|
||||||
|
./bin/photoscli manifest repair --out ./PhotosBackup --checksum sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Repair fills missing size/checksum metadata and skips missing, zero-byte, or unreadable files.
|
||||||
|
|
||||||
|
Preview and remove files not referenced by the manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli cleanup --out ./PhotosBackup --dry-run
|
||||||
|
./bin/photoscli cleanup --out ./PhotosBackup
|
||||||
|
```
|
||||||
|
|
||||||
|
Cleanup preserves manifest/log/failure files, `.photoscli`, and sidecars next to manifest-referenced media.
|
||||||
|
|
||||||
|
Run read-only diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli doctor
|
||||||
|
./bin/photoscli doctor --out ./PhotosBackup
|
||||||
|
./bin/photoscli doctor --out ./PhotosBackup --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Doctor checks Photos access and, when `--out` is provided, backup directory status, manifest entries, and failure count.
|
||||||
|
|
||||||
## Safe Operating Practices
|
## Safe Operating Practices
|
||||||
|
|
||||||
- Run `--dry-run` before the first large backup.
|
- Run `--dry-run` before the first large backup.
|
||||||
|
|||||||
+437
-36
@@ -2,10 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -31,6 +34,7 @@ var (
|
|||||||
renameFunc = os.Rename
|
renameFunc = os.Rename
|
||||||
openFileFunc = os.OpenFile
|
openFileFunc = os.OpenFile
|
||||||
removeFunc = os.Remove
|
removeFunc = os.Remove
|
||||||
|
reg = manifest.Default
|
||||||
)
|
)
|
||||||
|
|
||||||
type exportOptions struct {
|
type exportOptions struct {
|
||||||
@@ -42,6 +46,10 @@ type exportOptions struct {
|
|||||||
verify bool
|
verify bool
|
||||||
format string
|
format string
|
||||||
sidecar string
|
sidecar string
|
||||||
|
checksum string
|
||||||
|
xmpPrivacy string
|
||||||
|
xmpKeywords string
|
||||||
|
xmpRating string
|
||||||
metadataOnly bool
|
metadataOnly bool
|
||||||
reverseGeocode bool
|
reverseGeocode bool
|
||||||
minSize int64
|
minSize int64
|
||||||
@@ -86,6 +94,12 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
|||||||
return cmdDiff(args[1:], stdout, stderr, bridge)
|
return cmdDiff(args[1:], stdout, stderr, bridge)
|
||||||
case "verify":
|
case "verify":
|
||||||
return cmdVerify(args[1:], stdout, stderr)
|
return cmdVerify(args[1:], stdout, stderr)
|
||||||
|
case "manifest":
|
||||||
|
return cmdManifest(args[1:], stdout, stderr)
|
||||||
|
case "cleanup":
|
||||||
|
return cmdCleanup(args[1:], stdout, stderr)
|
||||||
|
case "doctor":
|
||||||
|
return cmdDoctor(args[1:], stdout, stderr, bridge)
|
||||||
case "retry-failed":
|
case "retry-failed":
|
||||||
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
|
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
|
||||||
case "failures":
|
case "failures":
|
||||||
@@ -131,6 +145,9 @@ USAGE
|
|||||||
photoscli report --out <dir> [--manifest jsonl|sqlite]
|
photoscli report --out <dir> [--manifest jsonl|sqlite]
|
||||||
photoscli diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite]
|
photoscli diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite]
|
||||||
photoscli verify --out <dir> [--manifest jsonl|sqlite]
|
photoscli verify --out <dir> [--manifest jsonl|sqlite]
|
||||||
|
photoscli manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
|
||||||
|
photoscli cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
|
||||||
|
photoscli doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
|
||||||
photoscli retry-failed --out <dir>
|
photoscli retry-failed --out <dir>
|
||||||
photoscli retry-failed --out <dir> --clear-on-success
|
photoscli retry-failed --out <dir> --clear-on-success
|
||||||
photoscli failures list --out <dir>
|
photoscli failures list --out <dir>
|
||||||
@@ -174,6 +191,16 @@ COMMANDS
|
|||||||
files are printed as <asset-id><TAB><filename>. Exits 2 on missing files.
|
files are printed as <asset-id><TAB><filename>. Exits 2 on missing files.
|
||||||
Add --sidecar to verify expected XMP sidecars too. Add --strict with
|
Add --sidecar to verify expected XMP sidecars too. Add --strict with
|
||||||
--sidecar to require photoscli schema/generator and exported filename metadata.
|
--sidecar to require photoscli schema/generator and exported filename metadata.
|
||||||
|
Add --deep to verify manifest SHA-256 checksums when present.
|
||||||
|
|
||||||
|
manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
|
||||||
|
Fill missing manifest size/checksum metadata from files that exist on disk.
|
||||||
|
|
||||||
|
cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
|
||||||
|
Remove files not referenced by the manifest. Use --dry-run first.
|
||||||
|
|
||||||
|
doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
|
||||||
|
Check Photos access and optional backup/manifest health without changing data.
|
||||||
|
|
||||||
retry-failed --out <dir>
|
retry-failed --out <dir>
|
||||||
Retry assets previously written to failures.jsonl.
|
Retry assets previously written to failures.jsonl.
|
||||||
@@ -225,9 +252,21 @@ COMMON EXPORT FLAGS
|
|||||||
--verify
|
--verify
|
||||||
Run manifest/file verification after export or backup-all.
|
Run manifest/file verification after export or backup-all.
|
||||||
|
|
||||||
--sidecar none|xmp
|
--checksum none|sha256
|
||||||
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
Store optional file checksum metadata in the manifest. Default: none.
|
||||||
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.
|
||||||
|
|
||||||
|
--xmp-keywords album-path|album|none
|
||||||
|
Control dc:subject keywords in generated XMP sidecars. Default: album-path.
|
||||||
|
|
||||||
|
--xmp-rating favorite|none
|
||||||
|
Control favorite-to-rating mapping in generated XMP sidecars. Default: favorite.
|
||||||
|
|
||||||
--metadata-only
|
--metadata-only
|
||||||
With --sidecar xmp, write or refresh XMP sidecars for files already in
|
With --sidecar xmp, write or refresh XMP sidecars for files already in
|
||||||
@@ -462,7 +501,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
|
||||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
mf, mfErr := reg.ParseFormat(manifestFmt)
|
||||||
if mfErr != nil {
|
if mfErr != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", mfErr)
|
fmt.Fprintf(stderr, "error: %v\n", mfErr)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -565,7 +604,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
}
|
}
|
||||||
var exported, failed int
|
var exported, failed int
|
||||||
if opts.metadataOnly {
|
if opts.metadataOnly {
|
||||||
m, _ := manifest.Open(outDir, mf)
|
m, _ := reg.Open(outDir, mf)
|
||||||
defer m.Close()
|
defer m.Close()
|
||||||
entries := manifestEntries(m)
|
entries := manifestEntries(m)
|
||||||
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
|
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
|
||||||
@@ -635,7 +674,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
|
||||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
mf, mfErr := reg.ParseFormat(manifestFmt)
|
||||||
if mfErr != nil {
|
if mfErr != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", mfErr)
|
fmt.Fprintf(stderr, "error: %v\n", mfErr)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -709,7 +748,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
}
|
}
|
||||||
var totalAssets, failed int
|
var totalAssets, failed int
|
||||||
if opts.metadataOnly {
|
if opts.metadataOnly {
|
||||||
m, _ := manifest.Open(outDir, mf)
|
m, _ := reg.Open(outDir, mf)
|
||||||
entries := manifestEntries(m)
|
entries := manifestEntries(m)
|
||||||
m.Close()
|
m.Close()
|
||||||
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
|
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
|
||||||
@@ -777,7 +816,7 @@ func logEntry(event, level, assetID, album, filename, cloud string, size int64,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.ExportResult) {
|
func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.ExportResult, checksum string) {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -790,7 +829,33 @@ func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.Export
|
|||||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||||
relPath = result.Filename
|
relPath = result.Filename
|
||||||
}
|
}
|
||||||
m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud))
|
m.AddEntry(manifest.NewEntryWithChecksum(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud, checksum))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addManifestEntryForResult(m manifest.Manifest, pa pendingAsset, result photos.ExportResult, opts exportOptions) error {
|
||||||
|
checksum := ""
|
||||||
|
if opts.checksum == "sha256" && !result.Skipped {
|
||||||
|
var err error
|
||||||
|
checksum, err = fileSHA256(filepath.Join(pa.path, result.Filename))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addManifestEntry(m, pa, result, checksum)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSHA256(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type xmpSidecarData struct {
|
type xmpSidecarData struct {
|
||||||
@@ -889,6 +954,20 @@ func sidecarPath(exportedPath string) string {
|
|||||||
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
|
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 {
|
func renderXMP(d xmpSidecarData) []byte {
|
||||||
attrs := []struct{ key, val string }{
|
attrs := []struct{ key, val string }{
|
||||||
{"photoscli:xmpSchemaVersion", "2"},
|
{"photoscli:xmpSchemaVersion", "2"},
|
||||||
@@ -1062,8 +1141,31 @@ func writeXMPSidecar(path string, data xmpSidecarData) error {
|
|||||||
return nil
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
mode := "preview"
|
mode := "preview"
|
||||||
@@ -1091,17 +1193,48 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
|||||||
if pa.asset.ModificationDate != nil {
|
if pa.asset.ModificationDate != nil {
|
||||||
modifyDate = *pa.asset.ModificationDate
|
modifyDate = *pa.asset.ModificationDate
|
||||||
}
|
}
|
||||||
|
location := pa.asset.Location
|
||||||
|
xmpPrivacy := opts.xmpPrivacy
|
||||||
|
if xmpPrivacy == "" {
|
||||||
|
xmpPrivacy = "keep"
|
||||||
|
}
|
||||||
|
xmpKeywords := opts.xmpKeywords
|
||||||
|
if xmpKeywords == "" {
|
||||||
|
xmpKeywords = "album-path"
|
||||||
|
}
|
||||||
|
xmpRating := opts.xmpRating
|
||||||
|
if xmpRating == "" {
|
||||||
|
xmpRating = "favorite"
|
||||||
|
}
|
||||||
var placemark *photos.Placemark
|
var placemark *photos.Placemark
|
||||||
if opts.reverseGeocode && pa.asset.Location != nil && cache != nil {
|
if opts.reverseGeocode && location != nil && cache != nil && xmpPrivacy == "keep" {
|
||||||
placemark = cache.lookup(pa.asset.Location.Latitude, pa.asset.Location.Longitude, bridge)
|
placemark = cache.lookup(pa.asset.Location.Latitude, pa.asset.Location.Longitude, bridge)
|
||||||
}
|
}
|
||||||
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
|
if xmpPrivacy == "strip-location" {
|
||||||
|
location = nil
|
||||||
|
placemark = nil
|
||||||
|
}
|
||||||
|
if xmpPrivacy == "strip-address" {
|
||||||
|
placemark = nil
|
||||||
|
}
|
||||||
|
keywords := keywordsFromAlbumPath(pa.album, relDir)
|
||||||
|
if xmpKeywords == "album" {
|
||||||
|
keywords = keywordsFromAlbumPath(pa.album, "")
|
||||||
|
}
|
||||||
|
if xmpKeywords == "none" {
|
||||||
|
keywords = nil
|
||||||
|
}
|
||||||
|
isFavorite := pa.asset.IsFavorite
|
||||||
|
if xmpRating == "none" {
|
||||||
|
isFavorite = false
|
||||||
|
}
|
||||||
|
data := xmpSidecarData{
|
||||||
AssetID: pa.asset.ID,
|
AssetID: pa.asset.ID,
|
||||||
OriginalFilename: pa.asset.Filename,
|
OriginalFilename: pa.asset.Filename,
|
||||||
ExportedFilename: result.Filename,
|
ExportedFilename: result.Filename,
|
||||||
Album: pa.album,
|
Album: pa.album,
|
||||||
AlbumPath: pa.path,
|
AlbumPath: pa.path,
|
||||||
Keywords: keywordsFromAlbumPath(pa.album, relDir),
|
Keywords: keywords,
|
||||||
ManifestPath: relPath,
|
ManifestPath: relPath,
|
||||||
MediaType: pa.asset.MediaType,
|
MediaType: pa.asset.MediaType,
|
||||||
MediaSubtypes: pa.asset.MediaSubtypes,
|
MediaSubtypes: pa.asset.MediaSubtypes,
|
||||||
@@ -1110,7 +1243,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
|||||||
PixelWidth: pa.asset.PixelWidth,
|
PixelWidth: pa.asset.PixelWidth,
|
||||||
PixelHeight: pa.asset.PixelHeight,
|
PixelHeight: pa.asset.PixelHeight,
|
||||||
Duration: pa.asset.Duration,
|
Duration: pa.asset.Duration,
|
||||||
IsFavorite: pa.asset.IsFavorite,
|
IsFavorite: isFavorite,
|
||||||
IsHidden: pa.asset.IsHidden,
|
IsHidden: pa.asset.IsHidden,
|
||||||
HasAdjustments: pa.asset.HasAdjustments,
|
HasAdjustments: pa.asset.HasAdjustments,
|
||||||
Cloud: result.Cloud,
|
Cloud: result.Cloud,
|
||||||
@@ -1120,14 +1253,25 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
|||||||
Size: result.Size,
|
Size: result.Size,
|
||||||
CreateDate: createDate,
|
CreateDate: createDate,
|
||||||
ModifyDate: modifyDate,
|
ModifyDate: modifyDate,
|
||||||
Location: pa.asset.Location,
|
Location: location,
|
||||||
Placemark: placemark,
|
Placemark: placemark,
|
||||||
BurstIdentifier: pa.asset.BurstIdentifier,
|
BurstIdentifier: pa.asset.BurstIdentifier,
|
||||||
RepresentsBurst: pa.asset.RepresentsBurst,
|
RepresentsBurst: pa.asset.RepresentsBurst,
|
||||||
BurstSelectionTypes: pa.asset.BurstSelectionTypes,
|
BurstSelectionTypes: pa.asset.BurstSelectionTypes,
|
||||||
AdjustmentInfo: pa.asset.AdjustmentInfo,
|
AdjustmentInfo: pa.asset.AdjustmentInfo,
|
||||||
Resources: pa.asset.Resources,
|
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 {
|
func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
|
||||||
@@ -1156,10 +1300,7 @@ func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals b
|
|||||||
}
|
}
|
||||||
|
|
||||||
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
|
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
|
||||||
if r, ok := m.(manifest.EntryReader); ok {
|
return m.Entries()
|
||||||
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) {
|
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
|
||||||
@@ -1285,7 +1426,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
|
|||||||
var m manifest.Manifest
|
var m manifest.Manifest
|
||||||
if !noManifest {
|
if !noManifest {
|
||||||
var err error
|
var err error
|
||||||
m, err = manifest.Open(outDir, mf)
|
m, err = reg.Open(outDir, mf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err)
|
fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -1298,7 +1439,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
|
|||||||
var lw manifest.LogWriter = manifest.NoopLogWriter
|
var lw manifest.LogWriter = manifest.NoopLogWriter
|
||||||
if enableLog {
|
if enableLog {
|
||||||
var err error
|
var err error
|
||||||
lw, err = manifest.OpenLogWriter(m, outDir)
|
lw, err = reg.OpenLogWriter(m, outDir, mf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err)
|
fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err)
|
||||||
lw = manifest.NoopLogWriter
|
lw = manifest.NoopLogWriter
|
||||||
@@ -1377,16 +1518,20 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
|
|||||||
failed++
|
failed++
|
||||||
appendFailure(pa.path, pa, exportErr)
|
appendFailure(pa.path, pa, exportErr)
|
||||||
} else if isSkipped {
|
} else if isSkipped {
|
||||||
addManifestEntry(m, pa, result)
|
_ = addManifestEntryForResult(m, pa, result, opts)
|
||||||
} else {
|
} else {
|
||||||
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts, geo, bridge); sidecarErr != nil {
|
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts, geo, bridge); sidecarErr != nil {
|
||||||
failed++
|
failed++
|
||||||
exportErr = sidecarErr
|
exportErr = sidecarErr
|
||||||
isErr = true
|
isErr = true
|
||||||
appendFailure(pa.path, pa, sidecarErr)
|
appendFailure(pa.path, pa, sidecarErr)
|
||||||
|
} else if checksumErr := addManifestEntryForResult(m, pa, result, opts); checksumErr != nil {
|
||||||
|
failed++
|
||||||
|
exportErr = checksumErr
|
||||||
|
isErr = true
|
||||||
|
appendFailure(pa.path, pa, checksumErr)
|
||||||
} else {
|
} else {
|
||||||
done++
|
done++
|
||||||
addManifestEntry(m, pa, result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
avgSpeed := float64(0)
|
avgSpeed := float64(0)
|
||||||
@@ -1517,16 +1662,20 @@ func exportPendingParallel(pending []pendingAsset, targetSize, quality int, orig
|
|||||||
failed++
|
failed++
|
||||||
appendFailure(entry.pa.path, entry.pa, entry.err)
|
appendFailure(entry.pa.path, entry.pa, entry.err)
|
||||||
} else if isSkipped {
|
} else if isSkipped {
|
||||||
addManifestEntry(m, entry.pa, entry.result)
|
_ = addManifestEntryForResult(m, entry.pa, entry.result, opts)
|
||||||
} else {
|
} else {
|
||||||
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts, geo, bridge); sidecarErr != nil {
|
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts, geo, bridge); sidecarErr != nil {
|
||||||
failed++
|
failed++
|
||||||
entry.err = sidecarErr
|
entry.err = sidecarErr
|
||||||
isErr = true
|
isErr = true
|
||||||
appendFailure(entry.pa.path, entry.pa, sidecarErr)
|
appendFailure(entry.pa.path, entry.pa, sidecarErr)
|
||||||
|
} else if checksumErr := addManifestEntryForResult(m, entry.pa, entry.result, opts); checksumErr != nil {
|
||||||
|
failed++
|
||||||
|
entry.err = checksumErr
|
||||||
|
isErr = true
|
||||||
|
appendFailure(entry.pa.path, entry.pa, checksumErr)
|
||||||
} else {
|
} else {
|
||||||
done++
|
done++
|
||||||
addManifestEntry(m, entry.pa, entry.result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
avgSpeed := float64(0)
|
avgSpeed := float64(0)
|
||||||
@@ -1566,7 +1715,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con
|
|||||||
var m manifest.Manifest
|
var m manifest.Manifest
|
||||||
if !noManifest {
|
if !noManifest {
|
||||||
var err error
|
var err error
|
||||||
m, err = manifest.Open(outDir, mf)
|
m, err = reg.Open(outDir, mf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err)
|
fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -1579,7 +1728,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con
|
|||||||
var lw manifest.LogWriter = manifest.NoopLogWriter
|
var lw manifest.LogWriter = manifest.NoopLogWriter
|
||||||
if enableLog {
|
if enableLog {
|
||||||
var err error
|
var err error
|
||||||
lw, err = manifest.OpenLogWriter(m, outDir)
|
lw, err = reg.OpenLogWriter(m, outDir, mf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err)
|
fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err)
|
||||||
lw = manifest.NoopLogWriter
|
lw = manifest.NoopLogWriter
|
||||||
@@ -1875,6 +2024,10 @@ 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"),
|
||||||
|
checksum: flagValWithDefault(args, "--checksum", "none"),
|
||||||
|
xmpPrivacy: flagValWithDefault(args, "--xmp-privacy", "keep"),
|
||||||
|
xmpKeywords: flagValWithDefault(args, "--xmp-keywords", "album-path"),
|
||||||
|
xmpRating: flagValWithDefault(args, "--xmp-rating", "favorite"),
|
||||||
metadataOnly: hasFlag(args, "--metadata-only"),
|
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"),
|
||||||
@@ -1887,12 +2040,37 @@ 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)
|
fmt.Fprintf(stderr, "error: --format must be jpeg, heic, or png, got %q\n", opts.format)
|
||||||
return opts, false
|
return opts, false
|
||||||
}
|
}
|
||||||
if opts.sidecar != "none" && opts.sidecar != "xmp" {
|
if opts.sidecar != "none" && !sidecarEnabled(opts.sidecar, "xmp") && !sidecarEnabled(opts.sidecar, "json") {
|
||||||
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
|
fmt.Fprintf(stderr, "error: --sidecar must be none, xmp, json, or xmp,json, got %q\n", opts.sidecar)
|
||||||
return opts, false
|
return opts, false
|
||||||
}
|
}
|
||||||
if opts.metadataOnly && opts.sidecar != "xmp" {
|
if opts.sidecar != "none" {
|
||||||
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
|
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.checksum != "none" && opts.checksum != "sha256" {
|
||||||
|
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", opts.checksum)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if opts.xmpKeywords != "album-path" && opts.xmpKeywords != "album" && opts.xmpKeywords != "none" {
|
||||||
|
fmt.Fprintf(stderr, "error: --xmp-keywords must be album-path, album, or none, got %q\n", opts.xmpKeywords)
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
|
if opts.xmpRating != "favorite" && opts.xmpRating != "none" {
|
||||||
|
fmt.Fprintf(stderr, "error: --xmp-rating must be favorite or none, got %q\n", opts.xmpRating)
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
|
if opts.metadataOnly && opts.sidecar == "none" {
|
||||||
|
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp, json, or xmp,json")
|
||||||
return opts, false
|
return opts, false
|
||||||
}
|
}
|
||||||
if v := flagVal(args, "--retry"); v != "" {
|
if v := flagVal(args, "--retry"); v != "" {
|
||||||
@@ -2050,7 +2228,7 @@ func cmdReport(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stderr, "error: --out is required")
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -2079,7 +2257,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int
|
|||||||
fmt.Fprintln(stderr, "error: --album-id and --out are required")
|
fmt.Fprintln(stderr, "error: --album-id and --out are required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -2119,11 +2297,12 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
|
|||||||
outDir := flagVal(args, "--out")
|
outDir := flagVal(args, "--out")
|
||||||
checkSidecar := hasFlag(args, "--sidecar")
|
checkSidecar := hasFlag(args, "--sidecar")
|
||||||
strictSidecar := hasFlag(args, "--strict")
|
strictSidecar := hasFlag(args, "--strict")
|
||||||
|
deep := hasFlag(args, "--deep")
|
||||||
if outDir == "" {
|
if outDir == "" {
|
||||||
fmt.Fprintln(stderr, "error: --out is required")
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -2157,6 +2336,16 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
|
|||||||
bad++
|
bad++
|
||||||
fmt.Fprintf(stdout, "%s\t%s\tsize-mismatch\tmanifest=%d\tdisk=%d\n", id, checkPath, e.Size, info.Size())
|
fmt.Fprintf(stdout, "%s\t%s\tsize-mismatch\tmanifest=%d\tdisk=%d\n", id, checkPath, e.Size, info.Size())
|
||||||
}
|
}
|
||||||
|
if deep && e.Checksum != "" {
|
||||||
|
checksum, err := fileSHA256(filepath.Join(outDir, checkPath))
|
||||||
|
if err != nil {
|
||||||
|
bad++
|
||||||
|
fmt.Fprintf(stdout, "%s\t%s\tchecksum-unreadable\n", id, checkPath)
|
||||||
|
} else if checksum != e.Checksum {
|
||||||
|
bad++
|
||||||
|
fmt.Fprintf(stdout, "%s\t%s\tchecksum-mismatch\tmanifest=%s\tdisk=%s\n", id, checkPath, e.Checksum, checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
if checkSidecar {
|
if checkSidecar {
|
||||||
bad += verifySidecar(stdout, outDir, id, checkPath, strictSidecar)
|
bad += verifySidecar(stdout, outDir, id, checkPath, strictSidecar)
|
||||||
}
|
}
|
||||||
@@ -2211,6 +2400,218 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool)
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||||
|
result := map[string]any{"photos_access": "ok"}
|
||||||
|
problems := 0
|
||||||
|
if rc := mustAuth(stderr, bridge); rc != exitOK {
|
||||||
|
result["photos_access"] = "denied"
|
||||||
|
problems++
|
||||||
|
}
|
||||||
|
outDir := flagVal(args, "--out")
|
||||||
|
if outDir != "" {
|
||||||
|
result["out"] = outDir
|
||||||
|
if info, err := statFunc(outDir); err != nil || !info.IsDir() {
|
||||||
|
result["backup_dir"] = "missing"
|
||||||
|
problems++
|
||||||
|
} else {
|
||||||
|
result["backup_dir"] = "ok"
|
||||||
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
entries, err := loadManifestEntries(outDir, mf)
|
||||||
|
if err != nil {
|
||||||
|
result["manifest"] = "error"
|
||||||
|
problems++
|
||||||
|
} else {
|
||||||
|
result["manifest"] = string(mf)
|
||||||
|
result["entries"] = len(entries)
|
||||||
|
}
|
||||||
|
failures := loadFailures(outDir)
|
||||||
|
result["failures"] = len(failures)
|
||||||
|
if len(failures) > 0 {
|
||||||
|
problems++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasFlag(args, "--json") {
|
||||||
|
result["problems"] = problems
|
||||||
|
if err := json.NewEncoder(stdout).Encode(result); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keys := make([]string, 0, len(result))
|
||||||
|
for k := range result {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(stdout, "%s\t%v\n", k, result[k])
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "problems\t%d\n", problems)
|
||||||
|
}
|
||||||
|
if problems > 0 {
|
||||||
|
return exitPartial
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdCleanup(args []string, stdout, stderr io.Writer) int {
|
||||||
|
outDir := flagVal(args, "--out")
|
||||||
|
if outDir == "" {
|
||||||
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
entries, err := loadManifestEntries(outDir, mf)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
keep := map[string]bool{
|
||||||
|
"downloads.jsonl": true,
|
||||||
|
"downloads.db": true,
|
||||||
|
"export.log": true,
|
||||||
|
"failures.jsonl": true,
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
checkPath := e.Path
|
||||||
|
if checkPath == "" {
|
||||||
|
checkPath = e.Filename
|
||||||
|
}
|
||||||
|
if checkPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(checkPath)
|
||||||
|
if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keep[clean] = true
|
||||||
|
keep[filepath.Clean(sidecarPath(clean))] = true
|
||||||
|
keep[filepath.Clean(jsonSidecarPath(clean))] = true
|
||||||
|
}
|
||||||
|
dryRun := hasFlag(args, "--dry-run")
|
||||||
|
removed := 0
|
||||||
|
skipped := 0
|
||||||
|
_ = filepath.WalkDir(outDir, func(path string, d fs.DirEntry, _ error) error {
|
||||||
|
if path == outDir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(outDir, path)
|
||||||
|
rel = filepath.Clean(rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
if rel == ".photoscli" || strings.HasPrefix(rel, ".photoscli"+string(os.PathSeparator)) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if keep[rel] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "%s\torphan\n", rel)
|
||||||
|
removed++
|
||||||
|
if !dryRun {
|
||||||
|
if err := removeFunc(path); err != nil {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
fmt.Fprintf(stdout, "removed\t%d\nskipped\t%d\n", removed, skipped)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdManifest(args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) < 1 || args[0] != "repair" {
|
||||||
|
fmt.Fprintln(stderr, "error: expected manifest repair")
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
outDir := flagVal(args, "--out")
|
||||||
|
if outDir == "" {
|
||||||
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
checksumMode := flagValWithDefault(args, "--checksum", "none")
|
||||||
|
if checksumMode != "none" && checksumMode != "sha256" {
|
||||||
|
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", checksumMode)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
m, err := manifest.Open(outDir, mf)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
reader := m.(manifest.EntryReader)
|
||||||
|
if !hasFlag(args, "--dry-run") {
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repaired := 0
|
||||||
|
skipped := 0
|
||||||
|
for id, entry := range reader.Entries() {
|
||||||
|
checkPath := entry.Path
|
||||||
|
if checkPath == "" {
|
||||||
|
checkPath = entry.Filename
|
||||||
|
}
|
||||||
|
if checkPath == "" {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(outDir, checkPath)
|
||||||
|
info, err := statFunc(fullPath)
|
||||||
|
if err != nil || info.IsDir() || info.Size() == 0 {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updated := entry
|
||||||
|
updated.ID = id
|
||||||
|
changed := false
|
||||||
|
if updated.Size != info.Size() {
|
||||||
|
updated.Size = info.Size()
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if checksumMode == "sha256" && updated.Checksum == "" {
|
||||||
|
checksum, err := fileSHA256(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
updated.Checksum = checksum
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repaired++
|
||||||
|
fmt.Fprintf(stdout, "%s\t%s\trepaired\n", id, checkPath)
|
||||||
|
if !hasFlag(args, "--dry-run") {
|
||||||
|
m.AddEntry(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasFlag(args, "--dry-run") {
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "repaired\t%d\nskipped\t%d\n", repaired, skipped)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
func cmdSidecar(args []string, stdout, stderr io.Writer) int {
|
func cmdSidecar(args []string, stdout, stderr io.Writer) int {
|
||||||
if len(args) < 1 || args[0] != "inspect" {
|
if len(args) < 1 || args[0] != "inspect" {
|
||||||
fmt.Fprintln(stderr, "error: expected sidecar inspect <file.xmp>")
|
fmt.Fprintln(stderr, "error: expected sidecar inspect <file.xmp>")
|
||||||
@@ -2352,7 +2753,7 @@ func cmdStatus(args []string, stdout, stderr io.Writer) int {
|
|||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
|
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
|
||||||
mf, err := manifest.ParseFormat(manifestFmt)
|
mf, err := reg.ParseFormat(manifestFmt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
|
|||||||
+529
-15
@@ -39,14 +39,24 @@ type errWriter struct{}
|
|||||||
|
|
||||||
func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") }
|
func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") }
|
||||||
|
|
||||||
|
type testFileInfo struct{ size int64 }
|
||||||
|
|
||||||
|
func (t testFileInfo) Name() string { return "test" }
|
||||||
|
func (t testFileInfo) Size() int64 { return t.size }
|
||||||
|
func (t testFileInfo) Mode() os.FileMode { return 0644 }
|
||||||
|
func (t testFileInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (t testFileInfo) IsDir() bool { return false }
|
||||||
|
func (t testFileInfo) Sys() any { return nil }
|
||||||
|
|
||||||
type noEntryManifest struct{}
|
type noEntryManifest struct{}
|
||||||
|
|
||||||
func (noEntryManifest) Has(string) bool { return false }
|
func (noEntryManifest) Has(string) bool { return false }
|
||||||
func (noEntryManifest) Add(string, string, int64, string) {}
|
func (noEntryManifest) Add(string, string, int64, string) {}
|
||||||
func (noEntryManifest) AddEntry(manifest.Entry) {}
|
func (noEntryManifest) AddEntry(manifest.Entry) {}
|
||||||
func (noEntryManifest) Save() error { return nil }
|
func (noEntryManifest) Save() error { return nil }
|
||||||
func (noEntryManifest) Close() {}
|
func (noEntryManifest) Close() {}
|
||||||
func (noEntryManifest) OpenAppend() error { return nil }
|
func (noEntryManifest) OpenAppend() error { return nil }
|
||||||
|
func (noEntryManifest) Entries() map[string]manifest.Entry { 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 }
|
||||||
@@ -2662,6 +2672,23 @@ func TestExportPendingParallelManifestAdd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportPendingParallelChecksumError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mf := manifest.LoadJSONL(dir)
|
||||||
|
if err := mf.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil
|
||||||
|
}
|
||||||
|
bar := newProgressBar(io.Discard, 1)
|
||||||
|
done, failed := exportPendingParallel([]pendingAsset{{asset: b.assets[0], path: dir}}, 1024, 85, false, 1, bar, b, 1, mf, manifest.NoopLogWriter, exportOptions{checksum: "sha256"})
|
||||||
|
if done != 0 || failed != 1 {
|
||||||
|
t.Fatalf("done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportPendingParallelCancel(t *testing.T) {
|
func TestExportPendingParallelCancel(t *testing.T) {
|
||||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
||||||
b.Cancel()
|
b.Cancel()
|
||||||
@@ -2804,6 +2831,58 @@ func TestExportAssetsManifestWrite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportAssetsChecksum(t *testing.T) {
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
||||||
|
dir := t.TempDir()
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
if err := os.WriteFile(filepath.Join(out, "img.jpg"), []byte("abc"), 0644); err != nil {
|
||||||
|
return photos.ExportResult{}, err
|
||||||
|
}
|
||||||
|
return photos.ExportResult{Filename: "img.jpg", Size: 3, Cloud: "local"}, nil
|
||||||
|
}
|
||||||
|
done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
|
||||||
|
if done != 1 || failed != 0 {
|
||||||
|
t.Fatalf("done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
entry := manifest.LoadJSONL(dir).Entries()["x1"]
|
||||||
|
if entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
|
||||||
|
t.Fatalf("checksum=%q", entry.Checksum)
|
||||||
|
}
|
||||||
|
got, err := fileSHA256(filepath.Join(dir, "img.jpg"))
|
||||||
|
if err != nil || got != entry.Checksum {
|
||||||
|
t.Fatalf("fileSHA256 got=%q err=%v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := fileSHA256(filepath.Join(dir, "missing.jpg")); err == nil {
|
||||||
|
t.Fatal("expected missing checksum error")
|
||||||
|
}
|
||||||
|
if _, err := fileSHA256(dir); err == nil {
|
||||||
|
t.Fatal("expected directory checksum error")
|
||||||
|
}
|
||||||
|
if err := addManifestEntryForResult(nil, pendingAsset{}, photos.ExportResult{}, exportOptions{}); err != nil {
|
||||||
|
t.Fatalf("nil manifest add error: %v", err)
|
||||||
|
}
|
||||||
|
if err := addManifestEntryForResult(manifest.LoadJSONL(dir), pendingAsset{asset: photos.Asset{ID: "skip"}, path: dir}, photos.ExportResult{Filename: "missing.jpg", Skipped: true}, exportOptions{checksum: "sha256"}); err != nil {
|
||||||
|
t.Fatalf("skipped checksum add error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportAssetsChecksumErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil
|
||||||
|
}
|
||||||
|
done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
|
||||||
|
if done != 0 || failed != 1 {
|
||||||
|
t.Fatalf("serial checksum error done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
dir = t.TempDir()
|
||||||
|
done, failed = exportAssets(b.assets, dir, 1024, 85, 2, false, 2, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
|
||||||
|
if done != 0 || failed != 1 {
|
||||||
|
t.Fatalf("parallel checksum error done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackupTreeManifestOpenErr(t *testing.T) {
|
func TestBackupTreeManifestOpenErr(t *testing.T) {
|
||||||
b := &mockBridge{
|
b := &mockBridge{
|
||||||
tree: []photos.CollectionNode{
|
tree: []photos.CollectionNode{
|
||||||
@@ -4204,6 +4283,277 @@ func TestMoreIntegrityBranches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVerifyDeepChecksums(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "good.jpg"), []byte("abc"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "bad.jpg"), []byte("bad"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "nocheck.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "adir"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := manifest.LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(manifest.Entry{ID: "good", Filename: "good.jpg", Path: "good.jpg", Size: 3, Cloud: "local", Checksum: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "bad", Filename: "bad.jpg", Path: "bad.jpg", Size: 3, Cloud: "local", Checksum: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "nocheck", Filename: "nocheck.jpg", Path: "nocheck.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "unreadable", Filename: "adir", Path: "adir", Cloud: "local", Checksum: "abc", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
out, stderr, rc := runWith([]string{"verify", "--out", dir, "--deep"}, &mockBridge{})
|
||||||
|
if rc != exitPartial || stderr != "" || !strings.Contains(out, "checksum-mismatch") || !strings.Contains(out, "checksum-unreadable") || strings.Contains(out, "good.jpg") || strings.Contains(out, "nocheck") {
|
||||||
|
t.Fatalf("deep verify rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"verify", "--out", dir}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" || strings.Contains(out, "checksum") {
|
||||||
|
t.Fatalf("plain verify rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestRepair(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("abc"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
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: 0, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "missing", Filename: "missing.jpg", Path: "missing.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "x1\tphoto.jpg\trepaired") || !strings.Contains(out, "skipped\t1") {
|
||||||
|
t.Fatalf("dry repair rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
if got := manifest.LoadJSONL(dir).Entries()["x1"].Checksum; got != "" {
|
||||||
|
t.Fatalf("dry run wrote checksum %q", got)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t1") {
|
||||||
|
t.Fatalf("repair rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
entry := manifest.LoadJSONL(dir).Entries()["x1"]
|
||||||
|
if entry.Size != 3 || entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
|
||||||
|
t.Fatalf("entry not repaired: %+v", entry)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t0") {
|
||||||
|
t.Fatalf("second repair rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestRepairErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, stderr, rc := runWith([]string{"manifest"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "expected manifest repair") {
|
||||||
|
t.Fatalf("manifest missing subcommand rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"manifest", "repair"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "--out") {
|
||||||
|
t.Fatalf("manifest repair missing out rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "bad"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "--checksum") {
|
||||||
|
t.Fatalf("manifest repair bad checksum rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--manifest", "bad"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "manifest") {
|
||||||
|
t.Fatalf("manifest repair bad manifest rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
fileOut := filepath.Join(dir, "file-out")
|
||||||
|
if err := os.WriteFile(fileOut, []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", fileOut}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "error:") {
|
||||||
|
t.Fatalf("manifest repair open append rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
badDBDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", badDBDir}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "error:") {
|
||||||
|
t.Fatalf("manifest repair open rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
saveDir := t.TempDir()
|
||||||
|
sm := manifest.LoadJSONL(saveDir)
|
||||||
|
if err := sm.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(saveDir, "photo.jpg"), []byte("abc"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sm.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Exported: time.Now().Unix()})
|
||||||
|
sm.Close()
|
||||||
|
oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("save") })
|
||||||
|
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", saveDir}, &mockBridge{})
|
||||||
|
manifest.SetJSONLSaveHook(oldHook)
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "save") {
|
||||||
|
t.Fatalf("manifest repair save rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestRepairBranches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := manifest.LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(manifest.Entry{ID: "empty"})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "stat-only", Filename: "missing.jpg", Path: "missing.jpg", Checksum: "", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "hash-fail", Filename: "hash.jpg", Path: "hash.jpg", Checksum: "", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
oldStat := statFunc
|
||||||
|
statFunc = func(path string) (os.FileInfo, error) {
|
||||||
|
if strings.HasSuffix(path, "hash.jpg") {
|
||||||
|
return testFileInfo{size: 3}, nil
|
||||||
|
}
|
||||||
|
return oldStat(path)
|
||||||
|
}
|
||||||
|
out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{})
|
||||||
|
statFunc = oldStat
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t3") {
|
||||||
|
t.Fatalf("manifest repair branches rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "orphan.jpg", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
|
||||||
|
full := filepath.Join(dir, path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := manifest.LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(manifest.Entry{ID: "x1", Filename: "keep.jpg", Path: "keep.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "fallback", Filename: "fallback.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "empty", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "badrel", Filename: "../bad.jpg", Path: "../bad.jpg", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "fallback.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, stderr, rc := runWith([]string{"cleanup", "--out", dir, "--dry-run"}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "orphan.jpg\torphan") || !strings.Contains(out, "removed\t1") {
|
||||||
|
t.Fatalf("cleanup dry rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); err != nil {
|
||||||
|
t.Fatalf("dry-run removed orphan: %v", err)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "removed\t1") {
|
||||||
|
t.Fatalf("cleanup rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("orphan still exists or bad error: %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "fallback.jpg", "downloads.jsonl", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, path)); err != nil {
|
||||||
|
t.Fatalf("kept file missing %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, stderr, rc := runWith([]string{"cleanup"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "--out") {
|
||||||
|
t.Fatalf("cleanup missing out rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"cleanup", "--out", dir, "--manifest", "bad"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "manifest") {
|
||||||
|
t.Fatalf("cleanup bad manifest rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
badDBDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"cleanup", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "error:") {
|
||||||
|
t.Fatalf("cleanup load error rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"cleanup", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" {
|
||||||
|
t.Fatalf("cleanup missing root rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
dir = t.TempDir()
|
||||||
|
oldRemove := removeFunc
|
||||||
|
removeFunc = func(string) error { return fmt.Errorf("remove") }
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "orphan.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, stderr, rc := runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
|
||||||
|
removeFunc = oldRemove
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t1") {
|
||||||
|
t.Fatalf("cleanup remove error rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctor(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()
|
||||||
|
out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{})
|
||||||
|
if rc != exitOK || !strings.Contains(stderr, "access granted") || !strings.Contains(out, "backup_dir\tok") || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "problems\t0") {
|
||||||
|
t.Fatalf("doctor rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"doctor", "--out", dir, "--json"}, &mockBridge{})
|
||||||
|
if rc != exitOK || !strings.Contains(out, `"entries":1`) || !strings.Contains(out, `"problems":0`) {
|
||||||
|
t.Fatalf("doctor json rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorProblems(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}}, fmt.Errorf("failed"))
|
||||||
|
out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{accessErr: fmt.Errorf("denied")})
|
||||||
|
if rc != exitPartial || !strings.Contains(stderr, "denied") || !strings.Contains(out, "photos_access\tdenied") || !strings.Contains(out, "failures\t1") {
|
||||||
|
t.Fatalf("doctor problems rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"doctor", "--out", dir, "--manifest", "bad"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "manifest") {
|
||||||
|
t.Fatalf("doctor bad manifest rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"doctor", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
|
||||||
|
if rc != exitPartial || !strings.Contains(out, "backup_dir\tmissing") {
|
||||||
|
t.Fatalf("doctor missing dir rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
badDBDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"doctor", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
|
||||||
|
if rc != exitPartial || !strings.Contains(out, "manifest\terror") {
|
||||||
|
t.Fatalf("doctor manifest error rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
stderrBuf := &bytes.Buffer{}
|
||||||
|
if rc := cmdDoctor([]string{"--json"}, errWriter{}, stderrBuf, &mockBridge{}); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") {
|
||||||
|
t.Fatalf("doctor json writer error rc=%d stderr=%q", rc, stderrBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVerifySidecarBranches(t *testing.T) {
|
func TestVerifySidecarBranches(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
subdir := filepath.Join(dir, "sub")
|
subdir := filepath.Join(dir, "sub")
|
||||||
@@ -4385,6 +4735,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) {
|
func TestSidecarExportIntegration(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
date := "2024-01-02T03:04:05Z"
|
date := "2024-01-02T03:04:05Z"
|
||||||
@@ -4395,7 +4791,7 @@ func TestSidecarExportIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
|
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 {
|
if exported != 1 || failed != 0 {
|
||||||
t.Fatalf("exported=%d failed=%d", exported, failed)
|
t.Fatalf("exported=%d failed=%d", exported, failed)
|
||||||
}
|
}
|
||||||
@@ -4412,6 +4808,13 @@ func TestSidecarExportIntegration(t *testing.T) {
|
|||||||
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
|
||||||
t.Fatal("sidecar should use basename, not double extension")
|
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) {
|
func TestSidecarReverseGeocodeCache(t *testing.T) {
|
||||||
@@ -4584,6 +4987,30 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
|||||||
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
||||||
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
|
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())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if _, ok := parseExportOptions([]string{"--xmp-keywords", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-keywords") {
|
||||||
|
t.Fatalf("expected xmp keywords validation error, stderr=%q", stderr.String())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if _, ok := parseExportOptions([]string{"--xmp-rating", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-rating") {
|
||||||
|
t.Fatalf("expected xmp rating validation error, stderr=%q", stderr.String())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if _, ok := parseExportOptions([]string{"--checksum", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--checksum") {
|
||||||
|
t.Fatalf("expected checksum validation error, stderr=%q", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
||||||
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
@@ -4591,10 +5018,96 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
oldRename := renameFunc
|
oldRename := renameFunc
|
||||||
renameFunc = func(string, string) error { return fmt.Errorf("sidecar rename") }
|
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
|
renameFunc = oldRename
|
||||||
if exported != 0 || failed != 1 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXMPSidecarPrivacy(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
asset := photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}
|
||||||
|
bridge := &mockBridge{}
|
||||||
|
bridge.reverseGeocodeFn = func(float64, float64) (photos.Placemark, error) {
|
||||||
|
return photos.Placemark{Country: "Sweden", Locality: "Stockholm"}, nil
|
||||||
|
}
|
||||||
|
pa := pendingAsset{asset: asset, root: dir, path: dir, album: "Album"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
privacy string
|
||||||
|
wantGPS bool
|
||||||
|
wantAddress bool
|
||||||
|
}{
|
||||||
|
{privacy: "keep", wantGPS: true, wantAddress: true},
|
||||||
|
{privacy: "strip-address", wantGPS: true, wantAddress: false},
|
||||||
|
{privacy: "strip-location", wantGPS: false, wantAddress: false},
|
||||||
|
} {
|
||||||
|
path := filepath.Join(dir, tc.privacy+".jpg")
|
||||||
|
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filepath.Base(path), Size: 4}, false, exportOptions{sidecar: "xmp", reverseGeocode: true, xmpPrivacy: tc.privacy}, newGeocodeCache(dir), bridge); err != nil {
|
||||||
|
t.Fatalf("%s write sidecar: %v", tc.privacy, err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(sidecarPath(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if strings.Contains(content, "photoscli:latitude") != tc.wantGPS {
|
||||||
|
t.Fatalf("%s GPS presence mismatch in %s", tc.privacy, content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "photoscli:addressCountry") != tc.wantAddress {
|
||||||
|
t.Fatalf("%s address presence mismatch in %s", tc.privacy, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXMPSidecarKeywordAndRatingOptions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
asset := photos.Asset{ID: "x1", Filename: "photo.jpg", IsFavorite: true}
|
||||||
|
pa := pendingAsset{asset: asset, root: dir, path: filepath.Join(dir, "Trips", "Beach"), album: "Beach"}
|
||||||
|
if err := os.MkdirAll(pa.path, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
keywords string
|
||||||
|
rating string
|
||||||
|
wantTrip bool
|
||||||
|
wantBeach bool
|
||||||
|
wantRate bool
|
||||||
|
}{
|
||||||
|
{name: "default", wantTrip: true, wantBeach: true, wantRate: true},
|
||||||
|
{name: "album", keywords: "album", wantTrip: false, wantBeach: true, wantRate: true},
|
||||||
|
{name: "none", keywords: "none", rating: "none", wantTrip: false, wantBeach: false, wantRate: false},
|
||||||
|
} {
|
||||||
|
filename := tc.name + ".jpg"
|
||||||
|
path := filepath.Join(pa.path, filename)
|
||||||
|
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filename, Size: 4}, false, exportOptions{sidecar: "xmp", xmpKeywords: tc.keywords, xmpRating: tc.rating}, nil, &mockBridge{}); err != nil {
|
||||||
|
t.Fatalf("%s write sidecar: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(sidecarPath(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if strings.Contains(content, "<rdf:li>Trips</rdf:li>") != tc.wantTrip {
|
||||||
|
t.Fatalf("%s Trips keyword mismatch in %s", tc.name, content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "<rdf:li>Beach</rdf:li>") != tc.wantBeach {
|
||||||
|
t.Fatalf("%s Beach keyword mismatch in %s", tc.name, content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "xmp:Rating=\"5\"") != tc.wantRate {
|
||||||
|
t.Fatalf("%s rating mismatch in %s", tc.name, content)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4631,7 +5144,7 @@ func TestMetadataOnlyExportErrors(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
||||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b)
|
_, 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)
|
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)
|
_, stderr, rc = runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, b)
|
||||||
@@ -4859,7 +5372,7 @@ func TestInjectedErrorBranchesForCoverage(t *testing.T) {
|
|||||||
removeFunc = oldRemove
|
removeFunc = oldRemove
|
||||||
|
|
||||||
mf := &mockManifest{}
|
mf := &mockManifest{}
|
||||||
addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1})
|
addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1}, "")
|
||||||
if mf.last.Path != "file.jpg" {
|
if mf.last.Path != "file.jpg" {
|
||||||
t.Fatalf("expected fallback rel path, got %+v", mf.last)
|
t.Fatalf("expected fallback rel path, got %+v", mf.last)
|
||||||
}
|
}
|
||||||
@@ -4943,7 +5456,8 @@ func (m *mockManifest) Has(string) bool { return false }
|
|||||||
func (m *mockManifest) Add(id string, filename string, size int64, cloud string) {
|
func (m *mockManifest) Add(id string, filename string, size int64, cloud string) {
|
||||||
m.last = manifest.NewEntry(id, filename, filename, size, cloud)
|
m.last = manifest.NewEntry(id, filename, filename, size, cloud)
|
||||||
}
|
}
|
||||||
func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
|
func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
|
||||||
func (m *mockManifest) Save() error { return nil }
|
func (m *mockManifest) Save() error { return nil }
|
||||||
func (m *mockManifest) Close() {}
|
func (m *mockManifest) Close() {}
|
||||||
func (m *mockManifest) OpenAppend() error { return nil }
|
func (m *mockManifest) OpenAppend() error { return nil }
|
||||||
|
func (m *mockManifest) Entries() map[string]manifest.Entry { return nil }
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestNewEntryPath(t *testing.T) {
|
|
||||||
e := newEntry("id1", "file.jpg", 123, "local")
|
|
||||||
if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 {
|
|
||||||
t.Fatalf("unexpected entry: %+v", e)
|
|
||||||
}
|
|
||||||
e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "cloud")
|
|
||||||
if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" {
|
|
||||||
t.Fatalf("unexpected entry with path: %+v", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddEntryDefaultsPath(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
jm := LoadJSONL(dir)
|
|
||||||
if err := jm.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
jm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local"})
|
|
||||||
jm.Close()
|
|
||||||
if got := LoadJSONL(dir).Entries()["x1"].Path; got != "file.jpg" {
|
|
||||||
t.Fatalf("jsonl path = %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
sdir := t.TempDir()
|
|
||||||
sm, err := LoadSQLite(sdir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := sm.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local"})
|
|
||||||
if _, err := sm.db.Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := sm.Entries()["x1"].Path; got != "file.jpg" {
|
|
||||||
t.Fatalf("sqlite path = %q", got)
|
|
||||||
}
|
|
||||||
sm.Close()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package jsonl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Adapter struct{}
|
||||||
|
|
||||||
|
func (Adapter) Format() types.Format { return types.FormatJSONL }
|
||||||
|
func (Adapter) Aliases() []string { return []string{"json"} }
|
||||||
|
func (Adapter) Path(dir string) string { return Path(dir) }
|
||||||
|
func (Adapter) Exists(dir string) bool { return FileExists(Path(dir)) }
|
||||||
|
func (Adapter) Open(dir string) (types.Manifest, error) { return Load(dir), nil }
|
||||||
|
|
||||||
|
func (Adapter) OpenLogWriter(_ types.Manifest, dir string) (types.LogWriter, error) {
|
||||||
|
return types.NewFileLogWriter(types.LogPath(dir))
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package jsonl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdapter(t *testing.T) {
|
||||||
|
a := Adapter{}
|
||||||
|
if a.Format() != types.FormatJSONL {
|
||||||
|
t.Fatal("expected JSONL format")
|
||||||
|
}
|
||||||
|
if len(a.Aliases()) != 1 || a.Aliases()[0] != "json" {
|
||||||
|
t.Fatal("expected json alias")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if a.Path(dir) != Path(dir) {
|
||||||
|
t.Fatal("expected path match")
|
||||||
|
}
|
||||||
|
if a.Exists(dir) {
|
||||||
|
t.Fatal("expected not to exist in empty dir")
|
||||||
|
}
|
||||||
|
m, err := a.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
w, err := a.OpenLogWriter(nil, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadEmpty(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected non-nil store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadNonexistent(t *testing.T) {
|
||||||
|
m := Load("/nonexistent/path")
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected non-nil store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreAddAndHas(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if m.Has("x") {
|
||||||
|
t.Fatal("expected Has to return false")
|
||||||
|
}
|
||||||
|
m.Add("x", "photo.jpg", 42, "s3")
|
||||||
|
if !m.Has("x") {
|
||||||
|
t.Fatal("expected Has to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSaveAndReload(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("id1", "file1.jpg", 10, "aws")
|
||||||
|
m.Add("id2", "file2.jpg", 20, "gcs")
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
|
||||||
|
m2 := Load(dir)
|
||||||
|
if !m2.Has("id1") {
|
||||||
|
t.Fatal("expected id1 after reload")
|
||||||
|
}
|
||||||
|
if !m2.Has("id2") {
|
||||||
|
t.Fatal("expected id2 after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendCreatesDirs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
subdir := dir + "/a/b"
|
||||||
|
m := Load(subdir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCloseIdempotent(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendIdempotent(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntries(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
m.Add("e1", "f1.jpg", 1, "c1")
|
||||||
|
m.Add("e2", "f2.jpg", 2, "c2")
|
||||||
|
entries := m.Entries()
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreManifestFormat(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if m.ManifestFormat() != types.FormatJSONL {
|
||||||
|
t.Fatal("expected JSONL format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendMkdirError(t *testing.T) {
|
||||||
|
m := Load("/proc/cannot-create-dir-here")
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSaveWithNoFile(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadFromExistingFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("abc", "a.jpg", 100, "gcs")
|
||||||
|
m.Close()
|
||||||
|
m2 := Load(dir)
|
||||||
|
if !m2.Has("abc") {
|
||||||
|
t.Fatal("expected abc after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileExists(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if FileExists(dir) {
|
||||||
|
t.Fatal("expected false for directory")
|
||||||
|
}
|
||||||
|
if FileExists("/nonexistent/file") {
|
||||||
|
t.Fatal("expected false for nonexistent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadWithPathFallback(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := Path(dir)
|
||||||
|
osWriteFile(path, []byte(`{"id":"abc","filename":"a.jpg","size":100,"cloud":"gcs","exported":1234}`+"\n"), 0644)
|
||||||
|
m := Load(dir)
|
||||||
|
if !m.Has("abc") {
|
||||||
|
t.Fatal("expected abc")
|
||||||
|
}
|
||||||
|
if e := m.Entries()["abc"]; e.Path != "a.jpg" {
|
||||||
|
t.Fatalf("expected path fallback to filename, got %q", e.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAppendAlreadyOpen(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAppendOpenFileError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.MkdirAll(Path(dir), 0755)
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Fatal("expected error when path is a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAppendMkdirError(t *testing.T) {
|
||||||
|
m := Load("/proc/cannot-create-dir-here")
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var osWriteFile = os.WriteFile
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package manifest
|
package jsonl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -6,32 +6,34 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type jsonlManifest struct {
|
type Store struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
entries map[string]Entry
|
entries map[string]types.Entry
|
||||||
path string
|
path string
|
||||||
file *os.File
|
file *os.File
|
||||||
syncFunc func() error
|
SyncFunc func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsonlSaveHook func() error
|
var saveHook func() error
|
||||||
|
|
||||||
func SetJSONLSaveHook(fn func() error) func() error {
|
func SetSaveHook(fn func() error) func() error {
|
||||||
old := jsonlSaveHook
|
old := saveHook
|
||||||
jsonlSaveHook = fn
|
saveHook = fn
|
||||||
return old
|
return old
|
||||||
}
|
}
|
||||||
|
|
||||||
func JSONLPath(dir string) string {
|
func Path(dir string) string {
|
||||||
return filepath.Join(dir, "downloads.jsonl")
|
return filepath.Join(dir, "downloads.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadJSONL(dir string) *jsonlManifest {
|
func Load(dir string) *Store {
|
||||||
m := &jsonlManifest{
|
m := &Store{
|
||||||
entries: make(map[string]Entry),
|
entries: make(map[string]types.Entry),
|
||||||
path: JSONLPath(dir),
|
path: Path(dir),
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(m.path)
|
data, err := os.ReadFile(m.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,6 +45,7 @@ func LoadJSONL(dir string) *jsonlManifest {
|
|||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Cloud string `json:"cloud"`
|
Cloud string `json:"cloud"`
|
||||||
|
Checksum string `json:"checksum,omitempty"`
|
||||||
Exported int64 `json:"exported"`
|
Exported int64 `json:"exported"`
|
||||||
}
|
}
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
@@ -55,12 +58,13 @@ func LoadJSONL(dir string) *jsonlManifest {
|
|||||||
if raw.Path == "" {
|
if raw.Path == "" {
|
||||||
raw.Path = raw.Filename
|
raw.Path = raw.Filename
|
||||||
}
|
}
|
||||||
m.entries[raw.ID] = Entry{
|
m.entries[raw.ID] = types.Entry{
|
||||||
ID: raw.ID,
|
ID: raw.ID,
|
||||||
Filename: raw.Filename,
|
Filename: raw.Filename,
|
||||||
Path: raw.Path,
|
Path: raw.Path,
|
||||||
Size: raw.Size,
|
Size: raw.Size,
|
||||||
Cloud: raw.Cloud,
|
Cloud: raw.Cloud,
|
||||||
|
Checksum: raw.Checksum,
|
||||||
Exported: raw.Exported,
|
Exported: raw.Exported,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,18 +72,22 @@ func LoadJSONL(dir string) *jsonlManifest {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Has(id string) bool {
|
func (m *Store) Has(id string) bool {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
_, ok := m.entries[id]
|
_, ok := m.entries[id]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) {
|
func (m *Store) ManifestFormat() types.Format {
|
||||||
m.AddEntry(newEntry(id, filename, size, cloud))
|
return types.FormatJSONL
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) AddEntry(entry Entry) {
|
func (m *Store) Add(id string, filename string, size int64, cloud string) {
|
||||||
|
m.AddEntry(types.NewEntry(id, filename, filename, size, cloud))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Store) AddEntry(entry types.Entry) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if entry.Path == "" {
|
if entry.Path == "" {
|
||||||
@@ -93,21 +101,22 @@ func (m *jsonlManifest) AddEntry(entry Entry) {
|
|||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Cloud string `json:"cloud"`
|
Cloud string `json:"cloud"`
|
||||||
|
Checksum string `json:"checksum,omitempty"`
|
||||||
Exported int64 `json:"exported"`
|
Exported int64 `json:"exported"`
|
||||||
}{ID: entry.ID, Filename: entry.Filename, Path: entry.Path, Size: entry.Size, Cloud: entry.Cloud, Exported: entry.Exported})
|
}{ID: entry.ID, Filename: entry.Filename, Path: entry.Path, Size: entry.Size, Cloud: entry.Cloud, Checksum: entry.Checksum, Exported: entry.Exported})
|
||||||
m.file.Write(data)
|
m.file.Write(data)
|
||||||
m.file.Write([]byte("\n"))
|
m.file.Write([]byte("\n"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Save() error {
|
func (m *Store) Save() error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.syncFunc != nil {
|
if m.SyncFunc != nil {
|
||||||
return m.syncFunc()
|
return m.SyncFunc()
|
||||||
}
|
}
|
||||||
if jsonlSaveHook != nil {
|
if saveHook != nil {
|
||||||
return jsonlSaveHook()
|
return saveHook()
|
||||||
}
|
}
|
||||||
if m.file != nil {
|
if m.file != nil {
|
||||||
return m.file.Sync()
|
return m.file.Sync()
|
||||||
@@ -115,7 +124,7 @@ func (m *jsonlManifest) Save() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Close() {
|
func (m *Store) Close() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.file != nil {
|
if m.file != nil {
|
||||||
@@ -124,7 +133,7 @@ func (m *jsonlManifest) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) OpenAppend() error {
|
func (m *Store) OpenAppend() error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.file != nil {
|
if m.file != nil {
|
||||||
@@ -141,12 +150,20 @@ func (m *jsonlManifest) OpenAppend() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Entries() map[string]Entry {
|
func (m *Store) Entries() map[string]types.Entry {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
out := make(map[string]Entry, len(m.entries))
|
out := make(map[string]types.Entry, len(m.entries))
|
||||||
for k, v := range m.entries {
|
for k, v := range m.entries {
|
||||||
out[k] = v
|
out[k] = v
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !info.IsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package jsonl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetSaveHook(t *testing.T) {
|
||||||
|
old := SetSaveHook(func() error { return fmt.Errorf("hook error") })
|
||||||
|
if old != nil {
|
||||||
|
t.Error("expected nil old hook")
|
||||||
|
}
|
||||||
|
restore := SetSaveHook(old)
|
||||||
|
if restore == nil {
|
||||||
|
t.Error("expected non-nil restore function")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveHookError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
old := SetSaveHook(func() error { return fmt.Errorf("hook error") })
|
||||||
|
defer SetSaveHook(old)
|
||||||
|
if err := m.Save(); err == nil {
|
||||||
|
t.Error("expected hook error from Save")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncFuncError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
m.SyncFunc = func() error { return fmt.Errorf("sync func error") }
|
||||||
|
if err := m.Save(); err == nil {
|
||||||
|
t.Error("expected syncFunc error from Save")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
m.file.Close()
|
||||||
|
if err := m.Save(); err == nil {
|
||||||
|
t.Error("expected Sync error on closed file")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEntryDefaultsPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(types.Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"})
|
||||||
|
m.Close()
|
||||||
|
loaded := Load(dir).Entries()["x1"]
|
||||||
|
if got := loaded.Path; got != "file.jpg" {
|
||||||
|
t.Fatalf("jsonl path = %q", got)
|
||||||
|
}
|
||||||
|
if loaded.Checksum != "sha256:abc" {
|
||||||
|
t.Fatalf("jsonl checksum = %q", loaded.Checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
type LogEntry struct {
|
|
||||||
Timestamp int64 `json:"ts"`
|
|
||||||
Level string `json:"level"`
|
|
||||||
Event string `json:"event"`
|
|
||||||
AssetID string `json:"asset_id,omitempty"`
|
|
||||||
Album string `json:"album,omitempty"`
|
|
||||||
Filename string `json:"filename,omitempty"`
|
|
||||||
Size int64 `json:"size,omitempty"`
|
|
||||||
Cloud string `json:"cloud,omitempty"`
|
|
||||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogWriter interface {
|
|
||||||
Log(entry LogEntry)
|
|
||||||
Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
type noopLogWriter struct{}
|
|
||||||
|
|
||||||
func (noopLogWriter) Log(LogEntry) { _ = struct{}{} }
|
|
||||||
func (noopLogWriter) Close() { _ = struct{}{} }
|
|
||||||
|
|
||||||
var NoopLogWriter LogWriter = noopLogWriter{}
|
|
||||||
|
|
||||||
func LogPath(dir string) string {
|
|
||||||
return dir + "/export.log"
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNoopLogWriter(t *testing.T) {
|
|
||||||
lw := NoopLogWriter
|
|
||||||
lw.Log(LogEntry{Event: "test"})
|
|
||||||
lw.Close()
|
|
||||||
noopLogWriter{}.Log(LogEntry{Event: "test"})
|
|
||||||
noopLogWriter{}.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSQLiteLogWriter(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(dir, "test.db")
|
|
||||||
db, err := sql.Open("sqlite", dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
lw, err := NewSQLiteLogWriter(db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lw.Log(LogEntry{
|
|
||||||
Timestamp: 1700000000,
|
|
||||||
Level: "info",
|
|
||||||
Event: "export_done",
|
|
||||||
AssetID: "asset-1",
|
|
||||||
Album: "Favorites",
|
|
||||||
Filename: "photo.jpg",
|
|
||||||
Size: 1024,
|
|
||||||
Cloud: "local",
|
|
||||||
DurationMs: 500,
|
|
||||||
Message: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
lw.Log(LogEntry{
|
|
||||||
Timestamp: 1700000001,
|
|
||||||
Level: "error",
|
|
||||||
Event: "export_fail",
|
|
||||||
AssetID: "asset-2",
|
|
||||||
Filename: "bad.jpg",
|
|
||||||
Message: "timeout",
|
|
||||||
})
|
|
||||||
|
|
||||||
lw.Close()
|
|
||||||
|
|
||||||
var count int
|
|
||||||
err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if count != 2 {
|
|
||||||
t.Errorf("expected 2 log entries, got %d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
var event, level, assetID string
|
|
||||||
err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if event != "export_done" || level != "info" || assetID != "asset-1" {
|
|
||||||
t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteLogWriterNilDB(t *testing.T) {
|
|
||||||
w := &sqliteLogWriter{db: nil}
|
|
||||||
w.Log(LogEntry{Event: "test"})
|
|
||||||
w.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSQLiteLogWriterError(t *testing.T) {
|
|
||||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := db.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := NewSQLiteLogWriter(db); err == nil {
|
|
||||||
t.Error("expected error for closed db")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteLogWriterCloseConcrete(t *testing.T) {
|
|
||||||
(&sqliteLogWriter{}).Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewFileLogWriter(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "export.log")
|
|
||||||
lw, err := NewFileLogWriter(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lw.Log(LogEntry{
|
|
||||||
Timestamp: 1700000000,
|
|
||||||
Level: "info",
|
|
||||||
Event: "export_done",
|
|
||||||
Filename: "photo.jpg",
|
|
||||||
Size: 2048,
|
|
||||||
})
|
|
||||||
|
|
||||||
lw.Close()
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
t.Error("expected log data")
|
|
||||||
}
|
|
||||||
if data[len(data)-1] != '\n' {
|
|
||||||
t.Error("expected trailing newline")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileLogWriterClosed(t *testing.T) {
|
|
||||||
w := &fileLogWriter{f: nil}
|
|
||||||
w.Log(LogEntry{Event: "test"})
|
|
||||||
w.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewFileLogWriterError(t *testing.T) {
|
|
||||||
_, err := NewFileLogWriter("/nonexistent/dir/export.log")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for bad path")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileLogWriterDoubleClose(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "export.log")
|
|
||||||
lw, err := NewFileLogWriter(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lw.Close()
|
|
||||||
lw.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogPath(t *testing.T) {
|
|
||||||
p := LogPath("/tmp/out")
|
|
||||||
if p != "/tmp/out/export.log" {
|
|
||||||
t.Errorf("expected /tmp/out/export.log, got %s", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenLogWriterSQLite(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer m.Close()
|
|
||||||
|
|
||||||
lw, err := OpenLogWriter(m, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
|
||||||
lw.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenLogWriterJSONL(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer m.Close()
|
|
||||||
|
|
||||||
lw, err := OpenLogWriter(m, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
|
||||||
lw.Close()
|
|
||||||
|
|
||||||
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
|
|
||||||
t.Error("expected export.log to exist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenLogWriterNilManifest(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
lw, err := OpenLogWriter(nil, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
|
||||||
lw.Close()
|
|
||||||
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
|
|
||||||
t.Error("expected export.log to exist for nil manifest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,37 @@
|
|||||||
package manifest
|
package manifest
|
||||||
|
|
||||||
import "time"
|
import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
|
||||||
type Entry struct {
|
type Entry = types.Entry
|
||||||
ID string
|
|
||||||
Filename string
|
|
||||||
Path string
|
|
||||||
Size int64
|
|
||||||
Cloud string
|
|
||||||
Exported int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type Manifest interface {
|
type Manifest = types.Manifest
|
||||||
Has(id string) bool
|
|
||||||
Add(id string, filename string, size int64, cloud string)
|
|
||||||
AddEntry(entry Entry)
|
|
||||||
Save() error
|
|
||||||
Close()
|
|
||||||
OpenAppend() error
|
|
||||||
}
|
|
||||||
|
|
||||||
type EntryReader interface {
|
type EntryReader = types.EntryReader
|
||||||
Entries() map[string]Entry
|
|
||||||
}
|
|
||||||
|
|
||||||
func newEntry(id, filename string, size int64, cloud string) Entry {
|
type Format = types.Format
|
||||||
return Entry{
|
|
||||||
ID: id,
|
|
||||||
Filename: filename,
|
|
||||||
Path: filename,
|
|
||||||
Size: size,
|
|
||||||
Cloud: cloud,
|
|
||||||
Exported: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEntry(id, filename, path string, size int64, cloud string) Entry {
|
const (
|
||||||
e := newEntry(id, filename, size, cloud)
|
FormatJSONL = types.FormatJSONL
|
||||||
if path != "" {
|
FormatSQLite = types.FormatSQLite
|
||||||
e.Path = path
|
)
|
||||||
}
|
|
||||||
return e
|
type LogEntry = types.LogEntry
|
||||||
}
|
|
||||||
|
type LogWriter = types.LogWriter
|
||||||
|
|
||||||
|
var NoopLogWriter = types.NoopLogWriter
|
||||||
|
|
||||||
|
type Adapter = types.Adapter
|
||||||
|
|
||||||
|
type Registry = types.Registry
|
||||||
|
|
||||||
|
type FormatReporter = types.FormatReporter
|
||||||
|
|
||||||
|
var (
|
||||||
|
NewRegistry = types.NewRegistry
|
||||||
|
NewEntry = types.NewEntry
|
||||||
|
NewEntryWithChecksum = types.NewEntryWithChecksum
|
||||||
|
LogPath = types.LogPath
|
||||||
|
ConvertManifest = types.ConvertManifest
|
||||||
|
NewFileLogWriter = types.NewFileLogWriter
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
|
||||||
|
type memoryStore struct {
|
||||||
|
entries map[string]types.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryStore() *memoryStore {
|
||||||
|
return &memoryStore{entries: make(map[string]types.Entry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Has(id string) bool {
|
||||||
|
_, ok := m.entries[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Add(id string, filename string, size int64, cloud string) {
|
||||||
|
m.AddEntry(types.NewEntry(id, filename, filename, size, cloud))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) AddEntry(entry types.Entry) {
|
||||||
|
if entry.Path == "" {
|
||||||
|
entry.Path = entry.Filename
|
||||||
|
}
|
||||||
|
m.entries[entry.ID] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Save() error { return nil }
|
||||||
|
func (m *memoryStore) Close() { _ = m }
|
||||||
|
|
||||||
|
func (m *memoryStore) OpenAppend() error { return nil }
|
||||||
|
|
||||||
|
func (m *memoryStore) Entries() map[string]types.Entry {
|
||||||
|
out := make(map[string]types.Entry, len(m.entries))
|
||||||
|
for k, v := range m.entries {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryAdapter struct{}
|
||||||
|
|
||||||
|
func (MemoryAdapter) Format() types.Format { return types.FormatJSONL }
|
||||||
|
func (MemoryAdapter) Aliases() []string { return nil }
|
||||||
|
func (MemoryAdapter) Path(string) string { return "" }
|
||||||
|
func (MemoryAdapter) Exists(string) bool { return false }
|
||||||
|
func (MemoryAdapter) Open(string) (types.Manifest, error) { return newMemoryStore(), nil }
|
||||||
|
|
||||||
|
func (MemoryAdapter) OpenLogWriter(types.Manifest, string) (types.LogWriter, error) {
|
||||||
|
return types.NoopLogWriter, nil
|
||||||
|
}
|
||||||
+17
-78
@@ -1,84 +1,21 @@
|
|||||||
package manifest
|
package manifest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Format string
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
|
||||||
const (
|
|
||||||
FormatJSONL Format = "jsonl"
|
|
||||||
FormatSQLite Format = "sqlite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Open(dir string, format Format) (Manifest, error) {
|
func Open(dir string, format Format) (Manifest, error) {
|
||||||
jsonlPath := JSONLPath(dir)
|
return Default.Open(dir, format)
|
||||||
sqlitePath := SQLitePath(dir)
|
|
||||||
jsonlExists := FileExists(jsonlPath)
|
|
||||||
sqliteExists := FileExists(sqlitePath)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case format == FormatJSONL && jsonlExists:
|
|
||||||
return LoadJSONL(dir), nil
|
|
||||||
case format == FormatSQLite && sqliteExists:
|
|
||||||
return LoadSQLite(dir)
|
|
||||||
case format == FormatJSONL && sqliteExists:
|
|
||||||
return ConvertFromSQLite(dir)
|
|
||||||
case format == FormatSQLite && jsonlExists:
|
|
||||||
return ConvertFromJSONL(dir)
|
|
||||||
default:
|
|
||||||
if format == FormatJSONL {
|
|
||||||
return LoadJSONL(dir), nil
|
|
||||||
}
|
|
||||||
return LoadSQLite(dir)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConvertFromJSONL(dir string) (Manifest, error) {
|
func ConvertFromJSONL(dir string) (Manifest, error) {
|
||||||
src := LoadJSONL(dir)
|
return types.ConvertManifest(dir, JSONLAdapter, SQLiteAdapter)
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open jsonl for read: %w", err)
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
dst, _ := LoadSQLite(dir)
|
|
||||||
if err := dst.OpenAppend(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open sqlite for write: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for id, e := range src.Entries() {
|
|
||||||
e.ID = id
|
|
||||||
dst.AddEntry(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Remove(JSONLPath(dir))
|
|
||||||
return dst, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConvertFromSQLite(dir string) (Manifest, error) {
|
func ConvertFromSQLite(dir string) (Manifest, error) {
|
||||||
src, _ := LoadSQLite(dir)
|
return types.ConvertManifest(dir, SQLiteAdapter, JSONLAdapter)
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open sqlite for read: %w", err)
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
dst := LoadJSONL(dir)
|
|
||||||
if err := dst.OpenAppend(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open jsonl for write: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for id, e := range src.Entries() {
|
|
||||||
e.ID = id
|
|
||||||
dst.AddEntry(e)
|
|
||||||
}
|
|
||||||
if err := dst.Save(); err != nil {
|
|
||||||
return nil, fmt.Errorf("save jsonl: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Remove(SQLitePath(dir))
|
|
||||||
return dst, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func FileExists(path string) bool {
|
func FileExists(path string) bool {
|
||||||
@@ -90,19 +27,21 @@ func FileExists(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ParseFormat(s string) (Format, error) {
|
func ParseFormat(s string) (Format, error) {
|
||||||
switch strings.ToLower(s) {
|
return Default.ParseFormat(s)
|
||||||
case "jsonl", "json":
|
|
||||||
return FormatJSONL, nil
|
|
||||||
case "sqlite", "db", "sqlite3":
|
|
||||||
return FormatSQLite, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenLogWriter(m Manifest, dir string) (LogWriter, error) {
|
func OpenLogWriter(m Manifest, dir string) (LogWriter, error) {
|
||||||
if sm, ok := m.(*sqliteManifest); ok && sm.DB() != nil {
|
format := FormatJSONL
|
||||||
return NewSQLiteLogWriter(sm.DB())
|
if reporter, ok := m.(types.FormatReporter); ok {
|
||||||
|
format = reporter.ManifestFormat()
|
||||||
}
|
}
|
||||||
return NewFileLogWriter(LogPath(dir))
|
return Default.OpenLogWriter(m, dir, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func osRemove(path string) error {
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
types.SetRemoveFunc(osRemove)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
jsonladapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/jsonl"
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/sqlite"
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
JSONLAdapter = jsonladapter.Adapter{}
|
||||||
|
SQLiteAdapter = sqlite.Adapter{}
|
||||||
|
)
|
||||||
|
|
||||||
|
var Default = types.NewRegistry(JSONLAdapter, SQLiteAdapter)
|
||||||
|
|
||||||
|
func LoadJSONL(dir string) *jsonladapter.Store {
|
||||||
|
return jsonladapter.Load(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func JSONLPath(dir string) string {
|
||||||
|
return jsonladapter.Path(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetJSONLSaveHook(fn func() error) func() error {
|
||||||
|
return jsonladapter.SetSaveHook(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadSQLite(dir string) (Manifest, error) {
|
||||||
|
return sqlite.Load(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SQLitePath(dir string) string {
|
||||||
|
return sqlite.Path(dir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
jsonladapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/jsonl"
|
||||||
|
sqliteadapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/sqlite"
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegistryParseFormatAliases(t *testing.T) {
|
||||||
|
r := Default
|
||||||
|
cases := map[string]Format{
|
||||||
|
"jsonl": FormatJSONL,
|
||||||
|
"json": FormatJSONL,
|
||||||
|
"sqlite": FormatSQLite,
|
||||||
|
"db": FormatSQLite,
|
||||||
|
"sqlite3": FormatSQLite,
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
got, err := r.ParseFormat(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFormat(%q): %v", input, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ParseFormat(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryParseFormatUnknown(t *testing.T) {
|
||||||
|
if _, err := Default.ParseFormat("bad"); err == nil {
|
||||||
|
t.Fatal("expected unknown format error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenConvertsExistingManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 12, "local")
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
|
||||||
|
converted, err := Default.Open(dir, FormatSQLite)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer converted.Close()
|
||||||
|
if !converted.Has("x1") {
|
||||||
|
t.Fatal("expected converted sqlite manifest to contain entry")
|
||||||
|
}
|
||||||
|
if FileExists(JSONLPath(dir)) {
|
||||||
|
t.Fatal("expected source jsonl manifest to be removed")
|
||||||
|
}
|
||||||
|
if !FileExists(SQLitePath(dir)) {
|
||||||
|
t.Fatal("expected sqlite manifest to exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryAdapterStore(t *testing.T) {
|
||||||
|
s := newMemoryStore()
|
||||||
|
if s.Has("x") {
|
||||||
|
t.Fatal("expected empty")
|
||||||
|
}
|
||||||
|
s.Add("x", "photo.jpg", 42, "local")
|
||||||
|
if !s.Has("x") {
|
||||||
|
t.Fatal("expected has after add")
|
||||||
|
}
|
||||||
|
s.AddEntry(Entry{ID: "y", Filename: "file.jpg"})
|
||||||
|
if e := s.Entries()["y"]; e.Path != "file.jpg" {
|
||||||
|
t.Fatal("expected default path")
|
||||||
|
}
|
||||||
|
if err := s.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
if err := s.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := MemoryAdapter{}
|
||||||
|
if adapter.Format() != FormatJSONL {
|
||||||
|
t.Fatal("expected JSONL format")
|
||||||
|
}
|
||||||
|
if len(adapter.Aliases()) != 0 {
|
||||||
|
t.Fatal("expected no aliases")
|
||||||
|
}
|
||||||
|
if adapter.Path("") != "" {
|
||||||
|
t.Fatal("expected empty path")
|
||||||
|
}
|
||||||
|
if adapter.Exists("") {
|
||||||
|
t.Fatal("expected not to exist")
|
||||||
|
}
|
||||||
|
m, err := adapter.Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
w, err := adapter.OpenLogWriter(nil, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenLogWriterUsesAdapter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Default.Open(dir, FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
w, err := Default.OpenLogWriter(m, dir, FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
if !FileExists(LogPath(dir)) {
|
||||||
|
t.Fatal("expected jsonl adapter to create file log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenCreatesRequestedAdapter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Default.Open(dir, FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
if _, ok := m.(*jsonladapter.Store); !ok {
|
||||||
|
t.Fatalf("expected jsonl store, got %T", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryUnknownAdapterErrors(t *testing.T) {
|
||||||
|
r := NewRegistry(JSONLAdapter)
|
||||||
|
if _, err := r.Open(t.TempDir(), FormatSQLite); err == nil {
|
||||||
|
t.Fatal("expected open error for unregistered format")
|
||||||
|
}
|
||||||
|
if _, err := r.OpenLogWriter(LoadJSONL(t.TempDir()), t.TempDir(), FormatSQLite); err == nil {
|
||||||
|
t.Fatal("expected log writer error for unregistered format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAdapterOpenLogWriterUsesSQLite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := LoadSQLite(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
w, err := SQLiteAdapter.OpenLogWriter(m, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
if _, ok := w.(*sqliteadapter.LogWriter); !ok {
|
||||||
|
t.Fatalf("expected sqlite log writer, got %T", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenLogWriterUsesManifestFormat(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := LoadSQLite(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
w, err := OpenLogWriter(m, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
if _, ok := w.(*sqliteadapter.LogWriter); !ok {
|
||||||
|
t.Fatalf("expected sqlite log writer, got %T", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAdapterOpenLogWriterFallsBackToFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
w, err := SQLiteAdapter.OpenLogWriter(LoadJSONL(dir), dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
if !FileExists(LogPath(dir)) {
|
||||||
|
t.Fatal("expected sqlite adapter fallback to create file log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetJSONLSaveHookWrapper(t *testing.T) {
|
||||||
|
old := SetJSONLSaveHook(func() error { return nil })
|
||||||
|
SetJSONLSaveHook(old)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestErrorBranches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := ConvertManifest(dir, failingAdapter{format: FormatJSONL, openErr: fmt.Errorf("boom")}, JSONLAdapter); err == nil {
|
||||||
|
t.Fatal("expected source open error")
|
||||||
|
}
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: badOpenManifest{}}, JSONLAdapter); err == nil {
|
||||||
|
t.Fatal("expected source OpenAppend error")
|
||||||
|
}
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: noReadManifest{}}, JSONLAdapter); err == nil {
|
||||||
|
t.Fatal("expected entry reader error")
|
||||||
|
}
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, failingAdapter{format: FormatSQLite, openErr: fmt.Errorf("boom")}); err == nil {
|
||||||
|
t.Fatal("expected destination open error")
|
||||||
|
}
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, staticAdapter{format: FormatSQLite, store: badOpenManifest{}}); err == nil {
|
||||||
|
t.Fatal("expected destination OpenAppend error")
|
||||||
|
}
|
||||||
|
oldRemove := types.RemoveFunc()
|
||||||
|
types.SetRemoveFunc(func(string) error { return fmt.Errorf("remove failed") })
|
||||||
|
defer func() { types.SetRemoveFunc(oldRemove) }()
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, staticAdapter{format: FormatSQLite, store: readableManifest{}}); err == nil {
|
||||||
|
t.Fatal("expected remove error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticAdapter struct {
|
||||||
|
format Format
|
||||||
|
store Manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a staticAdapter) Format() Format { return a.format }
|
||||||
|
func (a staticAdapter) Aliases() []string { return nil }
|
||||||
|
func (a staticAdapter) Path(string) string { return "manifest.file" }
|
||||||
|
func (a staticAdapter) Exists(string) bool { return true }
|
||||||
|
func (a staticAdapter) Open(string) (Manifest, error) { return a.store, nil }
|
||||||
|
func (a staticAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { return NoopLogWriter, nil }
|
||||||
|
|
||||||
|
type failingAdapter struct {
|
||||||
|
format Format
|
||||||
|
openErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a failingAdapter) Format() Format { return a.format }
|
||||||
|
func (a failingAdapter) Aliases() []string { return nil }
|
||||||
|
func (a failingAdapter) Path(string) string { return "manifest.file" }
|
||||||
|
func (a failingAdapter) Exists(string) bool { return true }
|
||||||
|
func (a failingAdapter) Open(string) (Manifest, error) { return nil, a.openErr }
|
||||||
|
func (a failingAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { return nil, a.openErr }
|
||||||
|
|
||||||
|
type readableManifest struct{}
|
||||||
|
|
||||||
|
func (readableManifest) Has(string) bool { return false }
|
||||||
|
func (readableManifest) Add(string, string, int64, string) {}
|
||||||
|
func (readableManifest) AddEntry(Entry) {}
|
||||||
|
func (readableManifest) Save() error { return nil }
|
||||||
|
func (readableManifest) Close() {}
|
||||||
|
func (readableManifest) OpenAppend() error { return nil }
|
||||||
|
func (readableManifest) Entries() map[string]Entry { return map[string]Entry{"x": {Filename: "x.jpg"}} }
|
||||||
|
|
||||||
|
type noReadManifest struct{}
|
||||||
|
|
||||||
|
func (noReadManifest) Has(string) bool { return false }
|
||||||
|
func (noReadManifest) Add(string, string, int64, string) {}
|
||||||
|
func (noReadManifest) AddEntry(Entry) {}
|
||||||
|
func (noReadManifest) Save() error { return nil }
|
||||||
|
func (noReadManifest) Close() {}
|
||||||
|
func (noReadManifest) OpenAppend() error { return nil }
|
||||||
|
func (noReadManifest) Entries() map[string]Entry { return nil }
|
||||||
|
|
||||||
|
type badOpenManifest struct{ readableManifest }
|
||||||
|
|
||||||
|
func (badOpenManifest) OpenAppend() error { return fmt.Errorf("open failed") }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Adapter struct{}
|
||||||
|
|
||||||
|
func (Adapter) Format() types.Format { return types.FormatSQLite }
|
||||||
|
func (Adapter) Aliases() []string { return []string{"db", "sqlite3"} }
|
||||||
|
func (Adapter) Path(dir string) string { return Path(dir) }
|
||||||
|
func (Adapter) Exists(dir string) bool { return FileExists(Path(dir)) }
|
||||||
|
func (Adapter) Open(dir string) (types.Manifest, error) { return Load(dir) }
|
||||||
|
func (Adapter) OpenLogWriter(m types.Manifest, dir string) (types.LogWriter, error) {
|
||||||
|
if sm, ok := m.(*Store); ok && sm.DB() != nil {
|
||||||
|
return NewLogWriter(sm.DB())
|
||||||
|
}
|
||||||
|
return types.NewFileLogWriter(types.LogPath(dir))
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdapter(t *testing.T) {
|
||||||
|
a := Adapter{}
|
||||||
|
if a.Format() != types.FormatSQLite {
|
||||||
|
t.Fatal("expected SQLite format")
|
||||||
|
}
|
||||||
|
if len(a.Aliases()) != 2 {
|
||||||
|
t.Fatal("expected 2 aliases")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if a.Path(dir) != Path(dir) {
|
||||||
|
t.Fatal("expected path match")
|
||||||
|
}
|
||||||
|
if a.Exists(dir) {
|
||||||
|
t.Fatal("expected not to exist in empty dir")
|
||||||
|
}
|
||||||
|
m, err := a.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
w, err := a.OpenLogWriter(nil, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterOpenLogWriterSQLite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
w, err := Adapter{}.OpenLogWriter(m, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
if _, ok := w.(*LogWriter); !ok {
|
||||||
|
t.Fatalf("expected sqlite log writer, got %T", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadEmpty(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected non-nil store")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadNonexistent(t *testing.T) {
|
||||||
|
m, err := Load("/nonexistent/path")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected non-nil store")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreAddHasSaveClose(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("sid1", "sfile.jpg", 99, "azure")
|
||||||
|
if !m.Has("sid1") {
|
||||||
|
t.Fatal("expected Has to return true")
|
||||||
|
}
|
||||||
|
if m.Has("nope") {
|
||||||
|
t.Fatal("expected Has to return false")
|
||||||
|
}
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("rid1", "rfile1.jpg", 10, "aws")
|
||||||
|
m.Add("rid2", "rfile2.jpg", 20, "gcs")
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
|
||||||
|
m2, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m2.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m2.Close()
|
||||||
|
if !m2.Has("rid1") {
|
||||||
|
t.Fatal("expected rid1 after reload")
|
||||||
|
}
|
||||||
|
if !m2.Has("rid2") {
|
||||||
|
t.Fatal("expected rid2 after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntries(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
m.Add("se1", "sf1.jpg", 1, "sc1")
|
||||||
|
m.Add("se2", "sf2.jpg", 2, "sc2")
|
||||||
|
entries := m.Entries()
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCloseIdempotent(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendIdempotent(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreManifestFormat(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.ManifestFormat() != types.FormatSQLite {
|
||||||
|
t.Fatal("expected SQLite format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSave(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendCreateDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
subdir := filepath.Join(dir, "subdir")
|
||||||
|
m, err := Load(subdir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCloseWithoutOpen(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntriesWithoutOpen(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries := m.Entries()
|
||||||
|
if entries != nil {
|
||||||
|
t.Errorf("Entries without open should return nil, got %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHasWithoutOpen(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.Has("x") {
|
||||||
|
t.Fatal("Has should return false without open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreAddEntryWithoutOpen(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(types.Entry{ID: "x", Filename: "f.jpg"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetOpener(t *testing.T) {
|
||||||
|
old := SetOpener(func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
return nil, fmt.Errorf("test")
|
||||||
|
})
|
||||||
|
if old != nil {
|
||||||
|
t.Fatal("expected nil old opener")
|
||||||
|
}
|
||||||
|
restore := SetOpener(old)
|
||||||
|
if restore == nil {
|
||||||
|
t.Fatal("expected non-nil restore")
|
||||||
|
}
|
||||||
|
SetOpener(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileExists(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if FileExists(dir) {
|
||||||
|
t.Fatal("expected false for directory")
|
||||||
|
}
|
||||||
|
if FileExists("/nonexistent/file") {
|
||||||
|
t.Fatal("expected false for nonexistent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterClose(t *testing.T) {
|
||||||
|
w := &LogWriter{}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenerWithOverride(t *testing.T) {
|
||||||
|
SetOpener(func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
if opener() == nil {
|
||||||
|
t.Fatal("expected override")
|
||||||
|
}
|
||||||
|
SetOpener(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterCloseDirect(t *testing.T) {
|
||||||
|
(&LogWriter{}).Close()
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package manifest
|
package sqlite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sqliteLogWriter struct {
|
type LogWriter struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) {
|
func NewLogWriter(db *sql.DB) (types.LogWriter, error) {
|
||||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (
|
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ts INTEGER NOT NULL,
|
ts INTEGER NOT NULL,
|
||||||
@@ -27,10 +29,10 @@ func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) {
|
|||||||
}
|
}
|
||||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`)
|
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`)
|
||||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`)
|
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`)
|
||||||
return &sqliteLogWriter{db: db}, nil
|
return &LogWriter{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *sqliteLogWriter) Log(e LogEntry) {
|
func (w *LogWriter) Log(e types.LogEntry) {
|
||||||
if w.db == nil {
|
if w.db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -38,4 +40,4 @@ func (w *sqliteLogWriter) Log(e LogEntry) {
|
|||||||
e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message)
|
e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *sqliteLogWriter) Close() { _ = w }
|
func (w *LogWriter) Close() { _ = w }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package manifest
|
package sqlite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -6,40 +6,46 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sqliteManifest struct {
|
type OpenerFunc func(driverName, dataSourceName string) (*sql.DB, error)
|
||||||
|
|
||||||
|
var openerOverride OpenerFunc
|
||||||
|
|
||||||
|
func SetOpener(fn OpenerFunc) (old OpenerFunc) {
|
||||||
|
old = openerOverride
|
||||||
|
openerOverride = fn
|
||||||
|
return old
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
path string
|
path string
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
open sqlOpenerFunc
|
open OpenerFunc
|
||||||
execFunc func(query string, args ...any) (sql.Result, error)
|
execFunc func(query string, args ...any) (sql.Result, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type sqlOpenerFunc func(driverName, dataSourceName string) (*sql.DB, error)
|
func Path(dir string) string {
|
||||||
|
|
||||||
var sqliteOpenFunc sqlOpenerFunc
|
|
||||||
|
|
||||||
func defaultSQLOpener() sqlOpenerFunc {
|
|
||||||
if sqliteOpenFunc != nil {
|
|
||||||
return sqliteOpenFunc
|
|
||||||
}
|
|
||||||
return sql.Open
|
|
||||||
}
|
|
||||||
|
|
||||||
func SQLitePath(dir string) string {
|
|
||||||
return filepath.Join(dir, "downloads.db")
|
return filepath.Join(dir, "downloads.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadSQLite(dir string) (*sqliteManifest, error) {
|
func Load(dir string) (*Store, error) {
|
||||||
m := &sqliteManifest{
|
m := &Store{
|
||||||
path: SQLitePath(dir),
|
path: Path(dir),
|
||||||
open: defaultSQLOpener(),
|
open: opener(),
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) OpenAppend() error {
|
func opener() OpenerFunc {
|
||||||
|
if openerOverride != nil {
|
||||||
|
return openerOverride
|
||||||
|
}
|
||||||
|
return sql.Open
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Store) OpenAppend() error {
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -64,6 +70,7 @@ func (m *sqliteManifest) OpenAppend() error {
|
|||||||
path TEXT NOT NULL DEFAULT '',
|
path TEXT NOT NULL DEFAULT '',
|
||||||
size INTEGER NOT NULL DEFAULT 0,
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
cloud TEXT NOT NULL DEFAULT '',
|
cloud TEXT NOT NULL DEFAULT '',
|
||||||
|
checksum TEXT NOT NULL DEFAULT '',
|
||||||
exported INTEGER NOT NULL DEFAULT 0
|
exported INTEGER NOT NULL DEFAULT 0
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,6 +78,7 @@ func (m *sqliteManifest) OpenAppend() error {
|
|||||||
return fmt.Errorf("create table: %w", err)
|
return fmt.Errorf("create table: %w", err)
|
||||||
}
|
}
|
||||||
_, _ = execFn(`ALTER TABLE downloads ADD COLUMN path TEXT NOT NULL DEFAULT ''`)
|
_, _ = execFn(`ALTER TABLE downloads ADD COLUMN path TEXT NOT NULL DEFAULT ''`)
|
||||||
|
_, _ = execFn(`ALTER TABLE downloads ADD COLUMN checksum TEXT NOT NULL DEFAULT ''`)
|
||||||
_, err = execFn(`CREATE INDEX IF NOT EXISTS idx_downloads_id ON downloads(id)`)
|
_, err = execFn(`CREATE INDEX IF NOT EXISTS idx_downloads_id ON downloads(id)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
@@ -80,7 +88,13 @@ func (m *sqliteManifest) OpenAppend() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) Has(id string) bool {
|
func (m *Store) DB() *sql.DB { return m.db }
|
||||||
|
func (m *Store) SetOpen(fn OpenerFunc) { m.open = fn }
|
||||||
|
func (m *Store) SetExecFunc(fn func(query string, args ...any) (sql.Result, error)) {
|
||||||
|
m.execFunc = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Store) Has(id string) bool {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -92,49 +106,47 @@ func (m *sqliteManifest) Has(id string) bool {
|
|||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) {
|
func (m *Store) ManifestFormat() types.Format {
|
||||||
m.AddEntry(newEntry(id, filename, size, cloud))
|
return types.FormatSQLite
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) AddEntry(entry Entry) {
|
func (m *Store) Add(id string, filename string, size int64, cloud string) {
|
||||||
|
m.AddEntry(types.NewEntry(id, filename, filename, size, cloud))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Store) AddEntry(entry types.Entry) {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if entry.Path == "" {
|
if entry.Path == "" {
|
||||||
entry.Path = entry.Filename
|
entry.Path = entry.Filename
|
||||||
}
|
}
|
||||||
m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, path, size, cloud, exported) VALUES (?, ?, ?, ?, ?, ?)`,
|
m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, path, size, cloud, checksum, exported) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Exported)
|
entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Checksum, entry.Exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) Save() error {
|
func (m *Store) Save() error { return nil }
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *sqliteManifest) Close() {
|
func (m *Store) Close() {
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
m.db.Close()
|
m.db.Close()
|
||||||
m.db = nil
|
m.db = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) DB() *sql.DB {
|
func (m *Store) Entries() map[string]types.Entry {
|
||||||
return m.db
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *sqliteManifest) Entries() map[string]Entry {
|
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make(map[string]Entry)
|
out := make(map[string]types.Entry)
|
||||||
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, exported FROM downloads`)
|
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Entry
|
var e types.Entry
|
||||||
if err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Exported); err == nil {
|
if err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Checksum, &e.Exported); err == nil {
|
||||||
if e.Path == "" {
|
if e.Path == "" {
|
||||||
e.Path = e.Filename
|
e.Path = e.Filename
|
||||||
}
|
}
|
||||||
@@ -143,3 +155,11 @@ func (m *sqliteManifest) Entries() map[string]Entry {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !info.IsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreOpenAppendSQLError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.SetOpen(func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
return nil, fmt.Errorf("simulated open error")
|
||||||
|
})
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error from sql.Open failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendCreateTableError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realOpen := m.open
|
||||||
|
m.SetOpen(func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
db, err := realOpen(driverName, dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
return db, nil
|
||||||
|
})
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error from closed DB CREATE TABLE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHasAfterClose(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
m.Close()
|
||||||
|
if m.Has("x1") {
|
||||||
|
t.Error("Has should return false after Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntriesAfterClose(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
m.Close()
|
||||||
|
entries := m.Entries()
|
||||||
|
if entries != nil {
|
||||||
|
t.Errorf("Entries should return nil after Close, got %d entries", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendMkdirAllError(t *testing.T) {
|
||||||
|
m, err := Load("/proc/cannot-write")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error creating dir under /proc")
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendNilOpener(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.open = nil
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendCreateIndexError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realOpen := m.open
|
||||||
|
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
db, err := realOpen(driverName, dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error from closed DB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHasQueryError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
closedDB := m.DB()
|
||||||
|
closedDB.Close()
|
||||||
|
if m.Has("x1") {
|
||||||
|
t.Error("Has should return false with broken DB connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntriesQueryError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
closedDB := m.DB()
|
||||||
|
closedDB.Close()
|
||||||
|
entries := m.Entries()
|
||||||
|
if entries == nil {
|
||||||
|
t.Error("Entries should return non-nil map with broken DB connection")
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHasQueryErrorWithOpenDB(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
closedDB := m.DB()
|
||||||
|
closedDB.Close()
|
||||||
|
if m.Has("x1") {
|
||||||
|
t.Error("Has should return false with closed DB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntriesQueryErrorWithOpenDB(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
closedDB := m.DB()
|
||||||
|
closedDB.Close()
|
||||||
|
entries := m.Entries()
|
||||||
|
if entries != nil && len(entries) != 0 {
|
||||||
|
t.Errorf("Entries should be empty with closed DB, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCreateIndexError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realOpen := m.open
|
||||||
|
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
db, err := realOpen(driverName, dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.SetExecFunc(func(query string, args ...any) (sql.Result, error) {
|
||||||
|
if strings.Contains(query, "CREATE INDEX") {
|
||||||
|
return nil, fmt.Errorf("injected CREATE INDEX error")
|
||||||
|
}
|
||||||
|
return db.Exec(query, args...)
|
||||||
|
})
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error from CREATE INDEX")
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEntryDefaultsPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(types.Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:def"})
|
||||||
|
if _, err := m.DB().Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sloaded := m.Entries()["x1"]
|
||||||
|
if got := sloaded.Path; got != "file.jpg" {
|
||||||
|
t.Fatalf("sqlite path = %q", got)
|
||||||
|
}
|
||||||
|
if sloaded.Checksum != "sha256:def" {
|
||||||
|
t.Fatalf("sqlite checksum = %q", sloaded.Checksum)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogWriter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "test.db")
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
lw, err := NewLogWriter(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lw.Log(types.LogEntry{
|
||||||
|
Timestamp: 1700000000,
|
||||||
|
Level: "info",
|
||||||
|
Event: "export_done",
|
||||||
|
AssetID: "asset-1",
|
||||||
|
Album: "Favorites",
|
||||||
|
Filename: "photo.jpg",
|
||||||
|
Size: 1024,
|
||||||
|
Cloud: "local",
|
||||||
|
DurationMs: 500,
|
||||||
|
})
|
||||||
|
lw.Log(types.LogEntry{
|
||||||
|
Timestamp: 1700000001,
|
||||||
|
Level: "error",
|
||||||
|
Event: "export_fail",
|
||||||
|
AssetID: "asset-2",
|
||||||
|
Filename: "bad.jpg",
|
||||||
|
Message: "timeout",
|
||||||
|
})
|
||||||
|
lw.Close()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("expected 2 log entries, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var event, level, assetID string
|
||||||
|
err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if event != "export_done" || level != "info" || assetID != "asset-1" {
|
||||||
|
t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterNilDB(t *testing.T) {
|
||||||
|
w := &LogWriter{db: nil}
|
||||||
|
w.Log(types.LogEntry{Event: "test"})
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogWriterError(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := NewLogWriter(db); err == nil {
|
||||||
|
t.Error("expected error for closed db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterCloseConcrete(t *testing.T) {
|
||||||
|
(&LogWriter{}).Close()
|
||||||
|
}
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSetJSONLSaveHook(t *testing.T) {
|
|
||||||
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
|
|
||||||
if old != nil {
|
|
||||||
t.Error("expected nil old hook")
|
|
||||||
}
|
|
||||||
restore := SetJSONLSaveHook(old)
|
|
||||||
if restore == nil {
|
|
||||||
t.Error("expected non-nil restore function")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONLSaveHookError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
|
|
||||||
defer SetJSONLSaveHook(old)
|
|
||||||
if err := m.Save(); err == nil {
|
|
||||||
t.Error("expected hook error from Save")
|
|
||||||
}
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONLSyncFuncError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.syncFunc = func() error { return fmt.Errorf("sync func error") }
|
|
||||||
if err := m.Save(); err == nil {
|
|
||||||
t.Error("expected syncFunc error from Save")
|
|
||||||
}
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendSQLError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
return nil, fmt.Errorf("simulated open error")
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error from sql.Open failure")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendCreateTableError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
realOpen := m.open
|
|
||||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
db.Close()
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error from closed DB CREATE TABLE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteHasAfterClose(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.Close()
|
|
||||||
if m.Has("x1") {
|
|
||||||
t.Error("Has should return false after Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteEntriesAfterClose(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.Close()
|
|
||||||
entries := m.Entries()
|
|
||||||
if entries != nil {
|
|
||||||
t.Errorf("Entries should return nil after Close, got %d entries", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendMkdirAllError(t *testing.T) {
|
|
||||||
m, err := LoadSQLite("/proc/cannot-write")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error creating dir under /proc")
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendNilOpener(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.open = nil
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err)
|
|
||||||
}
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendCreateIndexError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
realOpen := m.open
|
|
||||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
db.Close()
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error from closed DB")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteHasQueryError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
closedDB := m.db
|
|
||||||
closedDB.Close()
|
|
||||||
result := m.Has("x1")
|
|
||||||
if result {
|
|
||||||
t.Error("Has should return false with broken DB connection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteEntriesQueryError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
closedDB := m.db
|
|
||||||
closedDB.Close()
|
|
||||||
entries := m.Entries()
|
|
||||||
if entries == nil {
|
|
||||||
t.Error("Entries should return non-nil map with broken DB connection")
|
|
||||||
}
|
|
||||||
if len(entries) != 0 {
|
|
||||||
t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteHasQueryErrorWithOpenDB(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
closedDB := m.db
|
|
||||||
closedDB.Close()
|
|
||||||
if m.Has("x1") {
|
|
||||||
t.Error("Has should return false with closed DB")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteEntriesQueryErrorWithOpenDB(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
closedDB := m.db
|
|
||||||
closedDB.Close()
|
|
||||||
entries := m.Entries()
|
|
||||||
if entries != nil && len(entries) != 0 {
|
|
||||||
t.Errorf("Entries should be empty with closed DB, got %d", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteCreateIndexError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
realOpen := m.open
|
|
||||||
callCount := 0
|
|
||||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m.execFunc = func(query string, args ...any) (sql.Result, error) {
|
|
||||||
if strings.Contains(query, "CREATE INDEX") {
|
|
||||||
return nil, fmt.Errorf("injected CREATE INDEX error")
|
|
||||||
}
|
|
||||||
callCount++
|
|
||||||
return db.Exec(query, args...)
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error from CREATE INDEX")
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromJSONLOpenAppendSQLError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.Close()
|
|
||||||
oldOpen := sqliteOpenFunc
|
|
||||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
return nil, fmt.Errorf("simulated sqlite open error")
|
|
||||||
}
|
|
||||||
defer func() { sqliteOpenFunc = oldOpen }()
|
|
||||||
_, err := ConvertFromJSONL(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromJSONLDstOpenAppendError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.Close()
|
|
||||||
realOpen := defaultSQLOpener()
|
|
||||||
oldOpen := sqliteOpenFunc
|
|
||||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
db.Close()
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
defer func() { sqliteOpenFunc = oldOpen }()
|
|
||||||
_, err := ConvertFromJSONL(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromSQLiteSrcOpenAppendError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
src, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
src.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
src.Close()
|
|
||||||
realOpen := defaultSQLOpener()
|
|
||||||
callCount := 0
|
|
||||||
oldOpen := sqliteOpenFunc
|
|
||||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
callCount++
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if callCount > 0 {
|
|
||||||
db.Close()
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
defer func() { sqliteOpenFunc = oldOpen }()
|
|
||||||
_, err = ConvertFromSQLite(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from src.OpenAppend during ConvertFromSQLite")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromSQLiteDstOpenAppendError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
src, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
src.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
src.Close()
|
|
||||||
jsonlPath := JSONLPath(dir)
|
|
||||||
f, err := os.Create(jsonlPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
os.Chmod(jsonlPath, 0444)
|
|
||||||
defer os.Chmod(jsonlPath, 0644)
|
|
||||||
_, err = ConvertFromSQLite(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from dst.OpenAppend during ConvertFromSQLite")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONLSaveError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.file.Close()
|
|
||||||
if err := m.Save(); err == nil {
|
|
||||||
t.Error("expected Sync error on closed file")
|
|
||||||
}
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromSQLiteSaveError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
src, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
src.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
src.Close()
|
|
||||||
oldHook := jsonlSaveHook
|
|
||||||
jsonlSaveHook = func() error { return fmt.Errorf("simulated sync error") }
|
|
||||||
defer func() { jsonlSaveHook = oldHook }()
|
|
||||||
_, err = ConvertFromSQLite(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from dst.Save during ConvertFromSQLite")
|
|
||||||
}
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "save jsonl") {
|
|
||||||
t.Errorf("expected save jsonl error, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package manifest
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Adapter interface {
|
||||||
|
Format() Format
|
||||||
|
Aliases() []string
|
||||||
|
Path(dir string) string
|
||||||
|
Exists(dir string) bool
|
||||||
|
Open(dir string) (Manifest, error)
|
||||||
|
OpenLogWriter(m Manifest, dir string) (LogWriter, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
adapters []Adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry(adapters ...Adapter) Registry {
|
||||||
|
return Registry{adapters: adapters}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) ParseFormat(s string) (Format, error) {
|
||||||
|
want := strings.ToLower(s)
|
||||||
|
for _, adapter := range r.adapters {
|
||||||
|
if want == string(adapter.Format()) {
|
||||||
|
return adapter.Format(), nil
|
||||||
|
}
|
||||||
|
for _, alias := range adapter.Aliases() {
|
||||||
|
if want == strings.ToLower(alias) {
|
||||||
|
return adapter.Format(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) Open(dir string, format Format) (Manifest, error) {
|
||||||
|
dst, err := r.adapter(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if dst.Exists(dir) {
|
||||||
|
return dst.Open(dir)
|
||||||
|
}
|
||||||
|
for _, src := range r.adapters {
|
||||||
|
if src.Format() != dst.Format() && src.Exists(dir) {
|
||||||
|
return ConvertManifest(dir, src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst.Open(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) OpenLogWriter(m Manifest, dir string, format Format) (LogWriter, error) {
|
||||||
|
adapter, err := r.adapter(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return adapter.OpenLogWriter(m, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) adapter(format Format) (Adapter, error) {
|
||||||
|
for _, adapter := range r.adapters {
|
||||||
|
if adapter.Format() == format {
|
||||||
|
return adapter, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertManifest(dir string, src Adapter, dst Adapter) (Manifest, error) {
|
||||||
|
source, err := src.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s for read: %w", src.Format(), err)
|
||||||
|
}
|
||||||
|
if err := source.OpenAppend(); err != nil {
|
||||||
|
source.Close()
|
||||||
|
return nil, fmt.Errorf("open %s for read: %w", src.Format(), err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
target, err := dst.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s for write: %w", dst.Format(), err)
|
||||||
|
}
|
||||||
|
if err := target.OpenAppend(); err != nil {
|
||||||
|
target.Close()
|
||||||
|
return nil, fmt.Errorf("open %s for write: %w", dst.Format(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, entry := range source.Entries() {
|
||||||
|
entry.ID = id
|
||||||
|
target.AddEntry(entry)
|
||||||
|
}
|
||||||
|
if err := target.Save(); err != nil {
|
||||||
|
target.Close()
|
||||||
|
return nil, fmt.Errorf("save %s: %w", dst.Format(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := removeFile(src.Path(dir)); err != nil {
|
||||||
|
target.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeFile func(string) error
|
||||||
|
|
||||||
|
func SetRemoveFunc(fn func(string) error) {
|
||||||
|
removeFile = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveFunc() func(string) error {
|
||||||
|
return removeFile
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRegistry(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, err := r.ParseFormat("jsonl"); err == nil {
|
||||||
|
t.Fatal("expected error from empty registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryParseFormatAliases(t *testing.T) {
|
||||||
|
r := NewRegistry(testAdapter{format: FormatJSONL, aliases: []string{"json"}}, testAdapter{format: FormatSQLite, aliases: []string{"db", "sqlite3"}})
|
||||||
|
cases := map[string]Format{
|
||||||
|
"jsonl": FormatJSONL,
|
||||||
|
"json": FormatJSONL,
|
||||||
|
"sqlite": FormatSQLite,
|
||||||
|
"db": FormatSQLite,
|
||||||
|
"sqlite3": FormatSQLite,
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
got, err := r.ParseFormat(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFormat(%q): %v", input, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ParseFormat(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryParseFormatUnknown(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, err := r.ParseFormat("bad"); err == nil {
|
||||||
|
t.Fatal("expected unknown format error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenDefaultsToOpen(t *testing.T) {
|
||||||
|
r := NewRegistry(testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false})
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenUnknownFormat(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, err := r.Open(t.TempDir(), FormatSQLite); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenNoConversionFallback(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
testAdapter{format: FormatSQLite, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
)
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenSameFormatAdapterInLoop(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
)
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenOtherFormatNotExists(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
testAdapter{format: FormatSQLite, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
)
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenExistingManifest(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: true},
|
||||||
|
)
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenConverts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := &testStore{entries: map[string]Entry{"x1": {ID: "x1", Filename: "photo.jpg", Path: "photo.jpg"}}}
|
||||||
|
dst := &testStore{entries: map[string]Entry{}}
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: src, exists: true},
|
||||||
|
testAdapter{format: FormatSQLite, store: dst, exists: false},
|
||||||
|
)
|
||||||
|
SetRemoveFunc(func(string) error { return nil })
|
||||||
|
m, err := r.Open(dir, FormatSQLite)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
if _, ok := dst.entries["x1"]; !ok {
|
||||||
|
t.Fatal("expected converted entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenLogWriter(t *testing.T) {
|
||||||
|
r := NewRegistry(testAdapter{format: FormatJSONL, logWriter: NoopLogWriter})
|
||||||
|
w, err := r.OpenLogWriter(nil, t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenLogWriterUnknown(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, err := r.OpenLogWriter(nil, t.TempDir(), FormatSQLite); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := &testStore{entries: map[string]Entry{"x1": {ID: "x1", Filename: "photo.jpg", Path: "photo.jpg"}}}
|
||||||
|
dst := &testStore{entries: map[string]Entry{}}
|
||||||
|
SetRemoveFunc(func(string) error { return nil })
|
||||||
|
m, err := ConvertManifest(dir, testAdapter{format: FormatJSONL, store: src, exists: true}, testAdapter{format: FormatSQLite, store: dst, exists: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
if _, ok := dst.entries["x1"]; !ok {
|
||||||
|
t.Fatal("expected converted entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestSourceOpenError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, openErr: fmt.Errorf("boom")}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestSourceOpenAppendError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{openAppendErr: fmt.Errorf("boom")}}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestDstOpenError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, openErr: fmt.Errorf("boom")}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestDstOpenAppendError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{openAppendErr: fmt.Errorf("boom")}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestDstSaveError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{saveErr: fmt.Errorf("boom")}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestRemoveError(t *testing.T) {
|
||||||
|
SetRemoveFunc(func(string) error { return fmt.Errorf("remove failed") })
|
||||||
|
defer SetRemoveFunc(nil)
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRemoveFunc(t *testing.T) {
|
||||||
|
SetRemoveFunc(func(string) error { return nil })
|
||||||
|
if RemoveFunc() == nil {
|
||||||
|
t.Fatal("expected non-nil remove func")
|
||||||
|
}
|
||||||
|
SetRemoveFunc(nil)
|
||||||
|
if RemoveFunc() != nil {
|
||||||
|
t.Fatal("expected nil remove func")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoopLogWriterMethods(t *testing.T) {
|
||||||
|
NoopLogWriter.Log(LogEntry{})
|
||||||
|
NoopLogWriter.Close()
|
||||||
|
var n noopLogWriter
|
||||||
|
n.Log(LogEntry{})
|
||||||
|
n.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type testAdapter struct {
|
||||||
|
format Format
|
||||||
|
aliases []string
|
||||||
|
store Manifest
|
||||||
|
exists bool
|
||||||
|
openErr error
|
||||||
|
logWriter LogWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a testAdapter) Format() Format { return a.format }
|
||||||
|
func (a testAdapter) Aliases() []string { return a.aliases }
|
||||||
|
func (a testAdapter) Path(string) string { return "test" }
|
||||||
|
func (a testAdapter) Exists(string) bool { return a.exists }
|
||||||
|
func (a testAdapter) Open(string) (Manifest, error) {
|
||||||
|
if a.openErr != nil {
|
||||||
|
return nil, a.openErr
|
||||||
|
}
|
||||||
|
return a.store, nil
|
||||||
|
}
|
||||||
|
func (a testAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) {
|
||||||
|
if a.logWriter != nil {
|
||||||
|
return a.logWriter, nil
|
||||||
|
}
|
||||||
|
return NoopLogWriter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type testStore struct {
|
||||||
|
entries map[string]Entry
|
||||||
|
openAppendErr error
|
||||||
|
saveErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStore) Has(id string) bool {
|
||||||
|
_, ok := s.entries[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
func (s *testStore) Add(id string, filename string, size int64, cloud string) {
|
||||||
|
s.entries[id] = NewEntry(id, filename, filename, size, cloud)
|
||||||
|
}
|
||||||
|
func (s *testStore) AddEntry(e Entry) {
|
||||||
|
if e.Path == "" {
|
||||||
|
e.Path = e.Filename
|
||||||
|
}
|
||||||
|
s.entries[e.ID] = e
|
||||||
|
}
|
||||||
|
func (s *testStore) Save() error { return s.saveErr }
|
||||||
|
func (s *testStore) Close() {}
|
||||||
|
func (s *testStore) OpenAppend() error { return s.openAppendErr }
|
||||||
|
func (s *testStore) Entries() map[string]Entry {
|
||||||
|
out := make(map[string]Entry, len(s.entries))
|
||||||
|
for k, v := range s.entries {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ID string
|
||||||
|
Filename string
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
Cloud string
|
||||||
|
Checksum string
|
||||||
|
Exported int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manifest interface {
|
||||||
|
Has(id string) bool
|
||||||
|
Add(id string, filename string, size int64, cloud string)
|
||||||
|
AddEntry(entry Entry)
|
||||||
|
Save() error
|
||||||
|
Close()
|
||||||
|
OpenAppend() error
|
||||||
|
Entries() map[string]Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryReader = Manifest
|
||||||
|
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FormatJSONL Format = "jsonl"
|
||||||
|
FormatSQLite Format = "sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp int64 `json:"ts"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
AssetID string `json:"asset_id,omitempty"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Cloud string `json:"cloud,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogWriter interface {
|
||||||
|
Log(entry LogEntry)
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopLogWriter struct{}
|
||||||
|
|
||||||
|
func (noopLogWriter) Log(LogEntry) { _ = struct{}{} }
|
||||||
|
func (noopLogWriter) Close() { _ = struct{}{} }
|
||||||
|
|
||||||
|
var NoopLogWriter LogWriter = noopLogWriter{}
|
||||||
|
|
||||||
|
type FormatReporter interface {
|
||||||
|
ManifestFormat() Format
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogPath(dir string) string {
|
||||||
|
return dir + "/export.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEntry(id, filename, path string, size int64, cloud string) Entry {
|
||||||
|
e := Entry{
|
||||||
|
ID: id,
|
||||||
|
Filename: filename,
|
||||||
|
Path: filename,
|
||||||
|
Size: size,
|
||||||
|
Cloud: cloud,
|
||||||
|
Exported: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if path != "" {
|
||||||
|
e.Path = path
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry {
|
||||||
|
e := NewEntry(id, filename, path, size, cloud)
|
||||||
|
e.Checksum = checksum
|
||||||
|
return e
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewEntryPath(t *testing.T) {
|
||||||
|
e := NewEntry("id1", "file.jpg", "", 123, "local")
|
||||||
|
if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 {
|
||||||
|
t.Fatalf("unexpected entry: %+v", e)
|
||||||
|
}
|
||||||
|
e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "cloud")
|
||||||
|
if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" {
|
||||||
|
t.Fatalf("unexpected entry with path: %+v", e)
|
||||||
|
}
|
||||||
|
e = NewEntryWithChecksum("id3", "file3.jpg", "Album/file3.jpg", 789, "local", "sha256:abc")
|
||||||
|
if e.Checksum != "sha256:abc" || e.Path != "Album/file3.jpg" {
|
||||||
|
t.Fatalf("unexpected checksum entry: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoopLogWriter(t *testing.T) {
|
||||||
|
NoopLogWriter.Log(LogEntry{Event: "test"})
|
||||||
|
NoopLogWriter.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFileLogWriter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := LogPath(dir)
|
||||||
|
w, err := NewFileLogWriter(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Log(LogEntry{Timestamp: 1700000000, Level: "info", Event: "export_done", Filename: "photo.jpg", Size: 2048})
|
||||||
|
w.Close()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Error("expected log data")
|
||||||
|
}
|
||||||
|
if data[len(data)-1] != '\n' {
|
||||||
|
t.Error("expected trailing newline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileLogWriterClosed(t *testing.T) {
|
||||||
|
w, err := NewFileLogWriter(LogPath(t.TempDir()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
w.Log(LogEntry{Event: "after-close"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFileLogWriterError(t *testing.T) {
|
||||||
|
if _, err := NewFileLogWriter("/nonexistent/dir/export.log"); err == nil {
|
||||||
|
t.Error("expected error for bad path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileLogWriterDoubleClose(t *testing.T) {
|
||||||
|
w, err := NewFileLogWriter(LogPath(t.TempDir()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogPath(t *testing.T) {
|
||||||
|
if got := LogPath("/tmp/out"); got != "/tmp/out/export.log" {
|
||||||
|
t.Fatalf("LogPath = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user