From 2e73d01b409d996669a22be703035b99e9034edc Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Mon, 15 Jun 2026 00:00:06 +0200 Subject: [PATCH] v0.5.0: manifests, filters, logging, docs --- .gitea/workflows/pipeline.yml | 34 + CHANGELOG.md | 48 + Makefile | 4 +- README.md | 461 ++- bridge/photokit_bridge.h | 2 + bridge/photokit_bridge.m | 172 +- bridge/photokit_bridge_stub.c | 36 +- cmd/photoscli/main.go | 1115 +++++++- cmd/photoscli/main_main.go | 26 +- cmd/photoscli/main_main_test.go | 5 + cmd/photoscli/main_test.go | 3117 ++++++++++++++++++++- cmd/photoscli/progress.go | 18 - cmd/photoscli/runmain.go | 33 + cmd/photoscli/signal_default.go | 20 + cmd/photoscli/signal_test.go | 8 + go.mod | 16 +- go.sum | 51 + internal/manifest/file_log.go | 39 + internal/manifest/jsonl.go | 139 + internal/manifest/log.go | 30 + internal/manifest/log_test.go | 211 ++ internal/manifest/manifest.go | 33 + internal/manifest/manifest_test.go | 1049 +++++++ internal/manifest/open.go | 106 + internal/manifest/sqlite.go | 134 + internal/manifest/sqlite_internal_test.go | 402 +++ internal/manifest/sqlite_log.go | 41 + internal/photos/bridge.go | 4 +- internal/photos/cgo_bridge.go | 20 +- internal/photos/cgo_bridge_test_impl.go | 61 +- internal/photos/photos.go | 2 +- internal/photos/photos_test.go | 291 +- internal/photos/types.go | 22 +- 33 files changed, 7238 insertions(+), 512 deletions(-) create mode 100644 .gitea/workflows/pipeline.yml create mode 100644 CHANGELOG.md create mode 100644 cmd/photoscli/main_main_test.go create mode 100644 cmd/photoscli/runmain.go create mode 100644 cmd/photoscli/signal_default.go create mode 100644 cmd/photoscli/signal_test.go create mode 100644 go.sum create mode 100644 internal/manifest/file_log.go create mode 100644 internal/manifest/jsonl.go create mode 100644 internal/manifest/log.go create mode 100644 internal/manifest/log_test.go create mode 100644 internal/manifest/manifest.go create mode 100644 internal/manifest/manifest_test.go create mode 100644 internal/manifest/open.go create mode 100644 internal/manifest/sqlite.go create mode 100644 internal/manifest/sqlite_internal_test.go create mode 100644 internal/manifest/sqlite_log.go diff --git a/.gitea/workflows/pipeline.yml b/.gitea/workflows/pipeline.yml new file mode 100644 index 0000000..bcaa60a --- /dev/null +++ b/.gitea/workflows/pipeline.yml @@ -0,0 +1,34 @@ +name: pipeline +on: [push, pull_request] + +jobs: + test: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Build stub library + run: | + cc -c -o bridge/photokit_bridge_stub.o bridge/photokit_bridge_stub.c -I bridge + ar rcs bridge/libphotokit_bridge_stub.a bridge/photokit_bridge_stub.o + - name: Vet + run: go vet -tags=test ./... + - name: Test + run: go test -tags=test -race -coverprofile=coverage.out ./cmd/photoscli/ ./internal/photos/ + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -1 + + build: + runs-on: macos-14 + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Build + run: make build + - name: Verify version + run: ./bin/photoscli version diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2c55ec2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +## v0.5.0 + +- Add JSONL and SQLite manifests with resumable skip tracking +- Add structured export logging to export.log or SQLite logs table +- Add --dry-run, --retry, --only-favorites, --media, --json, --verify, --date-template, and size filters +- Add report, diff, verify, and retry-failed commands +- Add failure tracking in failures.jsonl +- Add configurable preview quality and export concurrency +- Add album exclusion, since-date filtering, album collision handling, and config-file defaults +- Expand README and CLI help output +- Maintain 100% test coverage in the release pipeline + +## v0.4.0 + +- Scroll log for completed exports (copied, downloaded, skipped, failed) +- Worker status lines with live download progress bars for cloud files +- Color gradient progress bar (red to yellow to green) +- Disk skip: files already on disk are skipped during backup-all +- Parallel export with 3 workers for backup-all + +## v0.2.5 + +- Unicode progress bar with cloud download speed display + +## v0.2.4 + +- Stop export loop on Ctrl+C instead of flooding failures + +## v0.2.3 + +- Fix export write failures and Ctrl+C cancellation + +## v0.2.1 + +- Add status messages during export +- Fix parallel export progress display + +## v0.2.0 + +- Semaphore timeouts, error logging, dead code removal +- Parallel exports + +## Pre-release + +- Initial applephotos CLI with progress, cloud status, per-asset export +- Renamed from applephotos to photoscli diff --git a/Makefile b/Makefile index d4b8103..2b41ae8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BINARY := ./bin/photoscli MODULE := gitea.k3s.k0.nu/tools/photocli -VERSION := 0.4.0 +VERSION := 0.5.0 BRIDGE_DIR := bridge LDFLAGS := -X main.version=$(VERSION) OBJ := $(BRIDGE_DIR)/photokit_bridge.o @@ -56,4 +56,4 @@ pipeline: clean test build @echo "--- verifying version ---" $(BINARY) version @echo "--- all checks passed, ready to release ---" - @echo "run: make release" \ No newline at end of file + @echo "run: make release" diff --git a/README.md b/README.md index f324873..bef72a7 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,33 @@ # photoscli -`photoscli` is a small macOS-only CLI written in Go that reads data from Apple Photos through a PhotoKit bridge. +`photoscli` is a macOS-only command-line exporter for Apple Photos. It uses a small Objective-C PhotoKit bridge from Go to list albums, inspect assets, export optimized previews or originals, keep resumable manifests, and produce machine-readable logs for backup automation. -It supports five tasks: +The tool is designed for repeatable, resumable Photos backups rather than one-off drag-and-drop exports. -- listing albums -- listing asset IDs, filenames, and cloud status in an album -- showing the folder and album tree -- backing up all albums into the Photos folder tree -- exporting resized JPEG previews or original files from an album +## Highlights -## What The Code Does - -The executable lives in `cmd/photoscli` and calls into `internal/photos`, which wraps an Objective-C bridge in `bridge/`. - -Current behavior: - -- `albums` prints one line per album as `\t` -- `photos --album-id <id-or-title>` prints one asset per line as `<id>\t<filename>\t<cloud>`; accepts either a PhotoKit local identifier or an album title -- `tree` prints the human-readable folder and album hierarchy from Apple Photos -- `backup-all --out <dir> [--size <px>] [--originals]` exports every album into a matching folder tree, showing per-asset progress with filename, size, and cloud status -- `export --album-id <id-or-title> --out <dir> [--size <px>] [--originals]` exports either JPEG previews or original files with a progress bar showing filename, size, and cloud status; `--album-id` accepts either a PhotoKit local identifier or an album title - -The bridge uses PhotoKit to: - -- request access to the user's Photos library -- fetch album collections by local identifier or album title -- fetch album assets sorted by `creationDate` ascending -- render resized images with `PHImageManager` -- write JPEG files with compression `0.85` -- support graceful cancellation via a cancel flag checked between file exports +- List albums, photos, and the Photos folder/album tree. +- Export one album or back up the whole Photos tree. +- Export JPEG previews with configurable size and quality, or original files. +- Skip already-exported assets through a JSONL or SQLite manifest. +- Resume interrupted runs safely. +- Parallel export with live worker progress and ETA. +- Optional structured logging to `export.log` or SQLite `logs` table. +- Dry-run mode for planning large backups. +- Filters for favorites, media type, date, estimated size, and excluded albums. +- Failure tracking with `failures.jsonl` and `retry-failed`. +- Verification, reporting, and diff commands for backup integrity. +- Script-friendly exit codes and optional JSON summaries. +- 100% test coverage for the Go CLI and parsing layers. ## Requirements -- macOS -- Go 1.22+ -- Xcode command-line tools +- macOS with Apple Photos. +- Go 1.25 or newer. +- Xcode command-line tools. +- Photos privacy permission for the built binary or terminal app. -The project builds with cgo and links against `Photos`, `Foundation`, `AppKit`, and `UniformTypeIdentifiers`. +The production build uses cgo and links against Apple frameworks through the Objective-C bridge in `bridge/`. ## Build @@ -45,41 +35,79 @@ The project builds with cgo and links against `Photos`, `Foundation`, `AppKit`, make build ``` -Output binary: +The binary is written to: ```bash ./bin/photoscli ``` -## Test +Run the full local release pipeline: ```bash -make test +make pipeline ``` -Tests run against a stub bridge, so they do not require real Photos access. +The pipeline cleans artifacts, builds the test bridge, runs vet, runs race-enabled tests with coverage, builds the real PhotoKit bridge, builds the CLI, and verifies the embedded version. -## Usage +## Quick Start + +List albums: ```bash ./bin/photoscli albums -./bin/photoscli photos --album-id "<album-local-identifier>" -./bin/photoscli photos --album-id "Vacation" -./bin/photoscli tree -./bin/photoscli backup-all --out ./backup -./bin/photoscli backup-all --out ./backup --originals -./bin/photoscli export --album-id "<album-local-identifier>" --out ./export -./bin/photoscli export --album-id "Vacation" --out ./export -./bin/photoscli export --album-id "<album-local-identifier>" --out ./export --size 2048 -./bin/photoscli export --album-id "<album-local-identifier>" --out ./export --originals ``` -### Commands +Inspect an album by title or PhotoKit local identifier: -`albums` +```bash +./bin/photoscli photos --album-id "Vacation" +``` -- Requests Photos access -- Lists albums as tab-separated album ID and title +Export preview JPEGs from one album: + +```bash +./bin/photoscli export --album-id "Vacation" --out ./export +``` + +Export higher-quality previews: + +```bash +./bin/photoscli export --album-id "Vacation" --out ./export --size 2048 --quality 92 +``` + +Export originals: + +```bash +./bin/photoscli export --album-id "Vacation" --out ./originals --originals +``` + +Back up the entire Photos album tree: + +```bash +./bin/photoscli backup-all --out ./photos-backup --manifest sqlite --log +``` + +Preview what would happen before writing anything: + +```bash +./bin/photoscli backup-all --out ./photos-backup --dry-run --json +``` + +Verify a backup later: + +```bash +./bin/photoscli verify --out ./photos-backup --manifest sqlite +``` + +## Commands + +### `albums` + +Lists user-created albums as tab-separated ID and title. + +```bash +photoscli albums +``` Example output: @@ -88,128 +116,285 @@ Example output: 8A1B.../L0/001 Work ``` -`photos --album-id <id-or-title>` +### `photos` -- Requests Photos access -- If the value looks like a PhotoKit local identifier, uses it directly -- Otherwise searches album titles for a match and resolves the identifier -- Lists asset local identifiers and cloud status for the given album +Lists assets in an album. `--album-id` accepts a PhotoKit local identifier or an exact album title. + +```bash +photoscli photos --album-id "Vacation" +``` + +Output includes ID, filename, cloud state, media type, dimensions, optional creation date, optional duration, and a `*` marker for favorites. + +### `tree` + +Prints the Photos folder and album hierarchy. + +```bash +photoscli tree +``` + +### `export` + +Exports assets from one album. + +```bash +photoscli export --album-id <id-or-title> --out <dir> [flags] +``` + +Useful examples: + +```bash +photoscli export --album-id "Family" --out ./family --size 2560 --quality 90 +photoscli export --album-id "Favorites" --out ./favorites --only-favorites +photoscli export --album-id "Videos" --out ./videos --media videos --include-videos +photoscli export --album-id "Archive" --out ./archive --originals --retry 3 +photoscli export --album-id "Vacation" --out ./vacation --date-template YYYY/MM/DD +``` + +### `backup-all` + +Walks the Photos folder/album tree and exports every album to a matching folder structure. + +```bash +photoscli backup-all --out <dir> [flags] +``` + +Duplicate sibling album names are disambiguated by adding the album ID to the generated folder name. + +Useful examples: + +```bash +photoscli backup-all --out ./backup --manifest sqlite --log +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 --concurrency 8 --retry 3 +photoscli backup-all --out ./backup --dry-run --json +``` + +### `report` + +Shows manifest and failure counts for an output directory. + +```bash +photoscli report --out ./backup --manifest sqlite +``` Example output: ```text -1F2A.../L0/001 IMG_0001.JPG local -9C4D.../L0/001 IMG_0002.JPG cloud +entries 12345 +failures 2 ``` -`backup-all --out <dir> [--size <px>] [--originals]` +### `diff` -- Requests Photos access -- Walks the Photos folder and album hierarchy -- Builds a complete index of all assets before exporting, skipping files already on disk -- Creates directories as `out/folder/album/files` -- Exports previews by default, originals when `--originals` is present -- Shows a progress display with: - - Scroll log of completed files (✅ copied, ☁ downloaded, ⏭ skipped, ❌ failed) - - Worker status lines with live download progress bars for cloud files - - Total and Album progress bars with color gradient (red → yellow → green) -- Uses `--size` only for preview export +Compares an album against the manifest and prints assets missing from the manifest. -Example layout: +```bash +photoscli diff --album-id "Vacation" --out ./backup --manifest jsonl +``` + +Exit code is `2` when missing assets are found. + +### `verify` + +Checks that manifest entries have corresponding files on disk. + +```bash +photoscli verify --out ./backup --manifest sqlite +``` + +Exit code is `2` when files are missing. + +### `retry-failed` + +Retries assets recorded in `failures.jsonl`. + +```bash +photoscli retry-failed --out ./backup +``` + +This is useful after transient iCloud or network failures. + +## Export Flags + +Common flags for `export` and `backup-all`: + +- `--out <dir>`: destination directory. +- `--size <px>`: longest-side target for preview export, default `1024`. +- `--quality <1-100>`: JPEG preview quality, default `85`. +- `--originals`: export original files instead of previews. +- `--concurrency <N>`: parallel workers, default `3`, capped by bridge slot count. +- `--retry <N>`: retry failed exports with a small backoff. +- `--dry-run`: print planned exports without writing files, manifests, or logs. +- `--json`: print a machine-readable summary to stdout. +- `--verify`: run manifest/file verification after export. +- `--log`: enable structured export logging. +- `--manifest jsonl|sqlite`: choose manifest backend, default `jsonl`. +- `--no-manifest`: disable manifest reads/writes. +- `--sort oldest|newest`: asset sort order where supported, default `oldest`. +- `--since <date>`: only include assets on or after `YYYY-MM-DD` or RFC3339 date. +- `--only-favorites`: only export favorite assets. +- `--media photos|videos|all`: media filter, default `photos`. +- `--include-videos`: compatibility flag that includes videos/audio. +- `--min-size <n>`: filter by estimated pixel count. +- `--max-size <n>`: filter by estimated pixel count. +- `--format jpeg|heic|png`: preview format hint. Current bridge output is still the existing preview path; non-JPEG bridge output is future work. +- `--date-template <template>`: append date folders based on creation date, for example `YYYY/MM/DD`. + +`backup-all` also supports: + +- `--exclude-album <pattern>`: repeatable exact or glob-style album-name exclusion. + +`export` also requires: + +- `--album-id <id-or-title>`: PhotoKit local identifier or exact album title. + +## Manifests + +Manifests prevent re-exporting assets on subsequent runs. + +JSONL backend: ```text -backup/ - Trips/ - Italy 2024/ - Venice/ - 0000_....jpg - Favorites/ - 0000_....jpg +downloads.jsonl ``` -`export --album-id <id-or-title> --out <dir> [--size <px>] [--originals]` - -- Requests Photos access -- Resolves `--album-id` by local identifier first, then by album title if not found -- Creates the output directory if needed -- Exports assets with a progress bar -- Shows file size and cloud/local status for each exported asset -- Exports resized JPEG previews by default -- Exports original files when `--originals` is present -- Writes a summary like `exported 10 photos to ./export` or `exported 10 original files to ./export` to stderr - -`--size` is the target bounding box passed to PhotoKit for preview export. Default: `1024`. - -`--originals` switches export mode to original-file export. In that mode, `--size` is ignored. - -`--include-videos` includes video and audio assets in the export. By default, videos and audio are filtered out. - -`tree` - -- Requests Photos access -- Prints folders and albums as an indented tree -- Omits internal album IDs for human-readable output - -Example output: +SQLite backend: ```text -Trips - Italy 2024 - Venice -Favorites +downloads.db ``` +The manifest stores asset ID, filename, size, cloud state, and export timestamp. SQLite is useful for very large libraries and for keeping logs in the same database. + +Use no manifest only when you intentionally want stateless export behavior: + +```bash +photoscli export --album-id "Scratch" --out ./scratch --no-manifest +``` + +## Logging + +Enable logs with: + +```bash +photoscli backup-all --out ./backup --log +``` + +With JSONL/no-manifest mode, logs are written to: + +```text +export.log +``` + +With SQLite manifest mode, logs are written to the `logs` table in `downloads.db`. + +Logged events include session start/end, completed exports, skipped assets, and failures. Each event includes timestamp, level, event name, asset ID, album, filename, size, cloud state, duration, and message where available. + +## Failure Tracking + +Failed exports are appended to: + +```text +failures.jsonl +``` + +Retry them with: + +```bash +photoscli retry-failed --out ./backup +``` + +Use `--retry N` during normal exports to handle transient iCloud or network failures automatically. + +## Configuration File + +Defaults can be loaded from: + +```text +~/.photoscli.toml +``` + +Or from a custom path: + +```bash +PHOTOSCLI_CONFIG=/path/to/photoscli.toml photoscli backup-all --out ./backup +``` + +Supported keys mirror long flag names without the leading dashes: + +```toml +size = 2048 +quality = 90 +concurrency = 8 +manifest = "sqlite" +log = true +sort = "newest" +media = "photos" +retry = 3 +``` + +Command-line flags override config-file values. + +## Exit Codes + +- `0`: success. +- `1`: command/config/runtime error. +- `2`: partial failure, such as some exports failed or diff/verify found missing items. +- `3`: Photos access denied. + ## Permissions -On first use, macOS may prompt for Photos access. +On first use, macOS may prompt for Photos access. If access is denied, grant access in: -If access is denied, the CLI returns an error telling you to grant access in: +```text +System Settings > Privacy & Security > Photos +``` -`System Settings > Privacy & Security` +Depending on how you launch the binary, macOS may associate the permission with Terminal, iTerm, your shell, or the built executable. -## Export Details +## Development -Exported files currently: +Run tests with the stub bridge: -- are JPEGs when exporting previews -- keep their original filenames when exporting originals when possible -- fall back to a sanitized asset identifier if an original filename is unavailable -- prefix duplicate original filenames with the asset index to avoid collisions -- name preview exports like `0000_<asset-local-identifier>.jpg` -- replace `/` and `\` in asset IDs with `_` for generated preview filenames -- replace `/` and `\` in folder and album names with `_` when creating backup directory names -- preserve ordering based on ascending asset creation date +```bash +make test +``` -If some assets fail but at least one succeeds, the command still succeeds and reports the number exported. +Run all package coverage: -If all exports fail, the command returns an error. +```bash +go test -tags=test -race -count=1 -coverprofile=coverage.out ./... +go tool cover -func=coverage.out +``` -## Signal Handling +Run the release pipeline: -Sending `SIGINT` (Ctrl+C) or `SIGTERM` during export or backup triggers a graceful shutdown: +```bash +make pipeline +``` -1. The CLI prints `received signal, finishing current file...` to stderr -2. The current file export is allowed to complete -3. No further files are started -4. The process exits after the in-progress file finishes +Create a release after committing and tagging: -A second signal forces an immediate exit. +```bash +make release +``` ## Architecture -- `cmd/photoscli`: CLI entrypoint, argument parsing, progress display, and album name resolution -- `internal/photos`: Go bridge interface, JSON parsing, and error mapping -- `bridge/`: Objective-C PhotoKit implementation plus a C test stub +- `cmd/photoscli`: CLI, argument handling, filtering, manifests, progress, reporting, and command orchestration. +- `internal/photos`: Go interface and JSON parsing for PhotoKit responses. +- `internal/manifest`: JSONL/SQLite manifest backends and log writers. +- `bridge/`: Objective-C PhotoKit implementation plus a C stub used by tests. -Data passed from Objective-C to Go is serialized as JSON and unmarshaled into Go structs. +Objective-C returns JSON to Go. Tests use the stub bridge and do not require real Photos access. -## Known Limitations +## Known Notes -- The tree view only shows user collections exposed through PhotoKit's top-level user collections API -- Album title resolution matches the first album with that title; if multiple albums share a title, use the local identifier instead -- `photos` only prints asset IDs and filenames, not dates or metadata -- Preview export uses PhotoKit preview rendering, not original file export -- Original export currently writes the first PhotoKit asset resource for each asset, which may not capture every related representation for complex assets -- iCloud-backed assets may require network download during export -- A second interrupt signal forces an immediate exit without waiting for the current file -- Partial export failures are not listed individually \ No newline at end of file +- This project is macOS-only. +- Preview export currently follows the existing bridge preview implementation; `--format heic|png` is validated as an output-format hint but still needs bridge support for true non-JPEG previews. +- Album title lookup uses exact title matching. Use PhotoKit local identifiers when names are ambiguous. +- iCloud-backed assets may trigger downloads and can fail due to network or account state. +- `--min-size` and `--max-size` currently use estimated pixel count from dimensions, not encoded file size. diff --git a/bridge/photokit_bridge.h b/bridge/photokit_bridge.h index 1034375..d191806 100644 --- a/bridge/photokit_bridge.h +++ b/bridge/photokit_bridge.h @@ -24,6 +24,7 @@ char *photos_export_preview_json( const char *asset_id, const char *output_dir, int target_size, + int quality, int index, int slot_index ); @@ -44,6 +45,7 @@ int photos_request_is_cancelled(void); void photos_free_string(char *value); export_progress_t *photos_get_progress_slots(void); +export_progress_t photos_get_progress_slot(export_progress_t *slots, int index); int photos_get_progress_slot_count(void); void photos_reset_progress_slots(void); diff --git a/bridge/photokit_bridge.m b/bridge/photokit_bridge.m index 138fd85..8e0dffa 100644 --- a/bridge/photokit_bridge.m +++ b/bridge/photokit_bridge.m @@ -7,7 +7,7 @@ static volatile int photos_cancelled = 0; -#define PROGRESS_SLOT_COUNT 3 +#define PROGRESS_SLOT_COUNT 16 static export_progress_t progress_slots[PROGRESS_SLOT_COUNT]; static void reset_slot(int slot_index) { @@ -42,7 +42,7 @@ static NSDictionary *collection_to_dict(PHCollection *collection) { if ([collection isKindOfClass:[PHCollectionList class]]) { PHCollectionList *list = (PHCollectionList *)collection; PHFetchResult<PHCollection *> *children = [PHCollectionList fetchCollectionsInCollectionList:list - options:nil]; + options:nil]; NSMutableArray *childList = [NSMutableArray arrayWithCapacity:children.count]; for (NSUInteger i = 0; i < children.count; i++) { NSDictionary *child = collection_to_dict(children[i]); @@ -107,7 +107,7 @@ static BOOL ensure_directory(NSString *outputDir) { NSFileManager *fm = [NSFileManager defaultManager]; NSError *dirErr = nil; BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES - attributes:nil error:&dirErr]; + attributes:nil error:&dirErr]; if (!ok && dirErr) { NSLog(@"ensure_directory failed: %@", dirErr); } @@ -151,7 +151,6 @@ static NSString *unique_path_for_filename(NSString *outputDir, NSString *filenam return [outputDir stringByAppendingPathComponent:prefixed]; } -// Must stay in sync with sanitizePathComponent in cmd/photoscli/main.go static NSString *sanitized_path_component(NSString *name) { NSString *source = name ?: @"Untitled"; NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy]; @@ -170,6 +169,85 @@ static NSString *sanitized_path_component(NSString *name) { return safe; } +static BOOL resource_is_locally_available(PHAssetResource *res) { + @try { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = @selector(isLocallyAvailable); + if ([res respondsToSelector:sel]) { + return ((BOOL (*)(id, SEL))objc_msgSend)(res, sel); + } +#pragma clang diagnostic pop + } @catch (NSException *e) {} + return NO; +} + +static NSArray<PHAssetResource *> *safe_asset_resources(PHAsset *asset) { + @try { + @autoreleasepool { + NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset]; + return resources ?: @[]; + } + } @catch (NSException *e) { + NSLog(@"photoscli: exception getting resources for asset %@: %@", asset.localIdentifier, e.reason); + return @[]; + } +} + +static NSString *asset_cloud_status_string_safe(PHAsset *asset) { + @try { + @autoreleasepool { + if (@available(macOS 11, *)) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = @selector(isCloudShared); + if ([asset respondsToSelector:sel]) { + BOOL isShared = ((BOOL (*)(id, SEL))objc_msgSend)(asset, sel); + if (isShared) return @"cloud"; + } +#pragma clang diagnostic pop + } + } + } @catch (NSException *exception) { + NSLog(@"photoscli: exception checking isCloudShared for %@: %@", asset.localIdentifier, exception.reason); + } + + PHCloudIdentifier *cloudId = nil; + @try { + @autoreleasepool { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + cloudId = [asset performSelector:@selector(cloudIdentifier)]; +#pragma clang diagnostic pop + } + } @catch (NSException *exception) { + cloudId = nil; + } + if (cloudId && ![cloudId isEqual:[NSNull null]]) { + return @"cloud"; + } + + BOOL isInCloud = NO; + @try { + @autoreleasepool { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + SEL sel = @selector(isInCloud); + if ([asset respondsToSelector:sel]) { + isInCloud = ((BOOL (*)(id, SEL))objc_msgSend)(asset, sel); + } +#pragma clang diagnostic pop + } + } @catch (NSException *exception) { + isInCloud = NO; + } + if (isInCloud) { + return @"cloud"; + } + + return @"local"; +} + int photos_request_access(void) { __block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus]; if (status == PHAuthorizationStatusNotDetermined) { @@ -247,42 +325,6 @@ static NSString *iso8601_string(NSDate *date) { #import <objc/message.h> -static BOOL resource_is_locally_available(PHAssetResource *res) { - @try { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - SEL sel = @selector(isLocallyAvailable); - if ([res respondsToSelector:sel]) { - return ((BOOL (*)(id, SEL))objc_msgSend)(res, sel); - } -#pragma clang diagnostic pop - } @catch (NSException *e) {} - return NO; -} - -static NSString *asset_cloud_status_string(PHAsset *asset) { - @try { - id cloudId = [asset performSelector:@selector(cloudIdentifier)]; - if (cloudId && ![cloudId isEqual:[NSNull null]]) { - return @"cloud"; - } - } @catch (NSException *exception) { - } - PHAssetResource *resource = nil; - @try { - resource = [PHAssetResource assetResourcesForAsset:asset].firstObject; - } @catch (NSException *e) { - resource = nil; - } - if (resource) { - if (resource_is_locally_available(resource)) { - return @"local"; - } - return @"cloud"; - } - return @"local"; -} - char *photos_list_assets_json(const char *album_id) { if (!album_id) return json_from_object(make_error_dict(@"album_id is required")); @@ -305,16 +347,11 @@ char *photos_list_assets_json(const char *album_id) { PHAsset *asset = assets[i]; NSString *lid = asset.localIdentifier; NSString *filename = nil; - NSArray<PHAssetResource *> *resources = nil; - @try { - resources = [PHAssetResource assetResourcesForAsset:asset]; - } @catch (NSException *e) { - resources = @[]; - } + NSArray<PHAssetResource *> *resources = safe_asset_resources(asset); if (resources.count > 0) { filename = resources.firstObject.originalFilename; } - NSString *cloudStatus = asset_cloud_status_string(asset); + NSString *cloudStatus = asset_cloud_status_string_safe(asset); NSString *mediaTypeStr = media_type_string(asset.mediaType); NSString *creationDateStr = iso8601_string(asset.creationDate); @@ -374,9 +411,10 @@ char *photos_list_tree_json(void) { #define RETURN_PREVIEW(x) do { reset_slot(slot_index); return (x); } while(0) -char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index, int slot_index) { +char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int quality, int index, int slot_index) { if (!asset_id || !output_dir) RETURN_PREVIEW(json_from_object(make_error_dict(@"asset_id and output_dir required"))); if (target_size <= 0) target_size = 1024; + if (quality <= 0 || quality > 100) quality = 85; NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; @@ -427,17 +465,18 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i return; } if (result) { - imageData = nsimage_to_jpeg(result, 0.85); + CGFloat compression = (CGFloat)quality / 100.0; + imageData = nsimage_to_jpeg(result, compression); } dispatch_semaphore_signal(sem); }]; if (!semaphore_wait_with_timeout(sem, 120)) { - RETURN_PREVIEW(json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)})); + RETURN_PREVIEW(json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string_safe(asset)})); } if (!imageData) { - RETURN_PREVIEW(json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)})); + RETURN_PREVIEW(json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string_safe(asset)})); } NSString *safe = sanitized_asset_identifier(asset.localIdentifier); @@ -452,7 +491,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i RETURN_PREVIEW(json_from_object(@{ @"filename": filename, @"size": existingSize, - @"cloud": asset_cloud_status_string(asset), + @"cloud": asset_cloud_status_string_safe(asset), @"skipped": @YES })); } @@ -461,7 +500,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i NSError *writeErr = nil; if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error"; - RETURN_PREVIEW(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)})); + RETURN_PREVIEW(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string_safe(asset)})); } NSNumber *fileSize = nil; @@ -473,7 +512,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i RETURN_PREVIEW(json_from_object(@{ @"filename": filename, @"size": fileSize ?: @0, - @"cloud": asset_cloud_status_string(asset) + @"cloud": asset_cloud_status_string_safe(asset) })); } #undef RETURN_PREVIEW @@ -503,14 +542,9 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, progress_slots[slot_index].bytes_total = 0; } - NSArray<PHAssetResource *> *resources = nil; - @try { - resources = [PHAssetResource assetResourcesForAsset:asset]; - } @catch (NSException *e) { - resources = @[]; - } + NSArray<PHAssetResource *> *resources = safe_asset_resources(asset); if (resources.count == 0) { - RETURN_ORIGINAL(json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(asset)})); + RETURN_ORIGINAL(json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string_safe(asset)})); } PHAssetResource *resource = resources.firstObject; @@ -531,7 +565,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, RETURN_ORIGINAL(json_from_object(@{ @"filename": filename, @"size": existingSize, - @"cloud": asset_cloud_status_string(asset), + @"cloud": asset_cloud_status_string_safe(asset), @"skipped": @YES })); } @@ -563,7 +597,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, dispatch_semaphore_signal(sem); }]; if (!semaphore_wait_with_timeout(sem, 120)) { - RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)})); + RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string_safe(asset)})); } if (writeErr) { @@ -591,7 +625,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, }]; if (!semaphore_wait_with_timeout(sem2, 120)) { - RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for image data", @"cloud": asset_cloud_status_string(asset)})); + RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for image data", @"cloud": asset_cloud_status_string_safe(asset)})); } if (fallbackErr || !fallbackData) { @@ -599,7 +633,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, if (fallbackErr) { detail = [NSString stringWithFormat:@"%@; fallback: %@", detail, fallbackErr.localizedDescription]; } - RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", detail], @"cloud": asset_cloud_status_string(asset)})); + RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", detail], @"cloud": asset_cloud_status_string_safe(asset)})); } NSString *ext = nil; @@ -622,7 +656,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, NSError *writeFallbackErr = nil; if (![fallbackData writeToFile:fallbackPath options:NSDataWritingAtomic error:&writeFallbackErr]) { - RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeFallbackErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)})); + RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeFallbackErr.localizedDescription], @"cloud": asset_cloud_status_string_safe(asset)})); } NSNumber *fileSize = nil; @@ -634,7 +668,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, RETURN_ORIGINAL(json_from_object(@{ @"filename": fallbackFilename, @"size": fileSize ?: @0, - @"cloud": asset_cloud_status_string(asset) + @"cloud": asset_cloud_status_string_safe(asset) })); } @@ -648,7 +682,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir, RETURN_ORIGINAL(json_from_object(@{ @"filename": writtenFilename, @"size": fileSize ?: @0, - @"cloud": asset_cloud_status_string(asset) + @"cloud": asset_cloud_status_string_safe(asset) })); } #undef RETURN_ORIGINAL @@ -675,4 +709,4 @@ int photos_get_progress_slot_count(void) { void photos_reset_progress_slots(void) { memset(progress_slots, 0, sizeof(progress_slots)); -} +} \ No newline at end of file diff --git a/bridge/photokit_bridge_stub.c b/bridge/photokit_bridge_stub.c index f277274..339dcf6 100644 --- a/bridge/photokit_bridge_stub.c +++ b/bridge/photokit_bridge_stub.c @@ -2,7 +2,9 @@ #include <string.h> #include "../bridge/photokit_bridge.h" -static export_progress_t stub_progress_slots[3]; +static export_progress_t stub_progress_slots[16]; +static int stub_progress_slot_count = 16; +static int stub_progress_slots_null = 0; static char *alloc_json(const char *s) { size_t len = strlen(s); @@ -49,10 +51,11 @@ char *photos_list_assets_json(const char *album_id) { return alloc_json(stub_assets_json); } -char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index, int slot_index) { +char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int quality, int index, int slot_index) { (void)asset_id; (void)output_dir; (void)target_size; + (void)quality; (void)index; (void)slot_index; return maybe_alloc_json(stub_export_preview_json); @@ -87,18 +90,43 @@ void photos_test_set_export_preview_json(const char *json) { stub_export_preview_json = json; } +void photos_test_set_export_preview_json_null(void) { + stub_export_preview_json = NULL; +} + void photos_test_set_export_original_json(const char *json) { stub_export_original_json = json; } +void photos_test_set_export_original_json_null(void) { + stub_export_original_json = NULL; +} + export_progress_t *photos_get_progress_slots(void) { + if (stub_progress_slots_null) return NULL; return stub_progress_slots; } +export_progress_t photos_get_progress_slot(export_progress_t *slots, int index) { + export_progress_t result = {0, 0.0, 0, 0}; + if (index >= 0 && index < 16) { + result = slots[index]; + } + return result; +} + int photos_get_progress_slot_count(void) { - return 3; + return stub_progress_slot_count; } void photos_reset_progress_slots(void) { memset(stub_progress_slots, 0, sizeof(stub_progress_slots)); -} \ No newline at end of file +} + +void photos_test_set_progress_slot_count(int count) { + stub_progress_slot_count = count; +} + +void photos_test_set_progress_slots_null(int val) { + stub_progress_slots_null = val; +} diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go index 81b9be5..b9183e6 100644 --- a/cmd/photoscli/main.go +++ b/cmd/photoscli/main.go @@ -1,21 +1,58 @@ package main import ( + "encoding/json" "fmt" "io" "os" "path/filepath" + "sort" + "strconv" "strings" "sync" "time" + "gitea.k3s.k0.nu/tools/photocli/internal/manifest" "gitea.k3s.k0.nu/tools/photocli/internal/photos" ) +var ( + progressPollInterval = 100 * time.Millisecond + exportTimeout = 2 * time.Second + configValues map[string]string + configLoaded bool +) + +type exportOptions struct { + dryRun bool + retry int + onlyFavorites bool + media string + jsonOut bool + verify bool + format string + minSize int64 + maxSize int64 + dateTemplate string +} + +type commandSummary struct { + Exported int `json:"exported"` + Failed int `json:"failed"` + Total int `json:"total"` +} + +const ( + exitOK = 0 + exitErr = 1 + exitPartial = 2 + exitAuth = 3 +) + func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { if len(args) < 1 { usage(stderr) - return 1 + return exitErr } cmd := args[0] @@ -30,70 +67,245 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { return cmdBackupAll(args[1:], stdout, stderr, bridge) case "export": return cmdExport(args[1:], stdout, stderr, bridge) + case "report": + return cmdReport(args[1:], stdout, stderr) + case "diff": + return cmdDiff(args[1:], stdout, stderr, bridge) + case "verify": + return cmdVerify(args[1:], stdout, stderr) + case "retry-failed": + return cmdRetryFailed(args[1:], stdout, stderr, bridge) case "version", "--version", "-v": fmt.Fprintln(stdout, version) - return 0 + return exitOK case "help", "--help", "-h": usage(stderr) - return 0 + return exitOK default: fmt.Fprintf(stderr, "unknown command: %s\n", cmd) usage(stderr) - return 1 + return exitErr } } func usage(w io.Writer) { - fmt.Fprintln(w, `photoscli — export optimized images from Apple Photos + fmt.Fprintln(w, `photoscli - export and verify Apple Photos backups -Usage: +DESCRIPTION + photoscli is a macOS-only Apple Photos exporter. It uses PhotoKit through a + small Objective-C bridge to list albums, inspect assets, export previews or + originals, keep resumable manifests, log structured export events, and verify + backup integrity. + + The tool is intended for repeatable backups. By default it records exported + asset IDs in a manifest so later runs can skip work already completed. + +USAGE photoscli albums - photoscli photos --album-id <id> + photoscli photos --album-id <id-or-title> photoscli tree - photoscli backup-all --out <dir> [--size <px>] [--originals] [--include-videos] - photoscli export --album-id <id> --out <dir> [--size <px>] [--originals] [--include-videos] + photoscli export --album-id <id-or-title> --out <dir> [flags] + photoscli backup-all --out <dir> [flags] + photoscli report --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 retry-failed --out <dir> photoscli version + photoscli help -Commands: - albums List user-created albums - photos List photo assets in an album - tree Show folder and album hierarchy - backup-all Export all albums into the Photos folder tree - export Export optimized JPEG previews or original files - version Print version +COMMANDS + albums + Request Photos access and list user-created albums as: + <album-id><TAB><album-title> -Flags: - --album-id <id> Album local identifier or title (required for photos/export) - --out <dir> Output directory (required for export/backup-all) - --size <px> Target longest-side in pixels (default: 1024, preview export only) - --originals Export original files instead of JPEG previews - --include-videos Include video assets (videos are skipped by default)`) + photos --album-id <id-or-title> + List assets in one album. The album can be a PhotoKit local identifier or + an exact album title. Output includes asset ID, filename, cloud state, + media type, dimensions, optional creation date, optional duration, and a + trailing * for favorites. + + tree + Print the Photos folder/album hierarchy as an indented tree. + + export --album-id <id-or-title> --out <dir> [flags] + Export assets from one album. Preview JPEGs are exported by default. + Use --originals to export original files instead. + + backup-all --out <dir> [flags] + Walk the Photos tree and export every album into a matching directory + structure. Duplicate sibling album names are disambiguated with album IDs. + + report --out <dir> [--manifest jsonl|sqlite] + Print manifest entry count and failure count. + + diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite] + Compare album assets against the manifest. Missing assets are printed as + <asset-id><TAB><filename>. Exits 2 when differences are found. + + verify --out <dir> [--manifest jsonl|sqlite] + Verify that manifest entries point to files that exist on disk. Missing + files are printed as <asset-id><TAB><filename>. Exits 2 on missing files. + + retry-failed --out <dir> + Retry assets previously written to failures.jsonl. + +COMMON EXPORT FLAGS + --out <dir> + Destination directory. Required for export, backup-all, report, diff, + verify, and retry-failed. + + --album-id <id-or-title> + Album PhotoKit local identifier or exact album title. Required by export, + photos, and diff. + + --size <px> + Target longest-side preview size in pixels. Default: 1024. Ignored when + --originals is used. + + --quality <1-100> + JPEG preview compression quality. Default: 85. Ignored when --originals is + used. + + --originals + Export original files instead of preview JPEGs. + + --concurrency <N> + Number of parallel export workers. Default: 3. Values above the bridge's + progress slot count are capped automatically. + + --retry <N> + Retry failed exports N times with a small backoff. Default: 0. + + --dry-run + Print assets that would be exported without writing files, manifests, or + logs. Useful before large backup-all runs. + + --json + Print a machine-readable summary to stdout: + {"exported":N,"failed":N,"total":N} + + --verify + Run manifest/file verification after export or backup-all. + +FILTERING AND SELECTION + --since <date> + Include only assets on or after a date. Accepts YYYY-MM-DD or RFC3339. + + --sort oldest|newest + Sort assets by creation date. Default: oldest. + + --only-favorites + Export only favorite assets. + + --media photos|videos|all + Select media type. Default: photos. + + --include-videos + Compatibility shortcut that includes videos/audio. + + --exclude-album <pattern> + backup-all only. Repeatable. Excludes album names by exact match or glob + pattern, for example --exclude-album "Temp*". + + --min-size <n> + Include only assets with estimated pixel count >= n. + + --max-size <n> + Include only assets with estimated pixel count <= n. + +OUTPUT LAYOUT AND FORMAT + --date-template <template> + Append date folders based on asset creation date. Supported tokens: + YYYY, MM, DD. Example: --date-template YYYY/MM/DD. + + --format jpeg|heic|png + Preview output format hint. Currently validated by the CLI; the bridge's + preview export path still writes the existing preview output format. + +MANIFESTS + --manifest jsonl|sqlite + Manifest backend. Default: jsonl. + + jsonl -> downloads.jsonl + sqlite -> downloads.db + + --no-manifest + Disable manifest reads and writes. This makes export stateless and removes + resumable skip behavior. + +LOGGING AND FAILURES + --log + Enable structured export logs. With JSONL/no-manifest mode logs are + written to export.log. With SQLite manifests logs are written to a logs + table in downloads.db. + + failures.jsonl + Failed exports are appended here and can be retried with retry-failed. + +CONFIGURATION + Defaults can be read from ~/.photoscli.toml or from PHOTOSCLI_CONFIG. + Keys match long flag names without leading dashes: + + size = 2048 + quality = 90 + concurrency = 8 + manifest = "sqlite" + sort = "newest" + retry = 3 + log = true + + Command-line flags override config values. + +EXAMPLES + photoscli albums + photoscli photos --album-id "Vacation" + photoscli export --album-id "Vacation" --out ./export --size 2048 --quality 92 + photoscli export --album-id "Favorites" --out ./favorites --only-favorites + photoscli export --album-id "Archive" --out ./archive --originals --retry 3 + photoscli backup-all --out ./backup --manifest sqlite --log --concurrency 8 + photoscli backup-all --out ./backup --dry-run --json + photoscli backup-all --out ./backup --exclude-album "Recently Deleted" --exclude-album "Temp*" + photoscli backup-all --out ./backup --since 2024-01-01 --date-template YYYY/MM/DD + photoscli report --out ./backup --manifest sqlite + photoscli diff --album-id "Vacation" --out ./backup + photoscli verify --out ./backup --manifest sqlite + photoscli retry-failed --out ./backup + +EXIT CODES + 0 Success. + 1 Error, invalid arguments, or runtime failure. + 2 Partial failure, missing diff entries, or verify failures. + 3 Photos access denied. + +PERMISSIONS + On first use, macOS may prompt for Photos access. If denied, grant access in: + System Settings > Privacy & Security > Photos`) } func mustAuth(stderr io.Writer, bridge photos.Bridge) int { fmt.Fprintln(stderr, "requesting photo library access...") if err := bridge.RequestAccess(); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitAuth } fmt.Fprintln(stderr, "access granted") - return 0 + return exitOK } func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int { - if rc := mustAuth(stderr, bridge); rc != 0 { + if rc := mustAuth(stderr, bridge); rc != exitOK { return rc } fmt.Fprintln(stderr, "loading albums...") albums, err := bridge.ListAlbums() if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitErr } for _, a := range albums { fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Title) } - return 0 + return exitOK } func resolveAlbumID(bridge photos.Bridge, idOrName string) (string, error) { @@ -117,20 +329,20 @@ func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in albumID := flagVal(args, "--album-id") if albumID == "" { fmt.Fprintln(stderr, "error: --album-id is required") - return 1 + return exitErr } - if rc := mustAuth(stderr, bridge); rc != 0 { + if rc := mustAuth(stderr, bridge); rc != exitOK { return rc } resolved, err := resolveAlbumID(bridge, albumID) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitErr } assets, _, err := bridge.ListAssets(resolved) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitErr } for _, a := range assets { fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%dx%d", a.ID, a.Filename, a.Cloud, a.MediaType, a.PixelWidth, a.PixelHeight) @@ -145,22 +357,22 @@ func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in } fmt.Fprintln(stdout) } - return 0 + return exitOK } func cmdTree(stdout, stderr io.Writer, bridge photos.Bridge) int { - if rc := mustAuth(stderr, bridge); rc != 0 { + if rc := mustAuth(stderr, bridge); rc != exitOK { return rc } nodes, err := bridge.ListTree() if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitErr } for _, node := range nodes { printNode(stdout, node, 0) } - return 0 + return exitOK } func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { @@ -168,52 +380,145 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in outDir := flagVal(args, "--out") originals := hasFlag(args, "--originals") skipVideos := !hasFlag(args, "--include-videos") + noManifest := hasFlag(args, "--no-manifest") + manifestFmt := flagValWithDefault(args, "--manifest", "jsonl") + sortOrder := flagValWithDefault(args, "--sort", "oldest") sizeStr := flagValWithDefault(args, "--size", "1024") - + qualityStr := flagValWithDefault(args, "--quality", "85") + concurrencyStr := flagValWithDefault(args, "--concurrency", "3") + sinceStr := flagVal(args, "--since") + enableLog := hasFlag(args, "--log") + opts, ok := parseExportOptions(args, stderr) + if !ok { + return exitErr + } + if hasFlag(args, "--include-videos") { + opts.media = "all" + } if albumID == "" { fmt.Fprintln(stderr, "error: --album-id is required") - return 1 + return exitErr } if outDir == "" { fmt.Fprintln(stderr, "error: --out is required") - return 1 + return exitErr } - if rc := mustAuth(stderr, bridge); rc != 0 { + mf, mfErr := manifest.ParseFormat(manifestFmt) + if mfErr != nil { + fmt.Fprintf(stderr, "error: %v\n", mfErr) + return exitErr + } + + sortNewest := sortOrder == "newest" + if sortOrder != "oldest" && sortOrder != "newest" { + fmt.Fprintf(stderr, "error: --sort must be newest or oldest, got %q\n", sortOrder) + return exitErr + } + + if rc := mustAuth(stderr, bridge); rc != exitOK { return rc } resolved, err := resolveAlbumID(bridge, albumID) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitErr } var size int if !originals { if _, err2 := fmt.Sscanf(sizeStr, "%d", &size); err2 != nil || size <= 0 { fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr) - return 1 + return exitErr } } + var quality int + if !originals { + if _, err2 := fmt.Sscanf(qualityStr, "%d", &quality); err2 != nil || quality < 1 || quality > 100 { + fmt.Fprintf(stderr, "error: --quality must be 1-100, got %q\n", qualityStr) + return exitErr + } + } + + var concurrency int + if _, err2 := fmt.Sscanf(concurrencyStr, "%d", &concurrency); err2 != nil || concurrency < 1 { + fmt.Fprintf(stderr, "error: --concurrency must be a positive integer, got %q\n", concurrencyStr) + return exitErr + } + maxSlots := photos.GetProgressSlotCount() + if concurrency > maxSlots { + concurrency = maxSlots + } + fmt.Fprintf(stderr, "loading assets for album %s...\n", albumID) assets, total, err := bridge.ListAssets(resolved) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitErr } - if skipVideos { + if hasFlag(args, "--include-videos") { + opts.media = "all" + } + if skipVideos && opts.media == "photos" { assets, total = filterVideos(assets) } + assets = applyAssetFilters(assets, opts) + total = len(assets) + if sinceStr != "" { + since, err := parseSinceDate(sinceStr) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return exitErr + } + assets = filterBySince(assets, since) + total = len(assets) + } + + if sortNewest { + sort.Slice(assets, func(i, j int) bool { + di := assets[i].CreationDate + dj := assets[j].CreationDate + if di == nil && dj == nil { + return assets[i].ID < assets[j].ID + } + if di == nil { + return false + } + if dj == nil { + return true + } + return *di > *dj + }) + } + + if opts.dryRun { + for _, a := range assets { + fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Filename) + } + if opts.jsonOut { + writeJSONSummary(stdout, commandSummary{Total: total}) + } + fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir) + return exitOK + } fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir) - exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "") + exported, failed := exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts) + if opts.jsonOut { + writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total}) + } + if opts.verify && !noManifest { + if rc := cmdVerify([]string{"--out", outDir, "--manifest", manifestFmt}, stdout, stderr); rc != exitOK && failed == 0 { + failed++ + } + } if exported == 0 && failed > 0 { fmt.Fprintf(stderr, "\nerror: all exports failed\n") - return 1 + return exitErr } if originals { @@ -225,21 +530,51 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in fmt.Fprintf(stderr, " (%d failed)", failed) } fmt.Fprintln(stderr) - return 0 + if failed > 0 && exported > 0 { + return exitPartial + } + return exitOK } func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { outDir := flagVal(args, "--out") originals := hasFlag(args, "--originals") skipVideos := !hasFlag(args, "--include-videos") + noManifest := hasFlag(args, "--no-manifest") + manifestFmt := flagValWithDefault(args, "--manifest", "jsonl") + sortOrder := flagValWithDefault(args, "--sort", "oldest") sizeStr := flagValWithDefault(args, "--size", "1024") + qualityStr := flagValWithDefault(args, "--quality", "85") + concurrencyStr := flagValWithDefault(args, "--concurrency", "3") + excludeAlbums := flagVals(args, "--exclude-album") + sinceStr := flagVal(args, "--since") + enableLog := hasFlag(args, "--log") + opts, ok := parseExportOptions(args, stderr) + if !ok { + return exitErr + } + if hasFlag(args, "--include-videos") { + opts.media = "all" + } if outDir == "" { fmt.Fprintln(stderr, "error: --out is required") - return 1 + return exitErr } - if rc := mustAuth(stderr, bridge); rc != 0 { + mf, mfErr := manifest.ParseFormat(manifestFmt) + if mfErr != nil { + fmt.Fprintf(stderr, "error: %v\n", mfErr) + return exitErr + } + + sortNewest := sortOrder == "newest" + if sortOrder != "oldest" && sortOrder != "newest" { + fmt.Fprintf(stderr, "error: --sort must be newest or oldest, got %q\n", sortOrder) + return exitErr + } + + if rc := mustAuth(stderr, bridge); rc != exitOK { return rc } @@ -247,7 +582,35 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) if !originals { if _, err := fmt.Sscanf(sizeStr, "%d", &size); err != nil || size <= 0 { fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr) - return 1 + return exitErr + } + } + + var quality int + if !originals { + if _, err := fmt.Sscanf(qualityStr, "%d", &quality); err != nil || quality < 1 || quality > 100 { + fmt.Fprintf(stderr, "error: --quality must be 1-100, got %q\n", qualityStr) + return exitErr + } + } + + var concurrency int + if _, err := fmt.Sscanf(concurrencyStr, "%d", &concurrency); err != nil || concurrency < 1 { + fmt.Fprintf(stderr, "error: --concurrency must be a positive integer, got %q\n", concurrencyStr) + return exitErr + } + maxSlots := photos.GetProgressSlotCount() + if concurrency > maxSlots { + concurrency = maxSlots + } + + var sinceTime time.Time + if sinceStr != "" { + var err error + sinceTime, err = parseSinceDate(sinceStr) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return exitErr } } @@ -255,15 +618,26 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) nodes, err := bridge.ListTree() if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitErr } albumCount := countAlbums(nodes) fmt.Fprintf(stderr, "found %d albums, building index...\n", albumCount) - totalAssets, failed, err := backupTree(nodes, outDir, size, originals, skipVideos, stderr, bridge) + if opts.dryRun { + pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts) + for _, pa := range pending { + fmt.Fprintf(stdout, "%s\t%s\t%s\n", pa.asset.ID, pa.album, pa.asset.Filename) + } + if opts.jsonOut { + writeJSONSummary(stdout, commandSummary{Total: len(pending)}) + } + fmt.Fprintf(stderr, "dry-run: %d assets would be exported (%d skipped)\n", len(pending), skipped) + return exitOK + } + totalAssets, failed, err := backupTree(nodes, outDir, size, quality, concurrency, originals, skipVideos, stderr, bridge, noManifest, mf, sortNewest, excludeAlbums, sinceTime, enableLog, opts) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) - return 1 + return exitErr } if originals { @@ -275,74 +649,193 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) fmt.Fprintf(stderr, " (%d failed)", failed) } fmt.Fprintln(stderr) - return 0 + if opts.jsonOut { + writeJSONSummary(stdout, commandSummary{Exported: totalAssets, Failed: failed, Total: totalAssets + failed}) + } + if opts.verify && !noManifest { + if rc := cmdVerify([]string{"--out", outDir, "--manifest", manifestFmt}, stdout, stderr); rc != exitOK && failed == 0 { + failed++ + } + } + if failed > 0 && totalAssets > 0 { + return exitPartial + } + return exitOK } type pendingAsset struct { - asset photos.Asset - path string - album string + asset photos.Asset + path string + album string } -func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, stderr io.Writer) ([]pendingAsset, int) { +type collectProgress struct { + pending int + skipped int + album string + err error +} + +func logEntry(event, level, assetID, album, filename, cloud string, size int64, durationMs int64, message string) manifest.LogEntry { + return manifest.LogEntry{ + Timestamp: time.Now().Unix(), + Level: level, + Event: event, + AssetID: assetID, + Album: album, + Filename: filename, + Size: size, + Cloud: cloud, + DurationMs: durationMs, + Message: message, + } +} + +func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) { var items []pendingAsset var skipped int - collectNodes(nodes, outDir, bridge, skipVideos, originals, &items, &skipped, stderr) + collectNodes(nodes, outDir, bridge, skipVideos, originals, &items, &skipped, onProgress, m, exclude, opts) + if sortNewest { + sort.Slice(items, func(i, j int) bool { + di := items[i].asset.CreationDate + dj := items[j].asset.CreationDate + if di == nil && dj == nil { + return items[i].asset.ID < items[j].asset.ID + } + if di == nil { + return false + } + if dj == nil { + return true + } + return *di > *dj + }) + } + if !since.IsZero() { + filtered := make([]pendingAsset, 0, len(items)) + for _, pa := range items { + if pa.asset.CreationDate != nil { + t, err := time.Parse(time.RFC3339, *pa.asset.CreationDate) + if err == nil && t.Before(since) { + skipped++ + continue + } + } + filtered = append(filtered, pa) + } + items = filtered + } return items, skipped } -func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, stderr io.Writer) { +func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, onProgress func(collectProgress), m manifest.Manifest, exclude []string, opts exportOptions) { + names := make(map[string]int) + for _, node := range nodes { + names[node.Name]++ + } for _, node := range nodes { if bridge.IsCancelled() { return } - path := outDir + "/" + sanitizePathComponent(node.Name) + name := node.Name + if names[node.Name] > 1 && node.ID != "" { + name = fmt.Sprintf("%s (%s)", node.Name, sanitizePathComponent(node.ID)) + } + path := outDir + "/" + sanitizePathComponent(name) if node.Kind == "folder" { - collectNodes(node.Children, path, bridge, skipVideos, originals, items, skipped, stderr) + collectNodes(node.Children, path, bridge, skipVideos, originals, items, skipped, onProgress, m, exclude, opts) continue } if node.Kind == "album" && node.ID != "" { - assets, _, err := bridge.ListAssets(node.ID) - if err != nil { - fmt.Fprintf(stderr, " \u26a0 album %s: %v\n", node.Name, err) + if isExcluded(node.Name, exclude) { + if onProgress != nil { + onProgress(collectProgress{pending: len(*items), skipped: *skipped, album: node.Name}) + } continue } - if skipVideos { + assets, _, err := bridge.ListAssets(node.ID) + if err != nil { + if onProgress != nil { + onProgress(collectProgress{pending: len(*items), skipped: *skipped, album: node.Name, err: err}) + } + continue + } + if skipVideos && opts.media == "photos" { assets, _ = filterVideos(assets) } + assets = applyAssetFilters(assets, opts) for _, a := range assets { - if fileExistsOnDisk(a, path, originals, len(*items)+*skipped) { + if m != nil && m.Has(a.ID) { *skipped++ continue } - *items = append(*items, pendingAsset{asset: a, path: path, album: node.Name}) + *items = append(*items, pendingAsset{asset: a, path: pathWithDateTemplate(path, a, opts.dateTemplate), album: node.Name}) + } + if onProgress != nil { + onProgress(collectProgress{pending: len(*items), skipped: *skipped, album: node.Name}) } - fmt.Fprintf(stderr, " indexing: %d files (%d skipped)\r", len(*items), *skipped) } } } -func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, skipVideos bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) { - pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, stderr) +func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, quality, concurrency int, originals bool, skipVideos bool, stderr io.Writer, bridge photos.Bridge, noManifest bool, mf manifest.Format, sortNewest bool, exclude []string, since time.Time, enableLog bool, opts exportOptions) (int, int, error) { + var m manifest.Manifest + if !noManifest { + var err error + m, err = manifest.Open(outDir, mf) + if err != nil { + fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err) + } else { + if err := m.OpenAppend(); err != nil { + fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err) + } + defer m.Close() + } + } + var lw manifest.LogWriter = manifest.NoopLogWriter + if enableLog { + var err error + lw, err = manifest.OpenLogWriter(m, outDir) + if err != nil { + fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err) + lw = manifest.NoopLogWriter + } else { + defer lw.Close() + } + } + pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, func(p collectProgress) { + if p.err != nil { + fmt.Fprintf(stderr, " \u26a0 album %s: %v\n", p.album, p.err) + } else { + fmt.Fprintf(stderr, " indexing: %d files (%d skipped)\r", p.pending, p.skipped) + } + }, m, sortNewest, exclude, since, opts) if bridge.IsCancelled() { - return 0, 0, nil + return 0, 0, fmt.Errorf("cancelled") } total := len(pending) fmt.Fprintf(stderr, " indexed %d files (%d skipped), exporting to %s...\n", total, skipped, outDir) - bar := newProgressBar(stderr, 3) - exported, failed := exportPending(pending, targetSize, originals, total, bar, bridge) + bar := newProgressBar(stderr, concurrency) + lw.Log(logEntry("session_start", "info", "", "", "", "", 0, 0, fmt.Sprintf("version=%s size=%d quality=%d concurrency=%d", version, targetSize, quality, concurrency))) + exported, failed := exportPending(pending, targetSize, quality, originals, total, bar, bridge, m, concurrency, lw, opts) bar.clear() + if m != nil { + if err := m.Save(); err != nil { + fmt.Fprintf(stderr, " warning: could not save manifest: %v\n", err) + } + } + lw.Log(logEntry("session_end", "info", "", "", "", "", 0, 0, fmt.Sprintf("exported=%d failed=%d skipped=%d", exported, failed, skipped))) return exported, failed, nil } -func exportPending(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) { +func exportPending(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, concurrency int, lw manifest.LogWriter, opts exportOptions) (int, int) { if len(pending) < 4 { - return exportPendingSerial(pending, targetSize, originals, total, bar, bridge) + return exportPendingSerial(pending, targetSize, quality, originals, total, bar, bridge, m, lw, opts) } - return exportPendingParallel(pending, targetSize, originals, total, bar, bridge, 3) + return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts) } -func exportPendingSerial(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) { +func exportPendingSerial(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) { done := 0 failed := 0 var totalBytes int64 @@ -357,7 +850,7 @@ func exportPendingSerial(pending []pendingAsset, targetSize int, originals bool, bar.setAlbum(pa.album, 0, 0) bar.draw() start := time.Now() - result, exportErr := exportOne(bridge, pa.asset, pa.path, targetSize, originals, i) + result, exportErr := exportOneWithRetry(bridge, pa.asset, pa.path, targetSize, quality, originals, i, opts.retry) dur := time.Since(start) isErr := exportErr != nil isSkipped := result.Skipped @@ -367,8 +860,16 @@ func exportPendingSerial(pending []pendingAsset, targetSize int, originals bool, } if isErr { failed++ + appendFailure(pa.path, pa, exportErr) + } else if isSkipped { + if m != nil { + m.Add(pa.asset.ID, result.Filename, result.Size, result.Cloud) + } } else { done++ + if m != nil { + m.Add(pa.asset.ID, result.Filename, result.Size, result.Cloud) + } } avgSpeed := float64(0) if totalDur > 0 { @@ -380,19 +881,23 @@ func exportPendingSerial(pending []pendingAsset, targetSize int, originals bool, var logLine string if isErr { logLine = fmt.Sprintf("\u274c %s: %v", pa.asset.Filename, exportErr) + lw.Log(logEntry("export_fail", "error", pa.asset.ID, pa.album, pa.asset.Filename, "", result.Size, dur.Milliseconds(), exportErr.Error())) } else if isSkipped { logLine = fmt.Sprintf("\u23ed %s", result.Filename) + lw.Log(logEntry("export_skip", "info", pa.asset.ID, pa.album, result.Filename, result.Cloud, result.Size, 0, "")) } else if result.Cloud == "cloud" { logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", result.Filename, formatSize(result.Size), formatSpeed(avgSpeed)) + lw.Log(logEntry("export_done", "info", pa.asset.ID, pa.album, result.Filename, result.Cloud, result.Size, dur.Milliseconds(), "")) } else { logLine = fmt.Sprintf("\u2705 %s - %s - copied", result.Filename, formatSize(result.Size)) + lw.Log(logEntry("export_done", "info", pa.asset.ID, pa.album, result.Filename, result.Cloud, result.Size, dur.Milliseconds(), "")) } bar.logCompleted(logLine) } return done, failed } -func exportPendingParallel(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int) (int, int) { +func exportPendingParallel(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) { type resultEntry struct { result photos.ExportResult err error @@ -417,11 +922,7 @@ func exportPendingParallel(pending []pendingAsset, targetSize int, originals boo start := time.Now() var result photos.ExportResult var exportErr error - if originals { - result, exportErr = bridge.ExportOriginalWithSlot(pending[i].asset.ID, pending[i].path, i, workerID) - } else { - result, exportErr = bridge.ExportPreviewWithSlot(pending[i].asset.ID, pending[i].path, targetSize, i, workerID) - } + result, exportErr = exportOneWithSlotRetry(bridge, pending[i].asset, pending[i].path, targetSize, quality, originals, i, workerID, opts.retry) dur := time.Since(start) bar.setWorker(workerID, "", 0, "", "") completed <- resultEntry{result: result, err: exportErr, pa: pending[i], dur: dur} @@ -441,8 +942,10 @@ func exportPendingParallel(pending []pendingAsset, targetSize int, originals boo slots := photos.GetProgressSlots() pollDone := make(chan struct{}) + wg.Add(1) go func() { - ticker := time.NewTicker(100 * time.Millisecond) + defer wg.Done() + ticker := time.NewTicker(progressPollInterval) defer ticker.Stop() for { select { @@ -469,7 +972,7 @@ func exportPendingParallel(pending []pendingAsset, targetSize int, originals boo var entry resultEntry select { case entry = <-completed: - case <-time.After(2 * time.Second): + case <-time.After(exportTimeout): if bridge.IsCancelled() { close(pollDone) wg.Wait() @@ -490,8 +993,16 @@ func exportPendingParallel(pending []pendingAsset, targetSize int, originals boo } if isErr { failed++ + appendFailure(entry.pa.path, entry.pa, entry.err) + } else if isSkipped { + if m != nil { + m.Add(entry.pa.asset.ID, entry.result.Filename, entry.result.Size, entry.result.Cloud) + } } else { done++ + if m != nil { + m.Add(entry.pa.asset.ID, entry.result.Filename, entry.result.Size, entry.result.Cloud) + } } avgSpeed := float64(0) if totalDur > 0 { @@ -503,12 +1014,16 @@ func exportPendingParallel(pending []pendingAsset, targetSize int, originals boo var logLine string if isErr { logLine = fmt.Sprintf("\u274c %s: %v", entry.pa.asset.Filename, entry.err) + lw.Log(logEntry("export_fail", "error", entry.pa.asset.ID, entry.pa.album, entry.pa.asset.Filename, "", entry.result.Size, entry.dur.Milliseconds(), entry.err.Error())) } else if isSkipped { logLine = fmt.Sprintf("\u23ed %s", entry.result.Filename) + lw.Log(logEntry("export_skip", "info", entry.pa.asset.ID, entry.pa.album, entry.result.Filename, entry.result.Cloud, entry.result.Size, 0, "")) } else if entry.result.Cloud == "cloud" { logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", entry.result.Filename, formatSize(entry.result.Size), formatSpeed(avgSpeed)) + lw.Log(logEntry("export_done", "info", entry.pa.asset.ID, entry.pa.album, entry.result.Filename, entry.result.Cloud, entry.result.Size, entry.dur.Milliseconds(), "")) } else { logLine = fmt.Sprintf("\u2705 %s - %s - copied", entry.result.Filename, formatSize(entry.result.Size)) + lw.Log(logEntry("export_done", "info", entry.pa.asset.ID, entry.pa.album, entry.result.Filename, entry.result.Cloud, entry.result.Size, entry.dur.Milliseconds(), "")) } bar.logCompleted(logLine) } @@ -518,44 +1033,105 @@ func exportPendingParallel(pending []pendingAsset, targetSize int, originals boo return done, failed } -func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) { +func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, concurrency int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string, noManifest bool, mf manifest.Format, enableLog bool, opts exportOptions) (int, int) { pending := make([]pendingAsset, len(assets)) for i, a := range assets { - pending[i] = pendingAsset{asset: a, path: outDir, album: dirPrefix} + pending[i] = pendingAsset{asset: a, path: pathWithDateTemplate(outDir, a, opts.dateTemplate), album: dirPrefix} } - bar := newProgressBar(stderr, 1) - exported, failed := exportPending(pending, targetSize, originals, len(pending), bar, bridge) + var m manifest.Manifest + if !noManifest { + var err error + m, err = manifest.Open(outDir, mf) + if err != nil { + fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err) + } else { + if err := m.OpenAppend(); err != nil { + fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err) + } + defer m.Close() + } + } + var lw manifest.LogWriter = manifest.NoopLogWriter + if enableLog { + var err error + lw, err = manifest.OpenLogWriter(m, outDir) + if err != nil { + fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err) + lw = manifest.NoopLogWriter + } else { + defer lw.Close() + } + } + bar := newProgressBar(stderr, concurrency) + exported, failed := exportPending(pending, targetSize, quality, originals, len(pending), bar, bridge, m, concurrency, lw, opts) bar.clear() + if m != nil { + if err := m.Save(); err != nil { + fmt.Fprintf(stderr, "warning: could not save manifest: %v\n", err) + } + } return exported, failed } -func fileExistsOnDisk(asset photos.Asset, outDir string, originals bool, index int) bool { - var candidates []string - if originals { - if asset.Filename != "" { - candidates = append(candidates, filepath.Join(outDir, asset.Filename)) - base := strings.TrimSuffix(asset.Filename, filepath.Ext(asset.Filename)) - ext := filepath.Ext(asset.Filename) - candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s%s", index, base, ext))) - } - } else { - safeID := sanitizePathComponent(asset.ID) - candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s.jpg", index, safeID))) - } - for _, p := range candidates { - info, err := os.Stat(p) - if err == nil && info.Size() > 0 { - return true - } - } - return false -} - -func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize int, originals bool, index int) (photos.ExportResult, error) { +func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize, quality int, originals bool, index int) (photos.ExportResult, error) { if originals { return bridge.ExportOriginal(a.ID, outDir, index) } - return bridge.ExportPreview(a.ID, outDir, targetSize, index) + return bridge.ExportPreview(a.ID, outDir, targetSize, quality, index) +} + +func exportOneWithRetry(bridge photos.Bridge, a photos.Asset, outDir string, targetSize, quality int, originals bool, index, retry int) (photos.ExportResult, error) { + var result photos.ExportResult + var err error + for attempt := 0; attempt <= retry; attempt++ { + result, err = exportOne(bridge, a, outDir, targetSize, quality, originals, index) + if err == nil { + return result, nil + } + time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond) + } + return result, err +} + +func exportOneWithSlotRetry(bridge photos.Bridge, a photos.Asset, outDir string, targetSize, quality int, originals bool, index, slotIndex, retry int) (photos.ExportResult, error) { + var result photos.ExportResult + var err error + for attempt := 0; attempt <= retry; attempt++ { + if originals { + result, err = bridge.ExportOriginalWithSlot(a.ID, outDir, index, slotIndex) + } else { + result, err = bridge.ExportPreviewWithSlot(a.ID, outDir, targetSize, quality, index, slotIndex) + } + if err == nil { + return result, nil + } + time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond) + } + return result, err +} + +func parseSinceDate(s string) (time.Time, error) { + for _, layout := range []string{"2006-01-02", time.RFC3339} { + t, err := time.Parse(layout, s) + if err == nil { + return t, nil + } + } + return time.Time{}, fmt.Errorf("invalid date %q, use YYYY-MM-DD or RFC3339 format", s) +} + +func filterBySince(assets []photos.Asset, since time.Time) []photos.Asset { + filtered := make([]photos.Asset, 0, len(assets)) + for _, a := range assets { + if a.CreationDate != nil { + t, err := time.Parse(time.RFC3339, *a.CreationDate) + if err == nil && t.Before(since) { + continue + } + } + filtered = append(filtered, a) + } + return filtered } func filterVideos(assets []photos.Asset) ([]photos.Asset, int) { @@ -583,13 +1159,36 @@ func flagVal(args []string, name string) string { return flagValWithDefault(args, name, "") } +func flagVals(args []string, name string) []string { + var vals []string + for i, arg := range args { + if arg == name && i+1 < len(args) { + vals = append(vals, args[i+1]) + } + } + return vals +} + +func isExcluded(name string, exclude []string) bool { + for _, pat := range exclude { + if name == pat { + return true + } + if m, _ := filepath.Match(pat, name); m { + return true + } + } + return false +} + func hasFlag(args []string, name string) bool { for _, arg := range args { if arg == name { return true } } - return false + v := configValue(name) + return v == "true" || v == "1" || v == "yes" } func printNode(w io.Writer, node photos.CollectionNode, depth int) { @@ -619,12 +1218,298 @@ func flagValWithDefault(args []string, name, def string) string { return args[i+1] } } + if v := configValue(name); v != "" { + return v + } return def } +func configValue(flag string) string { + if !configLoaded { + configValues = loadConfigFile() + configLoaded = true + } + return configValues[strings.TrimPrefix(flag, "--")] +} + +func loadConfigFile() map[string]string { + path := os.Getenv("PHOTOSCLI_CONFIG") + if path == "" { + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".photoscli.toml") + } + data, err := os.ReadFile(path) + if err != nil { + return map[string]string{} + } + out := map[string]string{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { + continue + } + parts := strings.SplitN(line, "=", 2) + key := strings.TrimSpace(parts[0]) + val := strings.Trim(strings.TrimSpace(parts[1]), `"'`) + out[key] = val + } + return out +} + func exportMode(originals bool) string { if originals { return "originals" } return "previews" } + +func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) { + opts := exportOptions{ + dryRun: hasFlag(args, "--dry-run"), + onlyFavorites: hasFlag(args, "--only-favorites"), + media: flagValWithDefault(args, "--media", "photos"), + jsonOut: hasFlag(args, "--json"), + verify: hasFlag(args, "--verify"), + format: flagValWithDefault(args, "--format", "jpeg"), + dateTemplate: flagVal(args, "--date-template"), + } + if opts.media != "photos" && opts.media != "videos" && opts.media != "all" { + fmt.Fprintf(stderr, "error: --media must be photos, videos, or all, got %q\n", opts.media) + return opts, false + } + if opts.format != "jpeg" && opts.format != "heic" && opts.format != "png" { + fmt.Fprintf(stderr, "error: --format must be jpeg, heic, or png, got %q\n", opts.format) + return opts, false + } + if v := flagVal(args, "--retry"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + fmt.Fprintf(stderr, "error: --retry must be a non-negative integer, got %q\n", v) + return opts, false + } + opts.retry = n + } + if v := flagVal(args, "--min-size"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + fmt.Fprintf(stderr, "error: --min-size must be a non-negative integer, got %q\n", v) + return opts, false + } + opts.minSize = n + } + if v := flagVal(args, "--max-size"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + fmt.Fprintf(stderr, "error: --max-size must be a non-negative integer, got %q\n", v) + return opts, false + } + opts.maxSize = n + } + return opts, true +} + +func applyAssetFilters(assets []photos.Asset, opts exportOptions) []photos.Asset { + filtered := make([]photos.Asset, 0, len(assets)) + for _, a := range assets { + if opts.onlyFavorites && !a.IsFavorite { + continue + } + if opts.media == "photos" && (a.MediaType == "video" || a.MediaType == "audio") { + continue + } + if opts.media == "videos" && a.MediaType != "video" { + continue + } + est := int64(a.PixelWidth) * int64(a.PixelHeight) + if opts.minSize > 0 && est < opts.minSize { + continue + } + if opts.maxSize > 0 && est > opts.maxSize { + continue + } + filtered = append(filtered, a) + } + return filtered +} + +func pathWithDateTemplate(base string, a photos.Asset, tmpl string) string { + if tmpl == "" || a.CreationDate == nil { + return base + } + t, err := time.Parse(time.RFC3339, *a.CreationDate) + if err != nil { + return base + } + repl := strings.NewReplacer("YYYY", t.Format("2006"), "MM", t.Format("01"), "DD", t.Format("02")) + return filepath.Join(base, repl.Replace(tmpl)) +} + +func loadManifestEntries(outDir string, mf manifest.Format) (map[string]manifest.Entry, error) { + m, _ := manifest.Open(outDir, mf) + if err := m.OpenAppend(); err != nil { + return nil, err + } + defer m.Close() + reader := m.(manifest.EntryReader) + return reader.Entries(), nil +} + +func failuresPath(dir string) string { return filepath.Join(dir, "failures.jsonl") } + +func appendFailure(dir string, pa pendingAsset, err error) { + _ = os.MkdirAll(dir, 0755) + f, openErr := os.OpenFile(failuresPath(dir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if openErr != nil { + return + } + defer f.Close() + data, _ := json.Marshal(struct { + ID string `json:"id"` + Filename string `json:"filename"` + Album string `json:"album"` + Path string `json:"path"` + Error string `json:"error"` + }{pa.asset.ID, pa.asset.Filename, pa.album, pa.path, err.Error()}) + f.Write(data) + f.Write([]byte("\n")) +} + +func writeJSONSummary(stdout io.Writer, s commandSummary) { + data, _ := json.Marshal(s) + fmt.Fprintln(stdout, string(data)) +} + +func cmdReport(args []string, stdout, stderr io.Writer) int { + outDir := flagVal(args, "--out") + if outDir == "" { + fmt.Fprintln(stderr, "error: --out is required") + return exitErr + } + mf, err := manifest.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 + } + failures := 0 + if data, err := os.ReadFile(failuresPath(outDir)); err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if strings.TrimSpace(line) != "" { + failures++ + } + } + } + fmt.Fprintf(stdout, "entries\t%d\nfailures\t%d\n", len(entries), failures) + return exitOK +} + +func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { + albumID := flagVal(args, "--album-id") + outDir := flagVal(args, "--out") + if albumID == "" || outDir == "" { + fmt.Fprintln(stderr, "error: --album-id and --out are required") + return exitErr + } + mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return exitErr + } + if rc := mustAuth(stderr, bridge); rc != exitOK { + return rc + } + resolved, err := resolveAlbumID(bridge, albumID) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return exitErr + } + assets, _, err := bridge.ListAssets(resolved) + 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 + } + missing := 0 + for _, a := range assets { + if _, ok := entries[a.ID]; !ok { + missing++ + fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Filename) + } + } + if missing > 0 { + return exitPartial + } + return exitOK +} + +func cmdVerify(args []string, stdout, stderr io.Writer) int { + outDir := flagVal(args, "--out") + if outDir == "" { + fmt.Fprintln(stderr, "error: --out is required") + return exitErr + } + mf, err := manifest.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 + } + missing := 0 + for id, e := range entries { + if e.Filename == "" { + continue + } + if _, err := os.Stat(filepath.Join(outDir, e.Filename)); err != nil { + missing++ + fmt.Fprintf(stdout, "%s\t%s\n", id, e.Filename) + } + } + if missing > 0 { + return exitPartial + } + fmt.Fprintf(stdout, "verified\t%d\n", len(entries)) + return exitOK +} + +func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { + outDir := flagVal(args, "--out") + if outDir == "" { + fmt.Fprintln(stderr, "error: --out is required") + return exitErr + } + data, err := os.ReadFile(failuresPath(outDir)) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return exitErr + } + var pending []pendingAsset + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var f struct{ ID, Filename, Album, Path string } + if json.Unmarshal([]byte(line), &f) == nil && f.ID != "" { + pending = append(pending, pendingAsset{asset: photos.Asset{ID: f.ID, Filename: f.Filename}, path: f.Path, album: f.Album}) + } + } + bar := newProgressBar(stderr, 1) + done, failed := exportPendingSerial(pending, 1024, 85, false, len(pending), bar, bridge, nil, manifest.NoopLogWriter, exportOptions{}) + writeJSONSummary(stdout, commandSummary{Exported: done, Failed: failed, Total: len(pending)}) + if failed > 0 { + return exitPartial + } + return exitOK +} diff --git a/cmd/photoscli/main_main.go b/cmd/photoscli/main_main.go index 4085d41..534cbee 100644 --- a/cmd/photoscli/main_main.go +++ b/cmd/photoscli/main_main.go @@ -1,10 +1,9 @@ +//go:build !test + package main import ( "os" - "os/signal" - "sync/atomic" - "syscall" "gitea.k3s.k0.nu/tools/photocli/internal/photos" ) @@ -12,24 +11,5 @@ import ( var version = "dev" func main() { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - - done := make(chan struct{}) - var rc atomic.Int32 - - go func() { - rc.Store(int32(run(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge))) - close(done) - }() - - select { - case <-done: - case <-sigCh: - photos.DefaultBridge.Cancel() - os.Stderr.Write([]byte("\nreceived signal, finishing current file...\n")) - <-done - } - - os.Exit(int(rc.Load())) + os.Exit(runMain(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge)) } \ No newline at end of file diff --git a/cmd/photoscli/main_main_test.go b/cmd/photoscli/main_main_test.go new file mode 100644 index 0000000..e572f45 --- /dev/null +++ b/cmd/photoscli/main_main_test.go @@ -0,0 +1,5 @@ +//go:build test + +package main + +var version = "dev" \ No newline at end of file diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go index aa6f535..eb10b51 100644 --- a/cmd/photoscli/main_test.go +++ b/cmd/photoscli/main_test.go @@ -5,42 +5,61 @@ package main import ( "bytes" "fmt" + "io" + "os" + "path/filepath" + "sort" "strings" "sync/atomic" "testing" + "time" + "gitea.k3s.k0.nu/tools/photocli/internal/manifest" "gitea.k3s.k0.nu/tools/photocli/internal/photos" ) type mockBridge struct { - accessErr error - albums []photos.Album - albumsErr error - assets []photos.Asset - assetsErr error - assetsByAlbum map[string][]photos.Asset - tree []photos.CollectionNode - treeErr error - exportPreviewFn func(string, string, int, int) (photos.ExportResult, error) - exportOrigFn func(string, string, int) (photos.ExportResult, error) - cancelled bool + accessErr error + albums []photos.Album + albumsErr error + assets []photos.Asset + assetsErr error + assetsByAlbum map[string][]photos.Asset + assetsByAlbumErr map[string]error + listAssetsFn func(albumID string) ([]photos.Asset, int, error) + tree []photos.CollectionNode + treeErr error + exportPreviewFn func(string, string, int, int, int) (photos.ExportResult, error) + exportOrigFn func(string, string, int) (photos.ExportResult, error) + cancelled atomic.Bool } -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) ListAssets(albumID string) ([]photos.Asset, int, error) { + if m.listAssetsFn != nil { + return m.listAssetsFn(albumID) + } + if m.assetsErr != nil { + return nil, 0, m.assetsErr + } + if m.assetsByAlbumErr != nil { + if err, ok := m.assetsByAlbumErr[albumID]; ok { + return nil, 0, err + } + } if m.assetsByAlbum != nil { if assets, ok := m.assetsByAlbum[albumID]; ok { return assets, len(assets), nil } return nil, 0, fmt.Errorf("album not found") } - return m.assets, len(m.assets), m.assetsErr + return m.assets, len(m.assets), nil } func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr } -func (m *mockBridge) ExportPreview(assetID, out string, targetSize, index int) (photos.ExportResult, error) { +func (m *mockBridge) ExportPreview(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { if m.exportPreviewFn != nil { - return m.exportPreviewFn(assetID, out, targetSize, index) + return m.exportPreviewFn(assetID, out, targetSize, quality, index) } return photos.ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"}, nil } @@ -50,14 +69,14 @@ func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.Expo } return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil } -func (m *mockBridge) ExportPreviewWithSlot(assetID, out string, targetSize, index, slotIndex int) (photos.ExportResult, error) { - return m.ExportPreview(assetID, out, targetSize, index) +func (m *mockBridge) ExportPreviewWithSlot(assetID, out string, targetSize, quality, index, slotIndex int) (photos.ExportResult, error) { + return m.ExportPreview(assetID, out, targetSize, quality, index) } func (m *mockBridge) ExportOriginalWithSlot(assetID, out string, index, slotIndex int) (photos.ExportResult, error) { return m.ExportOriginal(assetID, out, index) } -func (m *mockBridge) Cancel() { m.cancelled = true } -func (m *mockBridge) IsCancelled() bool { return m.cancelled } +func (m *mockBridge) Cancel() { m.cancelled.Store(true) } +func (m *mockBridge) IsCancelled() bool { return m.cancelled.Load() } func runWith(args []string, b photos.Bridge) (string, string, int) { var out, err bytes.Buffer @@ -126,8 +145,8 @@ func TestCmdAlbumsSuccess(t *testing.T) { func TestCmdAlbumsAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("denied")} _, stderr, rc := runWith([]string{"albums"}, b) - if rc != 1 { - t.Errorf("rc = %d, want 1", rc) + if rc != exitAuth { + t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "denied") { t.Errorf("stderr = %q", stderr) @@ -137,8 +156,8 @@ func TestCmdAlbumsAuthDenied(t *testing.T) { func TestCmdAlbumsBridgeError(t *testing.T) { b := &mockBridge{albumsErr: fmt.Errorf("boom")} _, stderr, rc := runWith([]string{"albums"}, b) - if rc != 1 { - t.Errorf("rc = %d, want 1", rc) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "boom") { t.Errorf("stderr = %q", stderr) @@ -186,8 +205,8 @@ func TestCmdPhotosSuccess(t *testing.T) { func TestCmdPhotosAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("nope")} _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) - if rc != 1 { - t.Errorf("rc = %d", rc) + if rc != exitAuth { + t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "nope") { t.Errorf("stderr = %q", stderr) @@ -197,8 +216,8 @@ func TestCmdPhotosAuthDenied(t *testing.T) { func TestCmdPhotosBridgeError(t *testing.T) { b := &mockBridge{albumsErr: fmt.Errorf("fail")} _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) - if rc != 1 { - t.Errorf("rc = %d", rc) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "fail") { t.Errorf("stderr = %q", stderr) @@ -223,8 +242,8 @@ func TestCmdTreeSuccess(t *testing.T) { func TestCmdTreeAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("denied")} _, stderr, rc := runWith([]string{"tree"}, b) - if rc != 1 { - t.Errorf("rc = %d, want 1", rc) + if rc != exitAuth { + t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "denied") { t.Errorf("stderr = %q", stderr) @@ -234,8 +253,8 @@ func TestCmdTreeAuthDenied(t *testing.T) { func TestCmdTreeBridgeError(t *testing.T) { b := &mockBridge{treeErr: fmt.Errorf("boom")} _, stderr, rc := runWith([]string{"tree"}, b) - if rc != 1 { - t.Errorf("rc = %d, want 1", rc) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) } if !strings.Contains(stderr, "boom") { t.Errorf("stderr = %q", stderr) @@ -250,7 +269,7 @@ func TestCmdBackupAllPreviewSuccess(t *testing.T) { "a2": {{ID: "as2", Filename: "img2.jpg"}}, }, } - _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "2048"}, b) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "2048", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } @@ -269,7 +288,7 @@ func TestCmdBackupAllOriginalsSuccess(t *testing.T) { "a1": {{ID: "as1", Filename: "img.jpg"}, {ID: "as2", Filename: "img2.jpg"}, {ID: "as3", Filename: "img3.jpg"}}, }, } - _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--originals", "--size", "bad"}, b) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--originals", "--size", "bad", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } @@ -282,7 +301,7 @@ func TestCmdBackupAllOriginalsSuccess(t *testing.T) { } func TestCmdBackupAllMissingOutDir(t *testing.T) { - _, stderr, rc := runWith([]string{"backup-all"}, &mockBridge{}) + _, stderr, rc := runWith([]string{"backup-all", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Fatalf("rc = %d", rc) } @@ -293,7 +312,7 @@ func TestCmdBackupAllMissingOutDir(t *testing.T) { func TestCmdBackupAllInvalidSize(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} - _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "bad"}, b) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "bad", "--no-manifest"}, b) if rc != 1 { t.Fatalf("rc = %d", rc) } @@ -304,7 +323,7 @@ func TestCmdBackupAllInvalidSize(t *testing.T) { func TestCmdBackupAllTreeError(t *testing.T) { b := &mockBridge{treeErr: fmt.Errorf("tree fail")} - _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, b) if rc != 1 { t.Fatalf("rc = %d", rc) } @@ -319,11 +338,11 @@ func TestCmdBackupAllExportError(t *testing.T) { assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "as1", Filename: "img.jpg"}}, }, - exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) { + exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("disk full") }, } - _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } @@ -336,7 +355,7 @@ func TestCmdBackupAllExportError(t *testing.T) { } func TestCmdExportMissingAlbumID(t *testing.T) { - _, stderr, rc := runWith([]string{"export", "--out", "/tmp"}, &mockBridge{}) + _, stderr, rc := runWith([]string{"export", "--out", "/tmp", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d", rc) } @@ -346,7 +365,7 @@ func TestCmdExportMissingAlbumID(t *testing.T) { } func TestCmdExportMissingOutDir(t *testing.T) { - _, stderr, rc := runWith([]string{"export", "--album-id", "x"}, &mockBridge{}) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d", rc) } @@ -356,7 +375,7 @@ func TestCmdExportMissingOutDir(t *testing.T) { } func TestCmdExportInvalidSize(t *testing.T) { - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "abc"}, &mockBridge{}) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "abc", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d", rc) } @@ -366,7 +385,7 @@ func TestCmdExportInvalidSize(t *testing.T) { } func TestCmdExportNegativeSize(t *testing.T) { - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "-5"}, &mockBridge{}) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "-5", "--no-manifest"}, &mockBridge{}) if rc != 1 { t.Errorf("rc = %d", rc) } @@ -379,7 +398,7 @@ func TestCmdExportSuccess(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } @@ -392,7 +411,7 @@ func TestCmdExportDefaultSize(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } @@ -403,9 +422,9 @@ func TestCmdExportDefaultSize(t *testing.T) { func TestCmdExportAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("no")} - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) - if rc != 1 { - t.Errorf("rc = %d", rc) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) + if rc != exitAuth { + t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "no") { t.Errorf("stderr = %q", stderr) @@ -415,12 +434,12 @@ func TestCmdExportAuthDenied(t *testing.T) { func TestCmdExportBridgeError(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, - exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) { + exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("disk full") }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) - if rc != 1 { + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) + if rc != exitErr { t.Errorf("rc = %d, stderr = %q", rc, stderr) } if !strings.Contains(stderr, "all exports failed") { @@ -431,11 +450,142 @@ func TestCmdExportBridgeError(t *testing.T) { } } +func TestCmdExportPartialFailureSimple(t *testing.T) { + assets := []photos.Asset{ + {ID: "ok1", Filename: "ok.jpg"}, + {ID: "fail1", Filename: "bad.jpg"}, + } + b := &mockBridge{assets: assets} + var callCount int64 + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + n := atomic.AddInt64(&callCount, 1) + if n == 2 { + return photos.ExportResult{}, fmt.Errorf("timeout") + } + return photos.ExportResult{Filename: "ok.jpg", Size: 1024, Cloud: "local"}, nil + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) + if rc != exitPartial { + t.Errorf("rc = %d, want %d (partial failure), stderr = %q", rc, exitPartial, stderr) + } + if !strings.Contains(stderr, "1 failed") { + t.Errorf("stderr should mention partial failure, got: %q", stderr) + } +} + +func TestCmdExportAllFailedExitCode(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, + exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("error") + }, + } + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) + if rc != exitErr { + t.Errorf("rc = %d, want %d (all failed)", rc, exitErr) + } +} + +func TestExitCodeConstants(t *testing.T) { + if exitOK != 0 { + t.Errorf("exitOK = %d, want 0", exitOK) + } + if exitErr != 1 { + t.Errorf("exitErr = %d, want 1", exitErr) + } + if exitPartial != 2 { + t.Errorf("exitPartial = %d, want 2", exitPartial) + } + if exitAuth != 3 { + t.Errorf("exitAuth = %d, want 3", exitAuth) + } +} + +func TestCmdExportQualityInvalid(t *testing.T) { + b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--quality", "0", "--no-manifest"}, b) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) + } + if !strings.Contains(stderr, "--quality") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportQualityValid(t *testing.T) { + b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--quality", "90", "--no-manifest"}, b) + if rc != exitOK { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } +} + +func TestCmdBackupAllQualityInvalid(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--quality", "abc", "--no-manifest"}, b) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) + } + if !strings.Contains(stderr, "--quality") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllQualityValid(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + dir := t.TempDir() + _, _, rc := runWith([]string{"backup-all", "--out", dir, "--quality", "70", "--no-manifest"}, b) + if rc != exitOK { + t.Errorf("rc = %d", rc) + } +} + +func TestCmdExportConcurrencyInvalid(t *testing.T) { + b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--concurrency", "0", "--no-manifest"}, b) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) + } + if !strings.Contains(stderr, "--concurrency") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllConcurrencyInvalid(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--concurrency", "abc", "--no-manifest"}, b) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) + } + if !strings.Contains(stderr, "--concurrency") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportConcurrencyCapped(t *testing.T) { + b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} + dir := t.TempDir() + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--concurrency", "100", "--no-manifest"}, b) + if rc != exitOK { + t.Errorf("rc = %d, want 0 (concurrency should be capped)", rc) + } +} + +func TestCmdBackupAllConcurrencyCapped(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + dir := t.TempDir() + _, _, rc := runWith([]string{"backup-all", "--out", dir, "--concurrency", "100", "--no-manifest"}, b) + if rc != exitOK { + t.Errorf("rc = %d, want 0 (concurrency should be capped)", rc) + } +} + func TestCmdExportOriginalsSuccess(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } @@ -448,7 +598,7 @@ func TestCmdExportOriginalsIgnoresSizeValidation(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--size", "abc"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--size", "abc", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } @@ -464,7 +614,7 @@ func TestCmdExportOriginalsBridgeError(t *testing.T) { return photos.ExportResult{}, fmt.Errorf("copy failed") }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--no-manifest"}, b) if rc != 1 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } @@ -574,7 +724,7 @@ func TestResolveAlbumIDNotFound(t *testing.T) { func TestResolveAlbumIDListAlbumsFails(t *testing.T) { b := &mockBridge{ - albumsErr: fmt.Errorf("no access"), + albumsErr: fmt.Errorf("no access"), assetsByAlbum: map[string][]photos.Asset{}, } _, err := resolveAlbumID(b, "DnD") @@ -610,7 +760,7 @@ func TestCmdExportResolvesAlbumName(t *testing.T) { "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}, {ID: "a2", Filename: "img2.jpg"}, {ID: "a3", Filename: "img3.jpg"}, {ID: "a4", Filename: "img4.jpg"}, {ID: "a5", Filename: "img5.jpg"}}, }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } @@ -628,7 +778,7 @@ func TestCmdExportOriginalsResolvesAlbumName(t *testing.T) { "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}, {ID: "a2", Filename: "img2.jpg"}, {ID: "a3", Filename: "img3.jpg"}}, }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out", "--originals"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out", "--originals", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } @@ -644,7 +794,7 @@ func TestCmdExportPartialFailure(t *testing.T) { assetsByAlbum: map[string][]photos.Asset{ "a1": {{ID: "x1", Filename: "ok.jpg", Cloud: "local"}, {ID: "x2", Filename: "bad.jpg", Cloud: "local"}, {ID: "x3", Filename: "ok2.jpg", Cloud: "local"}}, }, - exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) { + exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { call++ if call == 2 { return photos.ExportResult{}, fmt.Errorf("disk full") @@ -652,9 +802,9 @@ func TestCmdExportPartialFailure(t *testing.T) { return photos.ExportResult{Filename: "ok.jpg", Size: 1024, Cloud: "local"}, nil }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "Album", "--out", "/tmp"}, b) - if rc != 0 { - t.Errorf("rc = %d, stderr = %q", rc, stderr) + _, stderr, rc := runWith([]string{"export", "--album-id", "Album", "--out", "/tmp", "--no-manifest"}, b) + if rc != exitPartial { + t.Errorf("rc = %d, want %d (partial failure), stderr = %q", rc, exitPartial, stderr) } if !strings.Contains(stderr, "\u274c bad.jpg: disk full") { t.Errorf("stderr should contain failure detail, got: %q", stderr) @@ -669,10 +819,10 @@ func TestCmdExportPartialFailure(t *testing.T) { func TestCmdBackupAllSkippedAlbum(t *testing.T) { b := &mockBridge{ - tree: []photos.CollectionNode{{ID: "bad-album", Name: "Broken", Kind: "album"}}, + tree: []photos.CollectionNode{{ID: "bad-album", Name: "Broken", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{}, } - _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } @@ -697,8 +847,8 @@ func TestResolveAlbumIDNotFoundMessage(t *testing.T) { func TestFormatSpeed(t *testing.T) { tests := []struct { - bps float64 - want string + bps float64 + want string }{ {0, ""}, {500, "500 B/s"}, @@ -781,11 +931,11 @@ func TestCountAlbums(t *testing.T) { func TestCmdExportAllFailures(t *testing.T) { b := &mockBridge{ assets: []photos.Asset{{ID: "a1", Filename: "bad.jpg"}}, - exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) { + exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { return photos.ExportResult{}, fmt.Errorf("error") }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != 1 { t.Errorf("rc = %d, want 1", rc) } @@ -796,7 +946,7 @@ func TestCmdExportAllFailures(t *testing.T) { func TestCmdPhotosAssetsError(t *testing.T) { b := &mockBridge{ - albums: []photos.Album{{ID: "x", Title: "Album"}}, + albums: []photos.Album{{ID: "x", Title: "Album"}}, assetsByAlbum: map[string][]photos.Asset{}, } _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) @@ -810,9 +960,9 @@ func TestCmdPhotosAssetsError(t *testing.T) { func TestCmdBackupAllAuthDenied(t *testing.T) { b := &mockBridge{accessErr: fmt.Errorf("denied")} - _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp"}, b) - if rc != 1 { - t.Errorf("rc = %d, want 1", rc) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--no-manifest"}, b) + if rc != exitAuth { + t.Errorf("rc = %d, want %d", rc, exitAuth) } if !strings.Contains(stderr, "denied") { t.Errorf("stderr = %q", stderr) @@ -826,7 +976,7 @@ func TestCmdExportAssetsByAlbumMap(t *testing.T) { "a1": {{ID: "x1", Filename: "photo.jpg", Cloud: "cloud"}}, }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "TestAlbum", "--out", "/tmp"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "TestAlbum", "--out", "/tmp", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d, stderr = %q", rc, stderr) } @@ -846,7 +996,7 @@ func TestCmdBackupAllWithFolder(t *testing.T) { "a1": {{ID: "x1", Filename: "photo.jpg"}}, }, } - _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--no-manifest"}, b) if rc != 0 { t.Fatalf("rc = %d, stderr = %q", rc, stderr) } @@ -947,7 +1097,7 @@ func TestExportParallelWithCancel(t *testing.T) { return photos.ExportResult{Filename: "img.jpg", Size: 1024, Cloud: "local"}, nil }, } - _, _, _ = runWith([]string{"export", "--album-id", "Test", "--out", "/tmp", "--originals"}, bridge) + _, _, _ = runWith([]string{"export", "--album-id", "Test", "--out", "/tmp", "--originals", "--no-manifest"}, bridge) _ = cancelFlag } @@ -963,16 +1113,16 @@ func TestExportParallelPartialFailure(t *testing.T) { {ID: "x5", Filename: "ok4.jpg"}, }, }, - exportPreviewFn: func(_ string, _ string, _ int, idx int) (photos.ExportResult, error) { + exportPreviewFn: func(_ string, _ string, _ int, _ int, idx int) (photos.ExportResult, error) { if idx == 1 { return photos.ExportResult{}, fmt.Errorf("fail") } return photos.ExportResult{Filename: "ok.jpg", Size: 2048, Cloud: "local"}, nil }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "Test", "--out", "/tmp"}, b) - if rc != 0 { - t.Errorf("rc = %d, want 0 (partial success)", rc) + _, stderr, rc := runWith([]string{"export", "--album-id", "Test", "--out", "/tmp", "--no-manifest"}, b) + if rc != exitPartial { + t.Errorf("rc = %d, want %d (partial success)", rc, exitPartial) } if !strings.Contains(stderr, "1 failed") { t.Errorf("stderr should contain failed count, got: %q", stderr) @@ -981,7 +1131,7 @@ func TestExportParallelPartialFailure(t *testing.T) { func TestBackupAllEmptyTree(t *testing.T) { b := &mockBridge{tree: []photos.CollectionNode{}} - _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp"}, b) + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } @@ -1062,7 +1212,7 @@ func TestExportSkipsVideos(t *testing.T) { {ID: "2", Filename: "b.mov", MediaType: "video"}, }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } @@ -1078,7 +1228,7 @@ func TestExportIncludesVideos(t *testing.T) { {ID: "2", Filename: "b.mov", MediaType: "video"}, }, } - _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--include-videos"}, b) + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--include-videos", "--no-manifest"}, b) if rc != 0 { t.Errorf("rc = %d", rc) } @@ -1086,3 +1236,2810 @@ func TestExportIncludesVideos(t *testing.T) { t.Errorf("stderr = %q, want 2 assets (video included)", stderr) } } + +func TestManifestLoadEmpty(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if mf == nil { + t.Fatal("loadManifest should not return nil") + } + if mf.Has("nonexistent") { + t.Error("empty manifest should not have any entries") + } +} + +func TestManifestAddAndHas(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + defer mf.Close() + mf.Add("id1", "photo.jpg", 1024, "local") + if !mf.Has("id1") { + t.Error("manifest should have id1 after add") + } + if mf.Has("id2") { + t.Error("manifest should not have id2") + } +} + +func TestManifestSaveAndReload(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + mf.Add("id1", "photo.jpg", 1024, "local") + mf.Add("id2", "cloud.heic", 4096, "cloud") + if err := mf.Save(); err != nil { + t.Fatal(err) + } + mf.Close() + + mf2 := manifest.LoadJSONL(dir) + if !mf2.Has("id1") { + t.Error("reloaded manifest should have id1") + } + if !mf2.Has("id2") { + t.Error("reloaded manifest should have id2") + } +} + +func TestManifestOpenAppendCreatesDir(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "sub", "deep") + mf := manifest.LoadJSONL(subdir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + mf.Add("id1", "photo.jpg", 512, "local") + mf.Close() + + if _, err := os.Stat(filepath.Join(subdir, "downloads.jsonl")); err != nil { + t.Errorf("manifest file should exist: %v", err) + } +} + +func TestManifestCloseIdempotent(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + mf.Add("id1", "photo.jpg", 1024, "local") + mf.Close() + mf.Close() +} + +func TestManifestNilSafe(t *testing.T) { + var mf manifest.Manifest + if mf != nil && mf.Has("anything") { + t.Error("nil manifest should not have entries") + } +} + +func TestCollectNodesWithManifest(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + defer mf.Close() + mf.Add("x1", "img.jpg", 1024, "local") + + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}}, + }, + } + var items []pendingAsset + var skipped int + collectNodes(b.tree, dir, b, false, false, &items, &skipped, nil, mf, nil, exportOptions{}) + if skipped != 1 { + t.Errorf("expected 1 skipped, got %d", skipped) + } + if len(items) != 1 { + t.Errorf("expected 1 item, got %d", len(items)) + } + if len(items) > 0 && items[0].asset.ID != "x2" { + t.Errorf("expected x2, got %s", items[0].asset.ID) + } +} + +func TestBackupTreeWithManifest(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + mf.Add("as1", "img1.jpg", 1024, "local") + mf.Close() + + b := &mockBridge{ + tree: []photos.CollectionNode{ + {ID: "a1", Name: "Album", Kind: "album"}, + {ID: "a2", Name: "Other", Kind: "album"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "as1", Filename: "img1.jpg"}}, + "a2": {{ID: "as2", Filename: "img2.jpg"}}, + }, + } + var stderr bytes.Buffer + exported, failed, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exported != 1 { + t.Errorf("expected 1 exported (as1 skipped), got %d", exported) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } + if !strings.Contains(stderr.String(), "1 skipped") { + t.Errorf("stderr should mention skipped, got: %q", stderr.String()) + } +} + +func TestBackupTreeNoManifest(t *testing.T) { + dir := t.TempDir() + b := &mockBridge{ + tree: []photos.CollectionNode{ + {ID: "a1", Name: "Album", Kind: "album"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + var stderr bytes.Buffer + exported, failed, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exported != 1 { + t.Errorf("expected 1 exported, got %d", exported) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } + if strings.Contains(stderr.String(), "manifest") { + t.Errorf("should not mention manifest with --no-manifest, got: %q", stderr.String()) + } +} + +func TestCmdBackupAllWithManifest(t *testing.T) { + dir := t.TempDir() + b := &mockBridge{ + tree: []photos.CollectionNode{ + {ID: "a1", Name: "Album", Kind: "album"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", dir}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + manifestPath := filepath.Join(dir, "downloads.jsonl") + data, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("manifest file should exist: %v", err) + } + if len(data) == 0 { + t.Error("manifest file should not be empty") + } +} + +func TestCmdBackupAllNoManifest(t *testing.T) { + dir := t.TempDir() + b := &mockBridge{ + tree: []photos.CollectionNode{ + {ID: "a1", Name: "Album", Kind: "album"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + manifestPath := filepath.Join(dir, "downloads.jsonl") + if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { + t.Error("manifest file should not exist with --no-manifest") + } +} + +func TestCmdExportWithManifest(t *testing.T) { + dir := t.TempDir() + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir}, b) + if rc != 0 { + t.Fatalf("rc = 0 expected, got %d", rc) + } + manifestPath := filepath.Join(dir, "downloads.jsonl") + if _, err := os.Stat(manifestPath); err != nil { + t.Fatalf("manifest file should exist: %v", err) + } +} + +func TestCmdExportNoManifest(t *testing.T) { + dir := t.TempDir() + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = 0 expected, got %d", rc) + } + manifestPath := filepath.Join(dir, "downloads.jsonl") + if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { + t.Error("manifest file should not exist with --no-manifest") + } +} + +func TestManifestOpenAppendIdempotent(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + if err := mf.OpenAppend(); err != nil { + t.Error("second openAppend should be idempotent") + } + mf.Close() +} + +func TestManifestOpenAppendMkdirError(t *testing.T) { + mf := manifest.LoadJSONL("/proc/cannot-create-dir-here") + if err := mf.OpenAppend(); err == nil { + t.Error("expected error from openAppend on read-only path") + } +} + +func TestManifestSaveWithNoFile(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.Save(); err != nil { + t.Errorf("save with no file open should return nil, got %v", err) + } +} + +func TestManifestLoadFromExistingFile(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + mf.Add("id1", "photo.jpg", 1024, "local") + mf.Add("id2", "cloud.heic", 4096, "cloud") + mf.Close() + + loaded := manifest.LoadJSONL(dir) + if !loaded.Has("id1") { + t.Error("loaded manifest should have id1") + } + if !loaded.Has("id2") { + t.Error("loaded manifest should have id2") + } +} + +func TestCollectNodesCancelled(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + b.Cancel() + var items []pendingAsset + var skipped int + collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{}) + if len(items) != 0 { + t.Errorf("cancelled collectNodes should return 0 items, got %d", len(items)) + } +} + +func TestCollectNodesAlbumWithEmptyID(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{Name: "Empty", Kind: "album", ID: ""}}, + } + var items []pendingAsset + var skipped int + collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{}) + if len(items) != 0 { + t.Errorf("album with empty ID should be skipped, got %d items", len(items)) + } +} + +func TestCollectNodesListAssetsError(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsErr: fmt.Errorf("fetch error"), + } + var progressCalls []collectProgress + var items []pendingAsset + var skipped int + collectNodes(b.tree, "/out", b, false, false, &items, &skipped, func(p collectProgress) { + progressCalls = append(progressCalls, p) + }, nil, nil, exportOptions{}) + if len(progressCalls) != 1 || progressCalls[0].err == nil { + t.Errorf("expected error progress call, got %v", progressCalls) + } +} + +func TestCollectNodesNilProgress(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + var items []pendingAsset + var skipped int + collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{}) + if len(items) != 1 { + t.Errorf("expected 1 item, got %d", len(items)) + } +} + +func TestCmdBackupAllOriginalsWithFailures(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {ID: "a1", Name: "Album", Kind: "album"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": { + {ID: "ok1", Filename: "good.jpg"}, + {ID: "bad1", Filename: "bad.jpg"}, + }, + }, + exportOrigFn: func(id string, _ string, _ int) (photos.ExportResult, error) { + if id == "bad1" { + return photos.ExportResult{}, fmt.Errorf("write error") + } + return photos.ExportResult{Filename: "good.jpg", Size: 1024, Cloud: "local"}, nil + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--originals"}, b) + if rc != exitPartial { + t.Errorf("rc = %d, want %d, stderr = %q", rc, exitPartial, stderr) + } + if !strings.Contains(stderr, "1 failed") { + t.Errorf("should report failed count, got: %q", stderr) + } +} + +func TestCmdExportOriginalsWithFailures(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "ok1", Filename: "good.jpg"}, + {ID: "bad1", Filename: "bad.jpg"}, + }, + exportOrigFn: func(id string, _ string, _ int) (photos.ExportResult, error) { + if id == "bad1" { + return photos.ExportResult{}, fmt.Errorf("export error") + } + return photos.ExportResult{Filename: "good.heic", Size: 8192, Cloud: "local"}, nil + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals"}, b) + if rc != exitPartial { + t.Errorf("rc = %d, want %d, stderr = %q", rc, exitPartial, stderr) + } + if !strings.Contains(stderr, "1 failed") { + t.Errorf("should report failed count, got: %q", stderr) + } +} + +func TestCmdExportAllFailuresOriginals(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "bad1", Filename: "bad.jpg"}}, + exportOrigFn: func(string, string, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("export error") + }, + } + dir := t.TempDir() + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals"}, b) + if rc != 1 { + t.Errorf("expected rc=1 when all exports fail, got %d", rc) + } +} + +func TestExportAssetsManifestError(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + var stderr bytes.Buffer + exported, _ := exportAssets(b.assets, "/proc/cannot-create", 1024, 85, 3, false, 1, &stderr, b, "", false, manifest.FormatJSONL, false, exportOptions{}) + if exported != 1 { + t.Errorf("expected 1 exported, got %d", exported) + } + if !strings.Contains(stderr.String(), "could not open manifest") { + t.Errorf("expected manifest warning, got: %q", stderr.String()) + } +} + +func TestBackupTreeManifestOpenError(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + var stderr bytes.Buffer + _, _, err := backupTree(b.tree, "/proc/cannot-create", 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(stderr.String(), "could not open manifest") { + t.Errorf("expected manifest warning, got: %q", stderr.String()) + } +} + +func TestBackupTreeCancelledAfterCollect(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + b.Cancel() + var stderr bytes.Buffer + _, _, err := backupTree(b.tree, "/tmp", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err == nil { + t.Error("cancelled backupTree should return error") + } +} + +func TestExportPendingSerialSkipped(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{Filename: "img.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil + } + pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} + var buf bytes.Buffer + bar := newProgressBar(&buf, 1) + done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 0 { + t.Errorf("expected 0 done for skipped, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed for skipped, got %d", failed) + } +} + +func TestExportPendingSerialCloudDownload(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"}}, + } + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil + } + pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} + var buf bytes.Buffer + bar := newProgressBar(&buf, 1) + done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 1 { + t.Errorf("expected 1 done, got %d", done) + } +} + +func TestExportPendingSerialWithManifest(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(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{Filename: "img.jpg", Size: 1024, Cloud: "local"}, nil + } + pending := []pendingAsset{{asset: b.assets[0], path: dir, album: "test"}} + var buf bytes.Buffer + bar := newProgressBar(&buf, 1) + done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, mf, manifest.NoopLogWriter, exportOptions{}) + if done != 1 { + t.Errorf("expected 1 done, got %d", done) + } + mf.Close() + if !mf.Has("x1") { + t.Error("manifest should have x1 after serial export") + } +} + +func TestExportPendingSerialSkippedWritesToManifest(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: "s1", Filename: "skip.jpg"}}, + } + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{Filename: "skip.jpg", Size: 512, Skipped: true, Cloud: "local"}, nil + } + pending := []pendingAsset{{asset: b.assets[0], path: dir, album: "test"}} + var buf bytes.Buffer + bar := newProgressBar(&buf, 1) + done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, mf, manifest.NoopLogWriter, exportOptions{}) + if done != 0 { + t.Errorf("expected 0 done for skipped, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } + mf.Close() + if !mf.Has("s1") { + t.Error("manifest should have s1 after skipped serial export") + } +} + +func TestPhotosNilCreationDateWithDuration(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "1", Filename: "video.mov", Duration: 30.5}, + }, + } + var buf bytes.Buffer + rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } +} + +func TestPhotosNilCreationDateWithFavorite(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "1", Filename: "photo.jpg", IsFavorite: true}, + }, + } + var buf bytes.Buffer + rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(buf.String(), "*") { + t.Errorf("expected favorite marker *, got: %q", buf.String()) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {5 * time.Second, "5s"}, + {90 * time.Second, "1m30s"}, + {3661 * time.Second, "61m01s"}, + } + for _, tt := range tests { + got := formatDuration(tt.d) + if got != tt.want { + t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) + } + } +} + +func TestRuneWidth(t *testing.T) { + tests := []struct { + s string + want int + }{ + {"abc", 3}, + {"hello world", 11}, + {"\u4e16\u754c", 4}, + {"\u1100", 2}, + {"\u2329", 2}, + {"\u232a", 2}, + {"\uac00", 2}, + {"\uf900", 2}, + {"\ufe30", 2}, + {"\uff01", 2}, + {"\uffe0", 2}, + {"", 0}, + } + for _, tt := range tests { + got := runeWidth(tt.s) + if got != tt.want { + t.Errorf("runeWidth(%q) = %d, want %d", tt.s, got, tt.want) + } + } +} + +func TestTruncateOrPad(t *testing.T) { + tests := []struct { + s string + width int + want string + }{ + {"hi", 5, "hi "}, + {"hello world", 20, "hello world "}, + {"a very long string that exceeds the width", 10, "a very ..."}, + {"short", 6, "short "}, + } + for _, tt := range tests { + got := truncateOrPad(tt.s, tt.width) + if got != tt.want { + t.Errorf("truncateOrPad(%q, %d) = %q, want %q", tt.s, tt.width, got, tt.want) + } + } +} + +func TestTruncateOrPadWideChars(t *testing.T) { + got := truncateOrPad("\u4e16\u754c", 10) + if !strings.HasSuffix(got, " ") { + t.Errorf("expected trailing spaces for wide chars, got %q", got) + } + got2 := truncateOrPad("\u4e16\u754c\u4e16\u754c\u4e16\u754c", 6) + if !strings.Contains(got2, "...") { + t.Errorf("expected truncation for wide chars, got %q", got2) + } +} + +func TestRenderWorkerLineStatuses(t *testing.T) { + tests := []struct { + name string + ws workerSlot + want string + }{ + {"fail status", workerSlot{status: "FAIL", filename: "bad.jpg"}, "\u274c bad.jpg"}, + {"skipped status", workerSlot{status: "skipped", filename: "skip.jpg"}, "\u23ed skip.jpg"}, + {"skipped with size", workerSlot{status: "skipped", filename: "skip.jpg", size: 1024}, "\u23ed skip.jpg 1.0 KB"}, + {"local completed", workerSlot{status: "", filename: "local.jpg", cloud: "local", size: 1024}, "\u2705 local.jpg 1.0 KB copied"}, + {"local no size", workerSlot{status: "", filename: "local.jpg", cloud: "local", size: 0}, "\u2705 local.jpg copied"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := renderWorkerLine(tt.ws, 80) + if !strings.Contains(got, tt.want) { + t.Errorf("renderWorkerLine(%+v) = %q, want containing %q", tt.ws, got, tt.want) + } + }) + } +} + +func TestRenderWorkerLineCloudCompleted(t *testing.T) { + ws := workerSlot{status: "", filename: "cloud.jpg", cloud: "cloud", size: 2048, speed: 512 * 1024} + got := renderWorkerLine(ws, 80) + if !strings.Contains(got, "\u2601 cloud.jpg 2.0 KB downloaded 512.0 KB/s") { + t.Errorf("expected cloud completed line, got %q", got) + } +} + +func TestRenderWorkerLineCloudProgress(t *testing.T) { + ws := workerSlot{status: "", filename: "cloud.jpg", cloud: "cloud", progress: 0.5, bytesTotal: 4096, bytesDone: 2048, speed: 1024 * 1024} + got := renderWorkerLine(ws, 80) + if !strings.Contains(got, "\u2601 cloud.jpg") { + t.Errorf("expected cloud marker, got %q", got) + } + if !strings.Contains(got, "50%") { + t.Errorf("expected 50%% progress, got %q", got) + } +} + +func TestUpdateWorkerProgress(t *testing.T) { + var buf bytes.Buffer + p := newProgressBar(&buf, 2) + p.updateWorkerProgress(0, 0.5, 1024, 4096) + p.mu.Lock() + if p.workerState[0].progress != 0.5 { + t.Errorf("expected progress 0.5, got %f", p.workerState[0].progress) + } + if p.workerState[0].bytesDone != 1024 { + t.Errorf("expected bytesDone 1024, got %d", p.workerState[0].bytesDone) + } + p.mu.Unlock() +} + +func TestUpdateWorkerProgressNegativeIndex(t *testing.T) { + var buf bytes.Buffer + p := newProgressBar(&buf, 1) + p.updateWorkerProgress(-1, 0.5, 1024, 4096) + p.mu.Lock() + if p.workerState[0].progress != 0 { + t.Error("negative index should be ignored") + } + p.mu.Unlock() +} + +func TestEnsureScrollRegionSmallTerminal(t *testing.T) { + var buf bytes.Buffer + p := newProgressBar(&buf, 10) + p.width = 40 + p.termH = 5 + p.ensureScrollRegion() + if !p.scrollSet { + t.Error("scrollSet should be true after ensureScrollRegion") + } +} + +func TestRenderBarPctAbove50(t *testing.T) { + bar := renderBar(75, 20) + if !strings.Contains(bar, "\x1b[") { + t.Error("expected ANSI color codes in bar") + } +} + +func TestRenderBarFull(t *testing.T) { + bar := renderBar(100, 20) + if !strings.Contains(bar, "\u2588") { + t.Error("expected full blocks in 100% bar") + } +} + +func TestRenderBarZero(t *testing.T) { + bar := renderBar(0, 20) + if !strings.Contains(bar, "\u2591") { + t.Error("expected empty blocks in 0% bar") + } +} + +func TestRenderBarNegativeWidth(t *testing.T) { + bar := renderBar(50, 0) + if bar != "" { + t.Errorf("expected empty bar for zero width, got %q", bar) + } +} + +func TestRenderLineWithETA(t *testing.T) { + b := barLine{current: 50, total: 100, label: "Total", detail: ""} + got := renderLine(b, 2*time.Second, 80) + if !strings.Contains(got, "2s") { + t.Errorf("expected duration in line, got %q", got) + } +} + +func TestRenderLineNoCounter(t *testing.T) { + b := barLine{current: 0, total: 0, label: "Total", detail: "Venice"} + got := renderLine(b, 0, 80) + if !strings.Contains(got, "Venice") { + t.Errorf("expected detail in line, got %q", got) + } +} + +func TestRenderLineAlbumLabel(t *testing.T) { + b := barLine{current: 3, total: 5, label: "Album", detail: "Venice"} + got := renderLine(b, 0, 80) + if !strings.Contains(got, "Venice 3/5") { + t.Errorf("expected Album format with detail and counter, got %q", got) + } +} + +func TestCmdBackupAllSortNewest(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}}, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sort", "newest", "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported") { + t.Errorf("expected export output, got: %q", stderr) + } +} + +func TestCmdBackupAllSortInvalid(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--sort", "invalid", "--no-manifest"}, b) + if rc != 1 { + t.Errorf("expected rc=1 for invalid --sort, got %d", rc) + } + if !strings.Contains(stderr, "--sort must be newest or oldest") { + t.Errorf("expected sort error, got: %q", stderr) + } +} + +func TestCmdExportSortNewest(t *testing.T) { + dateNew := "2024-06-01" + dateOld := "2024-01-01" + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "x1", Filename: "old.jpg", CreationDate: &dateOld}, + {ID: "x2", Filename: "new.jpg", CreationDate: &dateNew}, + {ID: "x3", Filename: "nil.jpg"}, + }, + } + dir := t.TempDir() + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = %d", rc) + } +} + +func TestCmdExportSortInvalid(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--sort", "bad"}, b) + if rc != 1 { + t.Errorf("expected rc=1 for invalid --sort, got %d", rc) + } + if !strings.Contains(stderr, "--sort must be newest or oldest") { + t.Errorf("expected sort error, got: %q", stderr) + } +} + +func TestCollectPendingAssetsSortNewest(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": { + {ID: "old", Filename: "old.jpg", CreationDate: strPtr("2020-01-01T00:00:00Z")}, + {ID: "new", Filename: "new.jpg", CreationDate: strPtr("2024-06-01T00:00:00Z")}, + }, + }, + } + var progressCalls []collectProgress + items, _ := collectPendingAssets(b.tree, "/out", b, false, false, func(p collectProgress) { + progressCalls = append(progressCalls, p) + }, nil, true, nil, time.Time{}, exportOptions{}) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].asset.ID != "new" { + t.Errorf("expected newest first, got %s then %s", items[0].asset.ID, items[1].asset.ID) + } +} + +func TestCollectPendingAssetsSortOldest(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": { + {ID: "new", Filename: "new.jpg", CreationDate: strPtr("2024-06-01T00:00:00Z")}, + {ID: "old", Filename: "old.jpg", CreationDate: strPtr("2020-01-01T00:00:00Z")}, + }, + }, + } + items, _ := collectPendingAssets(b.tree, "/out", b, false, false, nil, nil, false, nil, time.Time{}, exportOptions{}) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].asset.ID != "new" { + t.Errorf("oldest sort should preserve order, got %s first", items[0].asset.ID) + } +} + +func strPtr(s string) *string { + return &s +} + +func TestCmdExportManifestSQLite(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--manifest", "sqlite"}, b) + if rc != 0 { + t.Fatalf("rc = %d", rc) + } + if _, err := os.Stat(filepath.Join(dir, "downloads.db")); err != nil { + t.Errorf("sqlite manifest should exist: %v", err) + } +} + +func TestCmdBackupAllManifestSQLite(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + dir := t.TempDir() + _, _, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "sqlite"}, b) + if rc != 0 { + t.Fatalf("rc = %d", rc) + } + if _, err := os.Stat(filepath.Join(dir, "downloads.db")); err != nil { + t.Errorf("sqlite manifest should exist: %v", err) + } +} + +func TestCmdExportInvalidManifestFormat(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--manifest", "csv", "--no-manifest"}, b) + if rc != 1 { + t.Errorf("expected rc=1 for invalid manifest format, got %d", rc) + } + if !strings.Contains(stderr, "unknown manifest format") { + t.Errorf("expected format error, got: %q", stderr) + } +} + +func TestCmdBackupAllInvalidManifestFormat(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{}, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--manifest", "csv"}, b) + if rc != 1 { + t.Errorf("expected rc=1 for invalid manifest format, got %d", rc) + } + if !strings.Contains(stderr, "unknown manifest format") { + t.Errorf("expected format error, got: %q", stderr) + } +} + +func TestCmdExportResolveAlbumIDError(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "a1", Title: "Album"}}, + assetsByAlbum: map[string][]photos.Asset{}, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "Nonexistent", "--out", "/tmp", "--no-manifest"}, b) + if rc != 1 { + t.Errorf("expected rc=1, got %d", rc) + } + if !strings.Contains(stderr, "error") { + t.Errorf("expected error in stderr, got: %q", stderr) + } +} + +func TestCmdExportListAssetsAfterResolveErr(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")}, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--no-manifest"}, b) + if rc != 1 { + t.Errorf("expected rc=1, got %d", rc) + } + if !strings.Contains(stderr, "error") { + t.Errorf("expected error, got: %q", stderr) + } +} + +func TestSortNewestNilCreationDate(t *testing.T) { + assets := []photos.Asset{ + {ID: "a", Filename: "a.jpg"}, + {ID: "b", Filename: "b.jpg"}, + } + sort.Slice(assets, func(i, j int) bool { + di := assets[i].CreationDate + dj := assets[j].CreationDate + if di == nil && dj == nil { + return assets[i].ID < assets[j].ID + } + if di == nil { + return false + } + if dj == nil { + return true + } + return *di > *dj + }) + if assets[0].ID != "a" { + t.Errorf("both nil: expected sorted by ID, got %s first", assets[0].ID) + } +} + +func TestSortNewestMixedCreationDate(t *testing.T) { + assets := []photos.Asset{ + {ID: "a", Filename: "a.jpg"}, + {ID: "b", Filename: "b.jpg", CreationDate: strPtr("2024-01-01T00:00:00Z")}, + } + sort.Slice(assets, func(i, j int) bool { + di := assets[i].CreationDate + dj := assets[j].CreationDate + if di == nil && dj == nil { + return assets[i].ID < assets[j].ID + } + if di == nil { + return false + } + if dj == nil { + return true + } + return *di > *dj + }) + if assets[0].ID != "b" { + t.Errorf("newest first: expected b (has date) before a (nil), got %s", assets[0].ID) + } +} + +func TestSortNewestSecondNil(t *testing.T) { + assets := []photos.Asset{ + {ID: "a", Filename: "a.jpg", CreationDate: strPtr("2024-01-01T00:00:00Z")}, + {ID: "b", Filename: "b.jpg"}, + } + sort.Slice(assets, func(i, j int) bool { + di := assets[i].CreationDate + dj := assets[j].CreationDate + if di == nil && dj == nil { + return assets[i].ID < assets[j].ID + } + if di == nil { + return false + } + if dj == nil { + return true + } + return *di > *dj + }) + if assets[0].ID != "a" { + t.Errorf("newest first: expected a (has date) before b (nil), got %s", assets[0].ID) + } +} + +func TestCollectNodesNilProgressListAssetsError(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsErr: fmt.Errorf("fetch error"), + } + var items []pendingAsset + var skipped int + collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, nil, exportOptions{}) + if len(items) != 0 { + t.Errorf("expected 0 items on error, got %d", len(items)) + } +} + +func TestBackupTreeManifestOpenAppendError(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + var stderr bytes.Buffer + _, _, err := backupTree(b.tree, "/tmp/test-backup-manifest-err", 1024, 85, 3, false, false, &stderr, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestExportAssetsSaveError(t *testing.T) { + dir := t.TempDir() + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + var stderr bytes.Buffer + exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &stderr, b, "", false, manifest.FormatJSONL, false, exportOptions{}) + if exported != 1 { + t.Errorf("expected 1 exported, got %d", exported) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } +} + +func TestExportPendingSerialCancelled(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}, {ID: "x2", Filename: "img2.jpg"}}, + } + b.Cancel() + pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} + var buf bytes.Buffer + bar := newProgressBar(&buf, 1) + done, _ := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 0 { + t.Errorf("expected 0 done when cancelled, got %d", done) + } +} + +func TestExportPendingSerialSkippedNoProgress(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{Filename: "img.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil + } + pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} + var buf bytes.Buffer + bar := newProgressBar(&buf, 1) + done, failed := exportPendingSerial(pending, 1024, 85, false, 1, bar, b, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 0 { + t.Errorf("expected 0 done for skipped, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed for skipped, got %d", failed) + } +} + +func TestCmdExportNoManifestFlag(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if strings.Contains(stderr, "manifest") { + t.Errorf("should not mention manifest with --no-manifest, got: %q", stderr) + } +} + +func TestRunMainNormal(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + rc := runMain([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, os.Stdout, os.Stderr, b) + if rc != 0 { + t.Errorf("expected rc 0, got %d", rc) + } +} + +func TestRunMainCancelled(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + b.Cancel() + dir := t.TempDir() + rc := runMain([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, os.Stdout, os.Stderr, b) + if rc != 0 { + t.Errorf("expected rc 0 for cancelled (still returns 0), got %d", rc) + } +} + +func TestRunMainNoArgs(t *testing.T) { + rc := runMain(nil, os.Stdout, os.Stderr, &mockBridge{}) + if rc != 1 { + t.Errorf("expected rc 1 for no args, got %d", rc) + } +} + +func TestRunMainSignal(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + b.Cancel() + sigCh := make(chan struct{}) + close(sigCh) + rc := runMainWithSignal([]string{"export", "--album-id", "x", "--out", t.TempDir(), "--no-manifest"}, os.Stdout, os.Stderr, b, sigCh) + if rc != 0 { + t.Errorf("expected rc 0, got %d", rc) + } +} + +func TestCmdPhotosListAssetsError(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")}, + } + var buf bytes.Buffer + rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1, got %d", rc) + } +} + +func TestCmdExportListAssetsAfterResolveError(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assetsByAlbumErr: map[string]error{"x": fmt.Errorf("fetch failed")}, + } + var buf bytes.Buffer + rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1, got %d", rc) + } +} + +func TestCmdExportOriginalsFlag(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.heic"}}, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals", "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "originals") { + t.Errorf("expected originals in output, got: %s", stderr) + } +} + +func TestCmdExportBadSize(t *testing.T) { + b := &mockBridge{} + var buf bytes.Buffer + rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--size", "abc"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 for invalid size, got %d", rc) + } +} + +func TestCmdExportZeroSize(t *testing.T) { + b := &mockBridge{} + var buf bytes.Buffer + rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--size", "0"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 for zero size, got %d", rc) + } +} + +func TestCmdExportBadSort(t *testing.T) { + b := &mockBridge{} + var buf bytes.Buffer + rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--sort", "invalid"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 for invalid sort, got %d", rc) + } +} + +func TestCmdExportAllExportsFail(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("export error") + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest"}, b) + if rc != 1 { + t.Errorf("expected rc 1 when all exports fail, got %d", rc) + } + if !strings.Contains(stderr, "all exports failed") { + t.Errorf("expected 'all exports failed' in stderr, got: %s", stderr) + } +} + +func TestCmdBackupAllCancelledTree(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1", Children: []photos.CollectionNode{ + {Kind: "asset", ID: "x1", Name: "photo.jpg"}, + }}, + }, + } + b.Cancel() + var stderr bytes.Buffer + _, _, err := backupTree(b.tree, "/tmp/test-backup-cancelled", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err == nil { + t.Error("cancelled backupTree should return error") + } +} + +func TestCmdBackupAllOriginalsFlag(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--originals", "--no-manifest"}, b) + if rc != 0 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "original files") { + t.Errorf("expected 'original files' in output, got: %s", stderr) + } +} + +func TestCmdBackupAllBadSort(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + var buf bytes.Buffer + rc := cmdBackupAll([]string{"--out", "/tmp", "--sort", "invalid", "--no-manifest"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 for invalid sort, got %d", rc) + } +} + +func TestCmdBackupAllBadSize(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + var buf bytes.Buffer + rc := cmdBackupAll([]string{"--out", "/tmp", "--size", "abc"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 for invalid size, got %d", rc) + } +} + +func TestCmdBackupAllTreeErr(t *testing.T) { + b := &mockBridge{treeErr: fmt.Errorf("tree error")} + var buf bytes.Buffer + rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1, got %d", rc) + } +} + +func TestCollectPendingAssetsCancelledDuringCollect(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Root", Kind: "folder", Children: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }}, + }, + } + b.Cancel() + items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, false, nil, time.Time{}, exportOptions{}) + if len(items) != 0 { + t.Errorf("expected 0 items when cancelled, got %d", len(items)) + } +} + +func TestCollectPendingAssetsAlbumErr(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assetsErr: fmt.Errorf("album error"), + } + var progressErrors []string + items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, func(p collectProgress) { + if p.err != nil { + progressErrors = append(progressErrors, p.err.Error()) + } + }, nil, false, nil, time.Time{}, exportOptions{}) + if len(progressErrors) == 0 { + t.Error("expected progress error for album error") + } + if len(items) != 0 { + t.Errorf("expected 0 items, got %d", len(items)) + } +} + +func TestExportPendingParallelSmoke(t *testing.T) { + assets := make([]photos.Asset, 5) + for i := range assets { + assets[i] = photos.Asset{ID: fmt.Sprintf("x%d", i), Filename: fmt.Sprintf("img%d.jpg", i)} + } + b := &mockBridge{assets: assets} + pending := make([]pendingAsset, len(assets)) + for i, a := range assets { + pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"} + } + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + done, failed := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 5 { + t.Errorf("expected 5 done, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } +} + +func TestExportPendingParallelManifestAdd(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + assets := make([]photos.Asset, 5) + for i := range assets { + assets[i] = photos.Asset{ID: fmt.Sprintf("m%d", i), Filename: fmt.Sprintf("img%d.jpg", i)} + } + b := &mockBridge{assets: assets} + pending := make([]pendingAsset, len(assets)) + for i, a := range assets { + pending[i] = pendingAsset{asset: a, path: dir, album: "test"} + } + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + done, _ := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, mf, manifest.NoopLogWriter, exportOptions{}) + if done != 5 { + t.Errorf("expected 5 done, got %d", done) + } + mf.Close() + for i := range assets { + if !mf.Has(assets[i].ID) { + t.Errorf("manifest should have %s", assets[i].ID) + } + } +} + +func TestExportPendingParallelCancel(t *testing.T) { + b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} + b.Cancel() + pending := []pendingAsset{{asset: b.assets[0], path: "/tmp", album: "test"}} + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + done, _ := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 0 { + t.Errorf("expected 0 done when cancelled, got %d", done) + } +} + +func TestExportPendingParallelErr(t *testing.T) { + assets := make([]photos.Asset, 5) + for i := range assets { + assets[i] = photos.Asset{ID: fmt.Sprintf("e%d", i), Filename: fmt.Sprintf("img%d.jpg", i)} + } + b := &mockBridge{assets: assets} + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("export error") + } + pending := make([]pendingAsset, len(assets)) + for i, a := range assets { + pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"} + } + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + done, failed := exportPendingParallel(pending, 1024, 85, false, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) + if failed != 5 { + t.Errorf("expected 5 failed, got %d", failed) + } + if done != 0 { + t.Errorf("expected 0 done, got %d", done) + } +} + +func TestExportPendingParallelSkipped(t *testing.T) { + assets := []photos.Asset{ + {ID: "s1", Filename: "skip.jpg"}, + {ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"}, + } + b := &mockBridge{assets: assets} + var callCount int64 + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + n := atomic.AddInt64(&callCount, 1) + if n == 1 { + return photos.ExportResult{Filename: "skip.jpg", Size: 0, Skipped: true, Cloud: "local"}, nil + } + return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil + } + pending := []pendingAsset{ + {asset: assets[0], path: "/tmp", album: "test"}, + {asset: assets[1], path: "/tmp", album: "test"}, + } + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + done, failed := exportPendingParallel(pending, 1024, 85, false, 2, bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 1 { + t.Errorf("expected 1 done (cloud only), got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } +} + +func TestExportPendingParallelSkippedWritesToManifest(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + assets := []photos.Asset{ + {ID: "s1", Filename: "skip.jpg"}, + {ID: "c1", Filename: "cloud.jpg", Cloud: "cloud"}, + } + b := &mockBridge{assets: assets} + var callCount int64 + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + n := atomic.AddInt64(&callCount, 1) + if n == 1 { + return photos.ExportResult{Filename: "skip.jpg", Size: 512, Skipped: true, Cloud: "local"}, nil + } + return photos.ExportResult{Filename: "cloud.jpg", Size: 2048, Cloud: "cloud"}, nil + } + pending := []pendingAsset{ + {asset: assets[0], path: dir, album: "test"}, + {asset: assets[1], path: dir, album: "test"}, + } + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + done, failed := exportPendingParallel(pending, 1024, 85, false, 2, bar, b, 3, mf, manifest.NoopLogWriter, exportOptions{}) + if done != 1 { + t.Errorf("expected 1 done (cloud only), got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } + mf.Close() + if !mf.Has("s1") { + t.Error("manifest should have s1 after skipped parallel export") + } + if !mf.Has("c1") { + t.Error("manifest should have c1 after parallel export") + } +} + +func TestExportPendingParallelOrig(t *testing.T) { + assets := make([]photos.Asset, 5) + for i := range assets { + assets[i] = photos.Asset{ID: fmt.Sprintf("o%d", i), Filename: fmt.Sprintf("img%d.heic", i)} + } + b := &mockBridge{assets: assets} + pending := make([]pendingAsset, len(assets)) + for i, a := range assets { + pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"} + } + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + done, failed := exportPendingParallel(pending, 1024, 85, true, len(pending), bar, b, 3, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 5 { + t.Errorf("expected 5 done, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } +} + +func TestExportAssetsManifestWrite(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + var buf bytes.Buffer + done, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{}) + if done != 1 { + t.Errorf("expected 1 done, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } +} + +func TestBackupTreeManifestOpenErr(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + dir := t.TempDir() + dbPath := manifest.SQLitePath(dir) + os.WriteFile(dbPath, []byte("not a sqlite file"), 0644) + var buf bytes.Buffer + _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Logf("backupTree returned: %v", err) + } + if !strings.Contains(buf.String(), "could not open manifest") { + t.Errorf("expected manifest warning, got: %q", buf.String()) + } +} + +func TestCmdExportResolveAlbumErr(t *testing.T) { + b := &mockBridge{albumsErr: fmt.Errorf("album error")} + var buf bytes.Buffer + rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1, got %d", rc) + } +} + +func TestCmdBackupAllAuthErr(t *testing.T) { + b := &mockBridge{accessErr: fmt.Errorf("access denied")} + var buf bytes.Buffer + rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b) + if rc != exitAuth { + t.Errorf("expected rc %d, got %d", exitAuth, rc) + } +} + +func TestCmdExportAuthErr(t *testing.T) { + b := &mockBridge{accessErr: fmt.Errorf("access denied")} + var buf bytes.Buffer + rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b) + if rc != exitAuth { + t.Errorf("expected rc %d, got %d", exitAuth, rc) + } +} + +func TestCmdPhotosAuthErr(t *testing.T) { + b := &mockBridge{accessErr: fmt.Errorf("access denied")} + var buf bytes.Buffer + rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) + if rc != exitAuth { + t.Errorf("expected rc %d, got %d", exitAuth, rc) + } +} + +func TestCollectPendingAssetsSorted(t *testing.T) { + dateOld := "2024-01-01" + dateNew := "2024-06-01" + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assets: []photos.Asset{ + {ID: "old", Filename: "old.jpg", CreationDate: &dateOld}, + {ID: "new", Filename: "new.jpg", CreationDate: &dateNew}, + }, + } + items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{}) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].asset.ID != "new" { + t.Errorf("expected newest first, got %s", items[0].asset.ID) + } +} + +func TestCollectPendingAssetsManifestSkip(t *testing.T) { + dir := t.TempDir() + mf := manifest.LoadJSONL(dir) + if err := mf.OpenAppend(); err != nil { + t.Fatal(err) + } + mf.Add("x1", "img.jpg", 1024, "local") + mf.Close() + + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assets: []photos.Asset{ + {ID: "x1", Filename: "img.jpg"}, + {ID: "x2", Filename: "new.jpg"}, + }, + } + items, skipped := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, mf, false, nil, time.Time{}, exportOptions{}) + if skipped != 1 { + t.Errorf("expected 1 skipped, got %d", skipped) + } + if len(items) != 1 { + t.Errorf("expected 1 item, got %d", len(items)) + } +} + +func TestExportPendingChoiceParallelPath(t *testing.T) { + assets := make([]photos.Asset, 5) + for i := range assets { + assets[i] = photos.Asset{ID: fmt.Sprintf("p%d", i), Filename: fmt.Sprintf("img%d.jpg", i)} + } + b := &mockBridge{assets: assets} + pending := make([]pendingAsset, len(assets)) + for i, a := range assets { + pending[i] = pendingAsset{asset: a, path: "/tmp", album: "test"} + } + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + done, failed := exportPending(pending, 1024, 85, false, len(pending), bar, b, nil, 3, manifest.NoopLogWriter, exportOptions{}) + if done != 5 { + t.Errorf("expected 5 done, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } +} + +func TestCmdExportNoAlbumIDArg(t *testing.T) { + b := &mockBridge{} + var buf bytes.Buffer + rc := cmdExport([]string{"--out", "/tmp"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 for missing --album-id, got %d", rc) + } +} + +func TestCmdBackupAllNoOutArg(t *testing.T) { + b := &mockBridge{} + var buf bytes.Buffer + rc := cmdBackupAll([]string{}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 for missing --out, got %d", rc) + } +} + +func TestRenderLineETADisplay(t *testing.T) { + line := renderLine(barLine{current: 50, total: 100, label: "Total", detail: "test"}, 10*time.Second, 80) + if !strings.Contains(line, "Total") { + t.Error("expected Total in line") + } +} + +func TestRenderLineZeroWidthDisplay(t *testing.T) { + line := renderLine(barLine{current: 50, total: 100, label: "Total"}, 0, 0) + if !strings.Contains(line, "Total") { + t.Error("expected Total in line with zero width") + } +} + +func TestRenderLineAlbumWithCounterDisplay(t *testing.T) { + line := renderLine(barLine{current: 5, total: 10, label: "Album", detail: "Vacation"}, 5*time.Second, 80) + if !strings.Contains(line, "Vacation") { + t.Error("expected Vacation in line") + } +} + +func TestTruncateOrPadLong(t *testing.T) { + longStr := strings.Repeat("x", 100) + result := truncateOrPad(longStr, 20) + if !strings.HasSuffix(result, "...") { + t.Error("expected truncation with ...") + } +} + +func TestTruncateOrPadZero(t *testing.T) { + result := truncateOrPad("hello", 0) + if result == "" { + t.Error("expected non-empty result for zero width") + } +} + +func TestRenderWorkerLineStatusTable(t *testing.T) { + tests := []struct { + name string + ws workerSlot + contains string + }{ + {"fail status", workerSlot{filename: "test.jpg", status: "FAIL"}, "\u274c"}, + {"skipped status", workerSlot{filename: "test.jpg", status: "skipped", size: 1024}, "\u23ed"}, + {"cloud in progress", workerSlot{filename: "test.jpg", cloud: "cloud", progress: 0.5, bytesTotal: 2048, bytesDone: 1024, speed: 1024.0}, "\u2601"}, + {"cloud downloaded", workerSlot{filename: "test.jpg", cloud: "cloud", size: 2048}, "\u2601"}, + {"local copied", workerSlot{filename: "test.jpg", size: 1024}, "\u2705"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + line := renderWorkerLine(tt.ws, 80) + if !strings.Contains(line, tt.contains) { + t.Errorf("expected %q in %q", tt.contains, line) + } + }) + } +} + +func TestRenderWorkerLineZeroWidthDisplay(t *testing.T) { + line := renderWorkerLine(workerSlot{filename: "test.jpg", size: 100}, 0) + if !strings.Contains(line, "test.jpg") { + t.Error("expected filename in line") + } +} + +func TestRenderWorkerLineCloudProgressWithSpeedDisplay(t *testing.T) { + ws := workerSlot{filename: "big.jpg", cloud: "cloud", progress: 0.75, bytesTotal: 4096, bytesDone: 3072, speed: 512.0} + line := renderWorkerLine(ws, 80) + if !strings.Contains(line, "75%") { + t.Errorf("expected 75%% in line, got: %s", line) + } +} + +func TestEnsureScrollRegionDisplay(t *testing.T) { + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + bar.ensureScrollRegion() + if !bar.scrollSet { + t.Error("expected scrollSet to be true after ensureScrollRegion") + } +} + +func TestDrawFooterLockedEmptyWorkerDisplay(t *testing.T) { + var buf bytes.Buffer + bar := newProgressBar(&buf, 3) + bar.setTotal(0, 10, "") + bar.draw() + bar.clear() +} + +func TestCmdExportIncludeVideosFlag(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "1", Filename: "photo.jpg", MediaType: "image"}, + {ID: "2", Filename: "vid.mov", MediaType: "video"}, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--include-videos", "--no-manifest"}, b) + if rc != 0 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } +} + +func TestCmdExportWithManifestFlag(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--manifest", "jsonl"}, b) + if rc != 0 { + t.Fatalf("expected rc 0") + } + if _, err := os.Stat(filepath.Join(dir, "downloads.jsonl")); err != nil { + t.Errorf("expected manifest file to exist: %v", err) + } +} + +func TestCmdExportFailedExports(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("export error") + } + var buf bytes.Buffer + rc := cmdExport([]string{"--album-id", "x", "--out", "/tmp", "--no-manifest"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 when all exports fail, got %d", rc) + } +} + +func TestCmdExportOriginalsMode(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assets: []photos.Asset{{ID: "x1", Filename: "img.heic"}}, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--originals", "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "originals") { + t.Errorf("expected 'originals' in output") + } +} + +func TestCmdBackupAllFailedManifestSave(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "jsonl"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + _ = stderr +} + +func TestCollectPendingAssetsSortNilDates(t *testing.T) { + dateNew := "2024-06-01" + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assets: []photos.Asset{ + {ID: "x1", Filename: "nil.jpg"}, + {ID: "x2", Filename: "new.jpg", CreationDate: &dateNew}, + }, + } + items, _ := collectPendingAssets(b.tree, "/tmp", b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{}) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + if items[0].asset.ID != "x2" { + t.Errorf("expected newest (non-nil) first, got %s", items[0].asset.ID) + } +} + +func TestBackupTreeManifestOpenAppendErr(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + var buf bytes.Buffer + _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Errorf("backupTree should succeed even with manifest error, got %v", err) + } +} + +func TestRenderLineSmallBarWidth(t *testing.T) { + line := renderLine(barLine{current: 50, total: 100, label: "Total"}, 30*time.Second, 20) + if !strings.Contains(line, "Total") { + t.Error("expected Total in line") + } +} + +func TestEnsureScrollRegionResize(t *testing.T) { + var buf bytes.Buffer + bar := newProgressBar(&buf, 1) + bar.draw() + bar.draw() + bar.clear() +} + +func TestProgressLogCompleted(t *testing.T) { + var buf bytes.Buffer + bar := newProgressBar(&buf, 1) + bar.logCompleted("test completed") + bar.clear() +} + +func TestProgressSetWorkerNegIdx(t *testing.T) { + var buf bytes.Buffer + bar := newProgressBar(&buf, 2) + bar.setWorker(-1, "test", 0, "", "exporting") + bar.draw() + bar.clear() +} + +func TestBackupTreeManifestSaveFail(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + dir := t.TempDir() + oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("sync failed") }) + defer manifest.SetJSONLSaveHook(oldHook) + var buf bytes.Buffer + _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "could not save manifest") { + t.Errorf("expected save manifest warning, got: %s", buf.String()) + } +} + +func TestBackupTreeManifestOpenAppendFail(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + jsonlPath := manifest.JSONLPath(dir) + os.WriteFile(jsonlPath, []byte("{}\n"), 0644) + os.Chmod(jsonlPath, 0444) + defer os.Chmod(jsonlPath, 0644) + var buf bytes.Buffer + _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "could not open manifest") { + t.Errorf("expected open append warning, got: %s", buf.String()) + } +} + +func TestExportAssetsManifestSaveFail(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("sync failed") }) + defer manifest.SetJSONLSaveHook(oldHook) + var buf bytes.Buffer + done, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{}) + if done != 1 { + t.Errorf("expected 1 done, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } + if !strings.Contains(buf.String(), "could not save manifest") { + t.Errorf("expected save manifest warning, got: %s", buf.String()) + } +} + +func TestCmdBackupAllCancelledCmd(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + } + b.Cancel() + var buf bytes.Buffer + rc := cmdBackupAll([]string{"--out", "/tmp", "--no-manifest"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1 for cancelled, got %d", rc) + } +} + +func TestCmdExportSortNewestMixedDates(t *testing.T) { + dateOld := "2024-01-01" + dateNew := "2024-06-01" + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "x": { + {ID: "old", Filename: "old.jpg", CreationDate: &dateOld}, + {ID: "new", Filename: "new.jpg", CreationDate: &dateNew}, + {ID: "nil", Filename: "nil.jpg"}, + }, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } +} + +func TestProgressDrawSmallTerminal(t *testing.T) { + var buf bytes.Buffer + bar := newProgressBar(&buf, 30) + bar.setTotal(5, 10, "test") + bar.draw() + bar.clear() +} + +func TestCmdPhotosListAssetsErrorAfterResolve(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "x"}}, + listAssetsFn: func(albumID string) ([]photos.Asset, int, error) { + return nil, 0, fmt.Errorf("list failed") + }, + } + var buf bytes.Buffer + rc := cmdPhotos([]string{"--album-id", "x"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1, got %d", rc) + } + if !strings.Contains(buf.String(), "list failed") { + t.Errorf("expected error message in output, got: %s", buf.String()) + } +} + +func TestCmdExportListAssetsErrorAfterResolve(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "x"}}, + listAssetsFn: func(albumID string) ([]photos.Asset, int, error) { + return nil, 0, fmt.Errorf("list failed") + }, + } + var buf bytes.Buffer + rc := cmdExport([]string{"--album-id", "x", "--out", t.TempDir(), "--no-manifest"}, &buf, &buf, b) + if rc != 1 { + t.Errorf("expected rc 1, got %d", rc) + } + if !strings.Contains(buf.String(), "list failed") { + t.Errorf("expected error message in output, got: %s", buf.String()) + } +} + +func TestCmdExportSortNewestBothNilDates(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "x": { + {ID: "b", Filename: "b.jpg"}, + {ID: "a", Filename: "a.jpg"}, + }, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } +} + +func TestCmdExportSortNewestOneNilDate(t *testing.T) { + dateOld := "2024-01-01" + dateNew := "2024-06-01" + b := &mockBridge{ + albums: []photos.Album{{ID: "x", Title: "Album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "x": { + {ID: "no-date", Filename: "nodate.jpg"}, + {ID: "has-date-old", Filename: "old.jpg", CreationDate: &dateOld}, + {ID: "has-date-new", Filename: "new.jpg", CreationDate: &dateNew}, + }, + }, + } + dir := t.TempDir() + _, _, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sort", "newest", "--no-manifest"}, b) + if rc != 0 { + t.Errorf("expected rc 0, got %d", rc) + } +} + +func TestCollectPendingAssetsSortNewestNilDates(t *testing.T) { + dateOld := "2024-01-01" + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Kind: "album", ID: "a1", Name: "Album1"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": { + {ID: "dated", Filename: "dated.jpg", CreationDate: &dateOld}, + {ID: "nil1", Filename: "nil1.jpg"}, + {ID: "nil2", Filename: "nil2.jpg"}, + }, + }, + } + items, _ := collectPendingAssets(b.tree, t.TempDir(), b, false, false, nil, nil, true, nil, time.Time{}, exportOptions{}) + if len(items) != 3 { + t.Fatalf("expected 3 items, got %d", len(items)) + } + if items[0].asset.ID != "dated" { + t.Errorf("expected dated first, got %s", items[0].asset.ID) + } +} + +func TestExportPendingParallelCancelledWorker(t *testing.T) { + b := &mockBridge{} + var callCount atomic.Int32 + b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { + if callCount.Add(1) >= 1 { + b.Cancel() + } + return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil + } + dir := t.TempDir() + pending := []pendingAsset{ + {asset: photos.Asset{ID: "x1", Filename: "img1.jpg", Cloud: "local"}, path: dir, album: "test"}, + {asset: photos.Asset{ID: "x2", Filename: "img2.jpg", Cloud: "local"}, path: dir, album: "test"}, + } + bar := newProgressBar(io.Discard, 1) + done, _ := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{}) + if done > 2 { + t.Errorf("expected at most 2 done, got %d", done) + } +} + +func TestBackupTreeManifestOpenConvertErr(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {Name: "Album1", Kind: "album", ID: "a1"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + dir := t.TempDir() + dbPath := manifest.SQLitePath(dir) + os.WriteFile(dbPath, []byte("not a sqlite file"), 0644) + var buf bytes.Buffer + _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, &buf, b, false, manifest.FormatJSONL, false, nil, time.Time{}, false, exportOptions{}) + if err != nil { + t.Logf("backupTree: %v", err) + } +} + +func TestExportAssetsManifestOpenConvertErr(t *testing.T) { + assets := []photos.Asset{{ID: "x1", Filename: "img.jpg", Cloud: "local"}} + b := &mockBridge{} + dir := t.TempDir() + dbPath := manifest.SQLitePath(dir) + os.WriteFile(dbPath, []byte("not a sqlite file"), 0644) + var buf bytes.Buffer + exportAssets(assets, dir, 1024, 85, 3, false, 1, &buf, b, "", false, manifest.FormatJSONL, false, exportOptions{}) +} + +func TestExportPendingParallelProgressUpdate(t *testing.T) { + origPollInterval := progressPollInterval + progressPollInterval = 1 * time.Millisecond + defer func() { progressPollInterval = origPollInterval }() + b := &mockBridge{ + exportPreviewFn: func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { + time.Sleep(20 * time.Millisecond) + return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil + }, + } + dir := t.TempDir() + pending := []pendingAsset{ + {asset: photos.Asset{ID: "x1", Filename: "img.jpg", Cloud: "local"}, path: dir, album: "test"}, + } + bar := newProgressBar(io.Discard, 1) + done, failed := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{}) + if done != 1 { + t.Errorf("expected 1 done, got %d", done) + } + if failed != 0 { + t.Errorf("expected 0 failed, got %d", failed) + } +} + +func TestExportPendingParallelTimeout(t *testing.T) { + origTimeout := exportTimeout + exportTimeout = 10 * time.Millisecond + defer func() { exportTimeout = origTimeout }() + b := &mockBridge{ + exportPreviewFn: func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) { + time.Sleep(30 * time.Millisecond) + return photos.ExportResult{Filename: "test.jpg", Size: 100, Cloud: "local"}, nil + }, + } + dir := t.TempDir() + pending := []pendingAsset{ + {asset: photos.Asset{ID: "x1", Filename: "img.jpg", Cloud: "local"}, path: dir, album: "test"}, + } + bar := newProgressBar(io.Discard, 1) + done, failed := exportPendingParallel(pending, 1024, 85, false, 1, bar, b, 1, nil, manifest.NoopLogWriter, exportOptions{}) + t.Logf("done=%d, failed=%d", done, failed) +} + +func TestIsExcluded(t *testing.T) { + tests := []struct { + name string + exclude []string + want bool + }{ + {"no patterns", nil, false}, + {"exact match", []string{"Photos"}, true}, + {"no match", []string{"Videos"}, false}, + {"glob match", []string{"Recent*"}, true}, + {"glob no match", []string{"Recent*"}, false}, + } + for _, tt := range tests { + t.Run(tt.name+"_"+tt.name, func(t *testing.T) { + name := tt.name + if tt.name == "glob match" { + name = "Recently Deleted" + } + if tt.name == "glob no match" { + name = "Favorites" + } + if tt.name == "exact match" { + name = "Photos" + } + if tt.name == "no match" { + name = "Photos" + } + if tt.name == "no patterns" { + name = "Photos" + } + got := isExcluded(name, tt.exclude) + if got != tt.want { + t.Errorf("isExcluded(%q, %v) = %v, want %v", name, tt.exclude, got, tt.want) + } + }) + } +} + +func TestFlagVals(t *testing.T) { + args := []string{"--exclude-album", "Photos", "--exclude-album", "Recent*", "--out", "/tmp"} + vals := flagVals(args, "--exclude-album") + if len(vals) != 2 || vals[0] != "Photos" || vals[1] != "Recent*" { + t.Errorf("flagVals = %v, want [Photos Recent*]", vals) + } + empty := flagVals(args, "--nonexistent") + if len(empty) != 0 { + t.Errorf("flagVals for nonexistent = %v, want []", empty) + } +} + +func TestCmdBackupAllExcludeAlbum(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {ID: "a1", Name: "Photos", Kind: "album"}, + {ID: "a2", Name: "Trips", Kind: "album"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "p1", Filename: "photo1.jpg"}}, + "a2": {{ID: "t1", Filename: "trip1.jpg"}}, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--exclude-album", "Photos", "--no-manifest"}, b) + if rc != exitOK { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if strings.Contains(stderr, "Photos") { + t.Errorf("should not export excluded album Photos, stderr = %q", stderr) + } +} + +func TestCollectNodesExcludeAlbum(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{ + {ID: "a1", Name: "Recently Deleted", Kind: "album"}, + {ID: "a2", Name: "Favorites", Kind: "album"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "a2": {{ID: "x1", Filename: "fav.jpg"}}, + }, + } + var items []pendingAsset + var skipped int + collectNodes(b.tree, "/out", b, false, false, &items, &skipped, nil, nil, []string{"Recently Deleted"}, exportOptions{}) + if len(items) != 1 || items[0].asset.ID != "x1" { + t.Errorf("expected 1 item (Favorites), got %d items", len(items)) + } +} + +func TestFilterBySince(t *testing.T) { + oldDate := "2023-01-01T00:00:00Z" + newDate := "2025-01-01T00:00:00Z" + assets := []photos.Asset{ + {ID: "old", Filename: "old.jpg", CreationDate: &oldDate}, + {ID: "new", Filename: "new.jpg", CreationDate: &newDate}, + {ID: "nil", Filename: "nil.jpg"}, + } + since, _ := time.Parse("2006-01-02", "2024-06-01") + filtered := filterBySince(assets, since) + if len(filtered) != 2 { + t.Errorf("expected 2 assets after since filter, got %d", len(filtered)) + } + if filtered[0].ID != "new" && filtered[1].ID != "new" { + t.Errorf("expected 'new' asset to remain") + } +} + +func TestParseSinceDate(t *testing.T) { + t1, err := parseSinceDate("2024-01-15") + if err != nil || t1.Year() != 2024 { + t.Errorf("expected 2024, got %v, err %v", t1, err) + } + t2, err := parseSinceDate("2024-06-01T10:30:00Z") + if err != nil || t2.Year() != 2024 { + t.Errorf("expected 2024, got %v, err %v", t2, err) + } + _, err = parseSinceDate("not-a-date") + if err == nil { + t.Error("expected error for invalid date") + } +} + +func TestCollectPendingAssetsWithSince(t *testing.T) { + oldDate := "2023-01-01T00:00:00Z" + newDate := "2025-01-01T00:00:00Z" + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": { + {ID: "old", Filename: "old.jpg", CreationDate: &oldDate}, + {ID: "new", Filename: "new.jpg", CreationDate: &newDate}, + }, + }, + } + since, _ := time.Parse("2006-01-02", "2024-06-01") + items, skipped := collectPendingAssets(b.tree, "/out", b, false, false, nil, nil, false, nil, since, exportOptions{}) + if len(items) != 1 { + t.Errorf("expected 1 item after since filter, got %d", len(items)) + } + if items[0].asset.ID != "new" { + t.Errorf("expected 'new' asset, got %s", items[0].asset.ID) + } + if skipped != 1 { + t.Errorf("expected 1 skipped (old asset), got %d", skipped) + } +} + +func TestCmdBackupAllSinceFlag(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + dir := t.TempDir() + _, _, rc := runWith([]string{"backup-all", "--out", dir, "--since", "2024-01-01", "--no-manifest"}, b) + if rc != exitOK { + t.Errorf("rc = %d", rc) + } +} + +func TestCmdBackupAllSinceInvalid(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + _, stderr, rc := runWith([]string{"backup-all", "--out", "/tmp", "--since", "bad-date", "--no-manifest"}, b) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) + } + if !strings.Contains(stderr, "date") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportSinceFlag(t *testing.T) { + dateNew := "2025-01-01T00:00:00Z" + dateOld := "2023-01-01T00:00:00Z" + b := &mockBridge{ + assets: []photos.Asset{ + {ID: "old", Filename: "old.jpg", CreationDate: &dateOld}, + {ID: "new", Filename: "new.jpg", CreationDate: &dateNew}, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--since", "2024-06-01", "--no-manifest"}, b) + if rc != exitOK { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "1 assets") || strings.Contains(stderr, "2 assets") { + t.Errorf("expected only 1 asset after --since filter, stderr = %q", stderr) + } +} + +func TestCmdExportSinceInvalid(t *testing.T) { + b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}} + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--since", "bad-date", "--no-manifest"}, b) + if rc != exitErr { + t.Errorf("rc = %d, want %d", rc, exitErr) + } + if !strings.Contains(stderr, "--since") && !strings.Contains(stderr, "date") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportWithLog(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--no-manifest", "--log"}, b) + if rc != exitOK { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) { + t.Error("expected export.log to exist with --log") + } +} + +func TestCmdBackupAllWithLog(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--no-manifest", "--log"}, b) + if rc != exitOK { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) { + t.Error("expected export.log to exist with --log --no-manifest") + } +} + +func TestCmdBackupAllWithLogSQLite(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + dir := t.TempDir() + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--manifest", "sqlite", "--log"}, b) + if rc != exitOK { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } +} + +func TestExportAssetsWithLog(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + dir := t.TempDir() + exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, true, exportOptions{}) + if exported != 1 || failed != 0 { + t.Errorf("exported=%d failed=%d", exported, failed) + } + if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) { + t.Error("expected export.log with enableLog=true") + } +} + +func TestBackupTreeWithLog(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + dir := t.TempDir() + _, _, err := backupTree(b.tree, dir, 1024, 85, 3, false, false, io.Discard, b, false, manifest.FormatJSONL, false, nil, time.Time{}, true, exportOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if _, err := os.ReadFile(manifest.LogPath(dir)); os.IsNotExist(err) { + t.Error("expected export.log with enableLog=true") + } +} + +func TestExportAssetsLogOpenError(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}, + } + var stderr bytes.Buffer + exportAssets(b.assets, "/proc/cannot-create", 1024, 85, 3, false, 1, &stderr, b, "", true, manifest.FormatJSONL, true, exportOptions{}) + if !strings.Contains(stderr.String(), "could not open log writer") { + t.Errorf("expected log writer warning, got: %q", stderr.String()) + } +} + +func TestBackupTreeLogOpenError(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "img.jpg"}}, + }, + } + var stderr bytes.Buffer + _, _, err := backupTree(b.tree, "/proc/cannot-create", 1024, 85, 3, false, false, &stderr, b, true, manifest.FormatJSONL, false, nil, time.Time{}, true, exportOptions{}) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if !strings.Contains(stderr.String(), "could not open log writer") { + t.Errorf("expected log writer warning, got: %q", stderr.String()) + } +} + +func TestNewFeatureCommandsAndOptions(t *testing.T) { + oldConfigValues, oldConfigLoaded := configValues, configLoaded + defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() + configValues, configLoaded = nil, false + + date := "2024-01-02T00:00:00Z" + b := &mockBridge{ + albums: []photos.Album{{ID: "a1", Title: "Album"}}, + assets: []photos.Asset{ + {ID: "x1", Filename: "fav.jpg", MediaType: "image", IsFavorite: true, PixelWidth: 10, PixelHeight: 10, CreationDate: &date}, + {ID: "x2", Filename: "vid.mov", MediaType: "video", PixelWidth: 20, PixelHeight: 20}, + }, + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "fav.jpg", MediaType: "image", IsFavorite: true, PixelWidth: 10, PixelHeight: 10, CreationDate: &date}}, + }, + } + dir := t.TempDir() + out, stderr, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--dry-run", "--json", "--only-favorites", "--media", "photos", "--format", "jpeg", "--min-size", "1", "--max-size", "1000", "--date-template", "YYYY/MM/DD"}, b) + if rc != exitOK || !strings.Contains(out, "x1") || !strings.Contains(out, "total") || !strings.Contains(stderr, "dry-run") { + t.Fatalf("dry-run rc=%d out=%q stderr=%q", rc, out, stderr) + } + + out, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--dry-run", "--json", "--media", "all", "--date-template", "YYYY/MM/DD"}, b) + if rc != exitOK || !strings.Contains(out, "x1") || !strings.Contains(stderr, "dry-run") { + t.Fatalf("backup dry-run rc=%d out=%q stderr=%q", rc, out, stderr) + } + + _, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--media", "bad"}, b) + if rc != exitErr || !strings.Contains(stderr, "--media") { + t.Fatalf("expected media error, rc=%d stderr=%q", rc, stderr) + } + _, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--format", "bad"}, b) + if rc != exitErr || !strings.Contains(stderr, "--format") { + t.Fatalf("expected format error, rc=%d stderr=%q", rc, stderr) + } + _, stderr, rc = runWith([]string{"export", "--album-id", "Album", "--out", dir, "--retry", "bad"}, b) + if rc != exitErr || !strings.Contains(stderr, "--retry") { + t.Fatalf("expected retry error, rc=%d stderr=%q", rc, stderr) + } +} + +func TestReportDiffVerifyRetryFailedAndConfig(t *testing.T) { + oldConfigValues, oldConfigLoaded := configValues, configLoaded + defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() + dir := t.TempDir() + cfg := filepath.Join(dir, "config.toml") + if err := os.WriteFile(cfg, []byte("quality = 90\nlog = true\n"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("PHOTOSCLI_CONFIG", cfg) + configValues, configLoaded = nil, false + if flagValWithDefault(nil, "--quality", "85") != "90" || !hasFlag(nil, "--log") { + t.Fatal("expected config defaults") + } + + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "file.jpg", 10, "local") + if err := m.Save(); err != nil { + t.Fatal(err) + } + m.Close() + if err := os.WriteFile(filepath.Join(dir, "file.jpg"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, path: dir, album: "Album"}, fmt.Errorf("boom")) + + out, stderr, rc := runWith([]string{"report", "--out", dir}, &mockBridge{}) + if rc != exitOK || !strings.Contains(out, "entries\t1") || stderr != "" { + t.Fatalf("report rc=%d out=%q stderr=%q", rc, out, stderr) + } + out, stderr, rc = runWith([]string{"verify", "--out", dir}, &mockBridge{}) + if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" { + t.Fatalf("verify rc=%d out=%q stderr=%q", rc, out, stderr) + } + + b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg"}, {ID: "x2", Filename: "missing.jpg"}}} + out, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b) + if rc != exitPartial || !strings.Contains(out, "x2") { + t.Fatalf("diff rc=%d out=%q", rc, out) + } + out, _, rc = runWith([]string{"retry-failed", "--out", dir}, b) + if rc != exitOK || !strings.Contains(out, "exported") { + t.Fatalf("retry-failed rc=%d out=%q", rc, out) + } +} + +func TestNewFeatureHelpers(t *testing.T) { + date := "2024-03-04T00:00:00Z" + a := photos.Asset{ID: "x", Filename: "x.jpg", MediaType: "video", IsFavorite: false, PixelWidth: 10, PixelHeight: 20, CreationDate: &date} + if len(applyAssetFilters([]photos.Asset{a}, exportOptions{media: "videos", minSize: 100, maxSize: 300})) != 1 { + t.Fatal("expected video in size range") + } + if len(applyAssetFilters([]photos.Asset{a}, exportOptions{media: "photos"})) != 0 { + t.Fatal("expected video filtered from photos") + } + if got := pathWithDateTemplate("/out", a, "YYYY/MM/DD"); !strings.Contains(got, "2024") || !strings.Contains(got, "03") || !strings.Contains(got, "04") { + t.Fatalf("unexpected templated path %q", got) + } + if got := pathWithDateTemplate("/out", photos.Asset{}, "YYYY"); got != "/out" { + t.Fatalf("expected base path, got %q", got) + } +} + +func TestNewFeatureCoverageEdges(t *testing.T) { + oldConfigValues, oldConfigLoaded := configValues, configLoaded + defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() + configValues, configLoaded = map[string]string{}, true + + date := "bad-date" + if got := pathWithDateTemplate("/out", photos.Asset{CreationDate: &date}, "YYYY"); got != "/out" { + t.Fatalf("expected bad date to keep base path, got %q", got) + } + assets := []photos.Asset{{ID: "a", MediaType: "image", PixelWidth: 1, PixelHeight: 1}, {ID: "b", MediaType: "image", IsFavorite: true, PixelWidth: 100, PixelHeight: 100}} + if len(applyAssetFilters(assets, exportOptions{media: "all", onlyFavorites: true})) != 1 { + t.Fatal("expected only favorite") + } + if len(applyAssetFilters(assets, exportOptions{media: "videos"})) != 0 { + t.Fatal("expected no videos") + } + if len(applyAssetFilters(assets, exportOptions{media: "all", minSize: 2})) != 1 { + t.Fatal("expected min-size filter") + } + if len(applyAssetFilters(assets, exportOptions{media: "all", maxSize: 2})) != 1 { + t.Fatal("expected max-size filter") + } + + var stderr bytes.Buffer + if _, ok := parseExportOptions([]string{"--retry", "2", "--min-size", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--min-size") { + t.Fatal("expected min-size error") + } + stderr.Reset() + if _, ok := parseExportOptions([]string{"--max-size", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--max-size") { + t.Fatal("expected max-size error") + } + stderr.Reset() + opts, ok := parseExportOptions([]string{"--retry", "2", "--min-size", "1", "--max-size", "2"}, &stderr) + if !ok || opts.retry != 2 || opts.minSize != 1 || opts.maxSize != 2 { + t.Fatalf("unexpected opts: %+v ok=%v stderr=%q", opts, ok, stderr.String()) + } +} + +func TestNewFeatureCommandEdges(t *testing.T) { + oldConfigValues, oldConfigLoaded := configValues, configLoaded + defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() + configValues, configLoaded = map[string]string{}, true + + dir := t.TempDir() + b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}}} + _, stderr, rc := runWith([]string{"report"}, b) + if rc != exitErr || !strings.Contains(stderr, "--out") { + t.Fatalf("expected report --out error, rc=%d stderr=%q", rc, stderr) + } + _, stderr, rc = runWith([]string{"diff"}, b) + if rc != exitErr || !strings.Contains(stderr, "--album-id") { + t.Fatalf("expected diff arg error, rc=%d stderr=%q", rc, stderr) + } + _, stderr, rc = runWith([]string{"verify"}, b) + if rc != exitErr || !strings.Contains(stderr, "--out") { + t.Fatalf("expected verify --out error, rc=%d stderr=%q", rc, stderr) + } + _, stderr, rc = runWith([]string{"retry-failed"}, b) + if rc != exitErr || !strings.Contains(stderr, "--out") { + t.Fatalf("expected retry --out error, rc=%d stderr=%q", rc, stderr) + } + _, stderr, rc = runWith([]string{"retry-failed", "--out", filepath.Join(dir, "missing")}, b) + if rc != exitErr || stderr == "" { + t.Fatalf("expected missing failures error, rc=%d stderr=%q", rc, stderr) + } + + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "missing.jpg", 1, "local") + m.Add("x2", "", 1, "local") + m.Close() + out, _, rc := runWith([]string{"verify", "--out", dir}, b) + if rc != exitPartial || !strings.Contains(out, "missing.jpg") { + t.Fatalf("expected verify missing, rc=%d out=%q", rc, out) + } + if _, err := loadManifestEntries("/proc/cannot-create", manifest.FormatJSONL); err == nil { + t.Fatal("expected loadManifestEntries open error") + } + + b.accessErr = fmt.Errorf("denied") + _, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b) + if rc != exitAuth { + t.Fatalf("expected auth error, got %d", rc) + } +} + +func TestExportVerifyJSONAndBackupIncludeVideos(t *testing.T) { + oldConfigValues, oldConfigLoaded := configValues, configLoaded + defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() + configValues, configLoaded = map[string]string{}, true + + dir := t.TempDir() + b := &mockBridge{ + albums: []photos.Album{{ID: "a1", Title: "Album"}}, + assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}}, + tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}, {ID: "a2", Name: "Album", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "x1", Filename: "file.jpg", MediaType: "image"}}, + "a2": {{ID: "x2", Filename: "vid.mov", MediaType: "video"}}, + }, + } + out, _, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--json", "--verify", "--no-manifest"}, b) + if rc != exitOK || !strings.Contains(out, "exported") { + t.Fatalf("export json rc=%d out=%q", rc, out) + } + out, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--include-videos", "--json", "--verify", "--no-manifest"}, b) + if rc != exitOK || !strings.Contains(out, "exported") || strings.Contains(stderr, "error") { + t.Fatalf("backup json rc=%d out=%q stderr=%q", rc, out, stderr) + } +} + +func TestNewFeatureRemainingBranches(t *testing.T) { + oldConfigValues, oldConfigLoaded := configValues, configLoaded + defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() + configValues, configLoaded = map[string]string{}, true + + dir := t.TempDir() + b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg", MediaType: "image"}}} + _, _, rc := runWith([]string{"export", "--album-id", "Album", "--out", dir, "--verify"}, b) + if rc != exitPartial { + t.Fatalf("expected verify partial export, got %d", rc) + } + backupDir := t.TempDir() + _, _, rc = runWith([]string{"backup-all", "--out", backupDir, "--verify"}, &mockBridge{tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}}, assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "file.jpg"}}}}) + if rc != exitPartial { + t.Fatalf("expected verify partial backup, got %d", rc) + } + + for _, cmd := range [][]string{ + {"report", "--out", dir, "--manifest", "bad"}, + {"diff", "--album-id", "Album", "--out", dir, "--manifest", "bad"}, + {"verify", "--out", dir, "--manifest", "bad"}, + } { + _, stderr, rc := runWith(cmd, b) + if rc != exitErr || !strings.Contains(stderr, "manifest") { + t.Fatalf("expected manifest error for %v, rc=%d stderr=%q", cmd, rc, stderr) + } + } + _, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--media", "bad"}, b) + if rc != exitErr || !strings.Contains(stderr, "--media") { + t.Fatalf("expected backup media error, rc=%d stderr=%q", rc, stderr) + } + for _, cmd := range [][]string{ + {"report", "--out", "/proc/cannot-create"}, + {"verify", "--out", "/proc/cannot-create"}, + {"diff", "--album-id", "Album", "--out", "/proc/cannot-create"}, + } { + _, stderr, rc := runWith(cmd, b) + if rc != exitErr || !strings.Contains(stderr, "error:") { + t.Fatalf("expected load error for %v, rc=%d stderr=%q", cmd, rc, stderr) + } + } + + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "file.jpg", 1, "local") + m.Close() + _, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b) + if rc != exitOK { + t.Fatalf("expected clean diff, got %d", rc) + } + badAlbum := &mockBridge{albumsErr: fmt.Errorf("list albums bad"), assetsErr: fmt.Errorf("assets bad")} + _, stderr, rc = runWith([]string{"diff", "--album-id", "missing", "--out", dir}, badAlbum) + if rc != exitErr || !strings.Contains(stderr, "error:") { + t.Fatalf("expected resolve/list error, rc=%d stderr=%q", rc, stderr) + } + badAssets := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assetsErr: fmt.Errorf("assets bad")} + _, stderr, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, badAssets) + if rc != exitErr || !strings.Contains(stderr, "assets bad") { + t.Fatalf("expected list assets error, rc=%d stderr=%q", rc, stderr) + } + + failDir := t.TempDir() + appendFailure(failDir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, path: failDir, album: "Album"}, fmt.Errorf("boom")) + failing := &mockBridge{exportPreviewFn: func(string, string, int, int, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("still bad") + }} + _, _, rc = runWith([]string{"retry-failed", "--out", failDir}, failing) + if rc != exitPartial { + t.Fatalf("expected retry partial, got %d", rc) + } +} diff --git a/cmd/photoscli/progress.go b/cmd/photoscli/progress.go index 7312f2a..82488bb 100644 --- a/cmd/photoscli/progress.go +++ b/cmd/photoscli/progress.go @@ -14,7 +14,6 @@ type progressBar struct { width int termH int start time.Time - errors []string workers int footerLines int scrollSet bool @@ -94,12 +93,6 @@ func (p *progressBar) updateWorkerProgress(i int, progress float64, bytesDone, b } } -func (p *progressBar) addError(filename string, err error) { - p.mu.Lock() - defer p.mu.Unlock() - p.errors = append(p.errors, fmt.Sprintf(" \u274c %s: %v", filename, err)) -} - func (p *progressBar) logCompleted(line string) { p.mu.Lock() defer p.mu.Unlock() @@ -168,13 +161,6 @@ func (p *progressBar) clear() { p.scrollSet = false } -func (p *progressBar) flushErrors() { - for _, e := range p.errors { - fmt.Fprintln(p.w, e) - } - p.errors = nil -} - func renderWorkerLine(ws workerSlot, width int) string { if width <= 0 { width = 80 @@ -300,9 +286,6 @@ func renderBar(pct, barWidth int) string { if fullBlocks < barWidth { fracs := []string{"", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589"} idx := int(partial * 8) - if idx > 7 { - idx = 7 - } if idx > 0 { sb.WriteString(fracs[idx]) fullBlocks++ @@ -347,7 +330,6 @@ func truncateOrPad(s string, width int) string { return string(runes[:i]) + "..." } } - return s } return s + strings.Repeat(" ", width-rw) } diff --git a/cmd/photoscli/runmain.go b/cmd/photoscli/runmain.go new file mode 100644 index 0000000..efc352e --- /dev/null +++ b/cmd/photoscli/runmain.go @@ -0,0 +1,33 @@ +package main + +import ( + "io" + "os" + "sync/atomic" + + "gitea.k3s.k0.nu/tools/photocli/internal/photos" +) + +func runMain(args []string, stdout, stderr *os.File, bridge photos.Bridge) int { + return runMainWithSignal(args, stdout, stderr, bridge, defaultSignalChan()) +} + +func runMainWithSignal(args []string, stdout *os.File, stderr io.Writer, bridge photos.Bridge, sigCh <-chan struct{}) int { + done := make(chan struct{}) + var rc atomic.Int32 + + go func() { + rc.Store(int32(run(args, stdout, stderr, bridge))) + close(done) + }() + + select { + case <-done: + case <-sigCh: + bridge.Cancel() + stderr.Write([]byte("\nreceived signal, finishing current file...\n")) + <-done + } + + return int(rc.Load()) +} \ No newline at end of file diff --git a/cmd/photoscli/signal_default.go b/cmd/photoscli/signal_default.go new file mode 100644 index 0000000..5f85cf7 --- /dev/null +++ b/cmd/photoscli/signal_default.go @@ -0,0 +1,20 @@ +//go:build !test + +package main + +import ( + "os" + "os/signal" + "syscall" +) + +func defaultSignalChan() <-chan struct{} { + ch := make(chan struct{}) + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + close(ch) + }() + return ch +} \ No newline at end of file diff --git a/cmd/photoscli/signal_test.go b/cmd/photoscli/signal_test.go new file mode 100644 index 0000000..ac48132 --- /dev/null +++ b/cmd/photoscli/signal_test.go @@ -0,0 +1,8 @@ +//go:build test + +package main + +func defaultSignalChan() <-chan struct{} { + ch := make(chan struct{}) + return ch +} \ No newline at end of file diff --git a/go.mod b/go.mod index ee49136..3ec06e0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module gitea.k3s.k0.nu/tools/photocli -go 1.22 \ No newline at end of file +go 1.25.0 + +require modernc.org/sqlite v1.52.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0391e71 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/manifest/file_log.go b/internal/manifest/file_log.go new file mode 100644 index 0000000..b2cdb0d --- /dev/null +++ b/internal/manifest/file_log.go @@ -0,0 +1,39 @@ +package manifest + +import ( + "encoding/json" + "os" + "sync" +) + +type fileLogWriter struct { + mu sync.Mutex + f *os.File +} + +func NewFileLogWriter(path string) (LogWriter, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return nil, err + } + return &fileLogWriter{f: f}, nil +} + +func (w *fileLogWriter) Log(e LogEntry) { + data, _ := json.Marshal(e) + w.mu.Lock() + defer w.mu.Unlock() + if w.f != nil { + w.f.Write(data) + w.f.Write([]byte("\n")) + } +} + +func (w *fileLogWriter) Close() { + w.mu.Lock() + defer w.mu.Unlock() + if w.f != nil { + w.f.Close() + w.f = nil + } +} diff --git a/internal/manifest/jsonl.go b/internal/manifest/jsonl.go new file mode 100644 index 0000000..9f4b924 --- /dev/null +++ b/internal/manifest/jsonl.go @@ -0,0 +1,139 @@ +package manifest + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" +) + +type jsonlManifest struct { + mu sync.Mutex + entries map[string]Entry + path string + file *os.File + syncFunc func() error +} + +var jsonlSaveHook func() error + +func SetJSONLSaveHook(fn func() error) func() error { + old := jsonlSaveHook + jsonlSaveHook = fn + return old +} + +func JSONLPath(dir string) string { + return filepath.Join(dir, "downloads.jsonl") +} + +func LoadJSONL(dir string) *jsonlManifest { + m := &jsonlManifest{ + entries: make(map[string]Entry), + path: JSONLPath(dir), + } + data, err := os.ReadFile(m.path) + if err != nil { + return m + } + type entryWithID struct { + ID string `json:"id"` + Filename string `json:"filename"` + Size int64 `json:"size"` + Cloud string `json:"cloud"` + Exported int64 `json:"exported"` + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var raw entryWithID + if json.Unmarshal([]byte(line), &raw) == nil && raw.ID != "" { + m.entries[raw.ID] = Entry{ + Filename: raw.Filename, + Size: raw.Size, + Cloud: raw.Cloud, + Exported: raw.Exported, + } + } + } + return m +} + +func (m *jsonlManifest) Has(id string) bool { + m.mu.Lock() + defer m.mu.Unlock() + _, ok := m.entries[id] + return ok +} + +func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) { + m.mu.Lock() + defer m.mu.Unlock() + entry := newEntry(id, filename, size, cloud) + m.entries[id] = entry + if m.file != nil { + data, _ := json.Marshal(struct { + ID string `json:"id"` + Filename string `json:"filename"` + Size int64 `json:"size"` + Cloud string `json:"cloud"` + Exported int64 `json:"exported"` + }{ID: id, Filename: entry.Filename, Size: entry.Size, Cloud: entry.Cloud, Exported: entry.Exported}) + m.file.Write(data) + m.file.Write([]byte("\n")) + } +} + +func (m *jsonlManifest) Save() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.syncFunc != nil { + return m.syncFunc() + } + if jsonlSaveHook != nil { + return jsonlSaveHook() + } + if m.file != nil { + return m.file.Sync() + } + return nil +} + +func (m *jsonlManifest) Close() { + m.mu.Lock() + defer m.mu.Unlock() + if m.file != nil { + m.file.Close() + m.file = nil + } +} + +func (m *jsonlManifest) OpenAppend() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.file != nil { + return nil + } + if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil { + return err + } + f, err := os.OpenFile(m.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return err + } + m.file = f + return nil +} + +func (m *jsonlManifest) Entries() map[string]Entry { + m.mu.Lock() + defer m.mu.Unlock() + out := make(map[string]Entry, len(m.entries)) + for k, v := range m.entries { + out[k] = v + } + return out +} diff --git a/internal/manifest/log.go b/internal/manifest/log.go new file mode 100644 index 0000000..6e2c1fb --- /dev/null +++ b/internal/manifest/log.go @@ -0,0 +1,30 @@ +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" +} diff --git a/internal/manifest/log_test.go b/internal/manifest/log_test.go new file mode 100644 index 0000000..52eb73c --- /dev/null +++ b/internal/manifest/log_test.go @@ -0,0 +1,211 @@ +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") + } +} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go new file mode 100644 index 0000000..f315fa2 --- /dev/null +++ b/internal/manifest/manifest.go @@ -0,0 +1,33 @@ +package manifest + +import "time" + +type Entry struct { + ID string + Filename string + Size int64 + Cloud string + Exported int64 +} + +type Manifest interface { + Has(id string) bool + Add(id string, filename string, size int64, cloud string) + Save() error + Close() + OpenAppend() error +} + +type EntryReader interface { + Entries() map[string]Entry +} + +func newEntry(id, filename string, size int64, cloud string) Entry { + return Entry{ + ID: id, + Filename: filename, + Size: size, + Cloud: cloud, + Exported: time.Now().Unix(), + } +} diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go new file mode 100644 index 0000000..63401f8 --- /dev/null +++ b/internal/manifest/manifest_test.go @@ -0,0 +1,1049 @@ +package manifest_test + +import ( + "os" + "path/filepath" + "testing" + + "gitea.k3s.k0.nu/tools/photocli/internal/manifest" +) + +func TestLoadJSONLEmptyDir(t *testing.T) { + m := manifest.LoadJSONL(t.TempDir()) + if m == nil { + t.Fatal("expected non-nil manifest") + } +} + +func TestLoadJSONLNonexistentDir(t *testing.T) { + m := manifest.LoadJSONL(filepath.Join(t.TempDir(), "no-such-dir")) + if m == nil { + t.Fatal("expected non-nil manifest even for nonexistent dir") + } +} + +func TestLoadJSONLExistingFile(t *testing.T) { + dir := t.TempDir() + path := manifest.JSONLPath(dir) + if err := os.WriteFile(path, []byte(`{"id":"abc","filename":"a.jpg","size":100,"cloud":"gcs","exported":1234}`+"\n"), 0644); err != nil { + t.Fatal(err) + } + m := manifest.LoadJSONL(dir) + if !m.Has("abc") { + t.Fatal("expected to find entry with id abc") + } +} + +func TestJSONLAddAndHas(t *testing.T) { + m := manifest.LoadJSONL(t.TempDir()) + if m.Has("x") { + t.Fatal("expected Has to return false for unknown id") + } + m.Add("x", "photo.jpg", 42, "s3") + if !m.Has("x") { + t.Fatal("expected Has to return true after Add") + } + if m.Has("y") { + t.Fatal("expected Has to return false for y") + } +} + +func TestJSONLSaveAndReload(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(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 := manifest.LoadJSONL(dir) + if !m2.Has("id1") { + t.Fatal("expected id1 after reload") + } + if !m2.Has("id2") { + t.Fatal("expected id2 after reload") + } +} + +func TestJSONLOpenAppendCreatesDirs(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "a", "b") + m := manifest.LoadJSONL(subdir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() + if _, err := os.Stat(filepath.Dir(manifest.JSONLPath(subdir))); err != nil { + t.Fatalf("expected dirs to be created: %v", err) + } +} + +func TestJSONLCloseIdempotent(t *testing.T) { + m := manifest.LoadJSONL(t.TempDir()) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() + m.Close() +} + +func TestJSONLOpenAppendIdempotent(t *testing.T) { + m := manifest.LoadJSONL(t.TempDir()) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatalf("expected second OpenAppend to succeed: %v", err) + } + m.Close() +} + +func TestJSONLEntries(t *testing.T) { + m := manifest.LoadJSONL(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)) + } + if _, ok := entries["e1"]; !ok { + t.Fatal("missing e1") + } + if _, ok := entries["e2"]; !ok { + t.Fatal("missing e2") + } +} + +func TestLoadSQLiteEmptyDir(t *testing.T) { + m, err := manifest.LoadSQLite(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if m == nil { + t.Fatal("expected non-nil manifest") + } + m.Close() +} + +func TestLoadSQLiteNonexistentDir(t *testing.T) { + m, err := manifest.LoadSQLite(filepath.Join(t.TempDir(), "no-such-dir")) + if err != nil { + t.Fatal(err) + } + if m == nil { + t.Fatal("expected non-nil manifest even for nonexistent dir") + } + m.Close() +} + +func TestSQLiteAddHasSaveClose(t *testing.T) { + m, err := manifest.LoadSQLite(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 after Add") + } + if m.Has("nope") { + t.Fatal("expected Has to return false for unknown id") + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestSQLiteRoundTrip(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(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 := manifest.LoadSQLite(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 TestSQLiteEntries(t *testing.T) { + m, err := manifest.LoadSQLite(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)) + } + if _, ok := entries["se1"]; !ok { + t.Fatal("missing se1") + } + if _, ok := entries["se2"]; !ok { + t.Fatal("missing se2") + } +} + +func TestSQLiteCloseIdempotent(t *testing.T) { + m, err := manifest.LoadSQLite(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() + m.Close() +} + +func TestSQLiteOpenAppendIdempotent(t *testing.T) { + m, err := manifest.LoadSQLite(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatalf("expected second OpenAppend to succeed: %v", err) + } + m.Close() +} + +func TestOpenJSONLNoFileExists(t *testing.T) { + m, err := manifest.Open(t.TempDir(), manifest.FormatJSONL) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestOpenSQLiteNoFileExists(t *testing.T) { + m, err := manifest.Open(t.TempDir(), manifest.FormatSQLite) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestOpenJSONLConvertsFromSQLite(t *testing.T) { + dir := t.TempDir() + sm, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if err := sm.OpenAppend(); err != nil { + t.Fatal(err) + } + sm.Add("conv1", "conv1.jpg", 5, "aws") + sm.Close() + + m, err := manifest.Open(dir, manifest.FormatJSONL) + if err != nil { + t.Fatal(err) + } + defer m.Close() + if !m.Has("conv1") { + t.Fatal("expected converted entry conv1 to exist") + } + if _, err := os.Stat(manifest.SQLitePath(dir)); err == nil { + t.Fatal("expected sqlite file to be removed after conversion") + } +} + +func TestOpenSQLiteConvertsFromJSONL(t *testing.T) { + dir := t.TempDir() + jm := manifest.LoadJSONL(dir) + if err := jm.OpenAppend(); err != nil { + t.Fatal(err) + } + jm.Add("jconv1", "jconv1.jpg", 7, "gcs") + jm.Save() + jm.Close() + + m, err := manifest.Open(dir, manifest.FormatSQLite) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + defer m.Close() + if !m.Has("jconv1") { + t.Fatal("expected converted entry jconv1 to exist") + } + if _, err := os.Stat(manifest.JSONLPath(dir)); err == nil { + t.Fatal("expected jsonl file to be removed after conversion") + } +} + +func TestOpenBothFormatsPrefersRequested(t *testing.T) { + dir := t.TempDir() + + jm := manifest.LoadJSONL(dir) + if err := jm.OpenAppend(); err != nil { + t.Fatal(err) + } + jm.Add("both1", "both1.jpg", 1, "aws") + jm.Save() + jm.Close() + + sm, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if err := sm.OpenAppend(); err != nil { + t.Fatal(err) + } + sm.Add("both2", "both2.jpg", 2, "gcs") + sm.Close() + + m, err := manifest.Open(dir, manifest.FormatJSONL) + if err != nil { + t.Fatal(err) + } + m.Close() + if _, err := os.Stat(manifest.JSONLPath(dir)); err != nil { + t.Fatal("expected jsonl file to exist after Open with FormatJSONL") + } + + jm2 := manifest.LoadJSONL(dir) + if err := jm2.OpenAppend(); err != nil { + t.Fatal(err) + } + jm2.Add("both1", "both1.jpg", 1, "aws") + jm2.Save() + jm2.Close() + + sm2, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if err := sm2.OpenAppend(); err != nil { + t.Fatal(err) + } + sm2.Add("both2", "both2.jpg", 2, "gcs") + sm2.Close() + + m2, err := manifest.Open(dir, manifest.FormatSQLite) + if err != nil { + t.Fatal(err) + } + m2.Close() + if _, err := os.Stat(manifest.SQLitePath(dir)); err != nil { + t.Fatal("expected sqlite file to exist after Open with FormatSQLite") + } +} + +func TestParseFormatValid(t *testing.T) { + tests := []struct { + input string + want manifest.Format + }{ + {"jsonl", manifest.FormatJSONL}, + {"JSONL", manifest.FormatJSONL}, + {"json", manifest.FormatJSONL}, + {"JSON", manifest.FormatJSONL}, + {"sqlite", manifest.FormatSQLite}, + {"SQLITE", manifest.FormatSQLite}, + {"db", manifest.FormatSQLite}, + {"DB", manifest.FormatSQLite}, + {"sqlite3", manifest.FormatSQLite}, + {"SQLite3", manifest.FormatSQLite}, + } + for _, tt := range tests { + got, err := manifest.ParseFormat(tt.input) + if err != nil { + t.Errorf("ParseFormat(%q): unexpected error: %v", tt.input, err) + continue + } + if got != tt.want { + t.Errorf("ParseFormat(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestParseFormatInvalid(t *testing.T) { + _, err := manifest.ParseFormat("csv") + if err == nil { + t.Fatal("expected error for invalid format") + } +} + +func TestOpenAppendReadOnlyPath(t *testing.T) { + dir := t.TempDir() + roDir := filepath.Join(dir, "readonly") + if err := os.MkdirAll(roDir, 0755); err != nil { + t.Fatal(err) + } + path := manifest.JSONLPath(roDir) + if err := os.WriteFile(path, []byte("{}\n"), 0444); err != nil { + t.Fatal(err) + } + m := manifest.LoadJSONL(roDir) + err := m.OpenAppend() + if err == nil { + t.Fatal("expected error opening read-only path") + } + m.Close() +} + +func TestJSONLEntriesEmpty(t *testing.T) { + m := manifest.LoadJSONL(t.TempDir()) + entries := m.Entries() + if len(entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(entries)) + } +} + +func TestSQLiteEntriesEmpty(t *testing.T) { + m, err := manifest.LoadSQLite(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + defer m.Close() + entries := m.Entries() + if len(entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(entries)) + } +} + +func TestOpenJSONLWithExistingJSONL(t *testing.T) { + dir := t.TempDir() + jm := manifest.LoadJSONL(dir) + if err := jm.OpenAppend(); err != nil { + t.Fatal(err) + } + jm.Add("exist1", "exist1.jpg", 10, "aws") + jm.Save() + jm.Close() + + m, err := manifest.Open(dir, manifest.FormatJSONL) + if err != nil { + t.Fatal(err) + } + defer m.Close() + if !m.Has("exist1") { + t.Fatal("expected exist1 from existing jsonl") + } +} + +func TestOpenSQLiteWithExistingSQLite(t *testing.T) { + dir := t.TempDir() + sm, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if err := sm.OpenAppend(); err != nil { + t.Fatal(err) + } + sm.Add("sexist1", "sexist1.jpg", 15, "azure") + sm.Close() + + m, err := manifest.Open(dir, manifest.FormatSQLite) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + defer m.Close() + if !m.Has("sexist1") { + t.Fatal("expected sexist1 from existing sqlite") + } +} +func TestLoadJSONLWithEmptyLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "downloads.jsonl") + os.WriteFile(path, []byte("\n\n{\"id\":\"x1\",\"filename\":\"a.jpg\",\"size\":1024,\"cloud\":\"local\",\"exported\":100}\n\n"), 0644) + m := manifest.LoadJSONL(dir) + if !m.Has("x1") { + t.Error("should skip empty lines and parse valid JSON") + } +} + +func TestLoadJSONLMalformedJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "downloads.jsonl") + os.WriteFile(path, []byte("not-json\n{\"id\":\"x1\",\"filename\":\"a.jpg\",\"size\":1024,\"cloud\":\"local\",\"exported\":100}\n"), 0644) + m := manifest.LoadJSONL(dir) + if !m.Has("x1") { + t.Error("should skip malformed lines and parse valid ones") + } +} + +func TestLoadJSONLEmptyID(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "downloads.jsonl") + os.WriteFile(path, []byte("{\"id\":\"\",\"filename\":\"a.jpg\",\"size\":1024,\"cloud\":\"local\",\"exported\":100}\n"), 0644) + m := manifest.LoadJSONL(dir) + if m.Has("") { + t.Error("should skip entries with empty ID") + } +} + +func TestJSONLAddBeforeOpenAppend(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + m.Add("x1", "photo.jpg", 1024, "local") + if !m.Has("x1") { + t.Error("Add before OpenAppend should store in memory") + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() + data, err := os.ReadFile(filepath.Join(dir, "downloads.jsonl")) + if err != nil { + t.Fatal(err) + } + if len(data) > 0 { + t.Error("entries added before OpenAppend should not be in file") + } +} + +func TestSQLiteHasBeforeOpenAppend(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if m.Has("x1") { + t.Error("Has on unopened sqlite should return false") + } +} + +func TestSQLiteAddBeforeOpenAppend(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestSQLiteEntriesBeforeOpenAppend(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + entries := m.Entries() + if entries != nil { + t.Error("Entries on unopened sqlite should return nil") + } +} + +func TestSQLiteOpenAppendReadOnlyPath(t *testing.T) { + m, err := manifest.LoadSQLite("/proc/cannot-create-dir-here") + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err == nil { + t.Error("expected error from OpenAppend on read-only path") + } +} + +func TestFileExistsOnDirectory(t *testing.T) { + dir := t.TempDir() + if manifest.FileExists(dir) { + t.Error("manifest.FileExists should return false for directories") + } +} + +func TestConvertFromJSONLOpenAppendError(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + m.Close() + os.Chmod(filepath.Join(dir, "downloads.jsonl"), 0444) + defer os.Chmod(filepath.Join(dir, "downloads.jsonl"), 0644) + _, err := manifest.ConvertFromJSONL(dir) + if err == nil { + t.Error("expected error when JSONL cannot be opened for reading during conversion") + } +} + +func TestConvertFromSQLiteReadError(t *testing.T) { + dir := t.TempDir() + m, err := manifest.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() + result, err := manifest.ConvertFromSQLite(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + result.Close() + if _, err := os.Stat(manifest.JSONLPath(dir)); err != nil { + t.Errorf("JSONL file should exist after conversion: %v", err) + } + if os.Remove(manifest.SQLitePath(dir)) != nil { + t.Logf("sqlite file removed as expected") + } +} + +func TestJSONLSaveWithoutFile(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + if err := m.Save(); err != nil { + t.Errorf("Save without file should return nil, got %v", err) + } +} + +func TestJSONLOpenAppendTwice(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Error("second OpenAppend should return nil, got", err) + } + m.Close() +} + +func TestSQLiteHasWithoutOpen(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if m.Has("anything") { + t.Error("Has on unopened SQLite should return false") + } +} + +func TestSQLiteAddWithoutOpen(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + if m.Has("x1") { + t.Error("Add without OpenAppend should not persist") + } +} + +func TestSQLiteEntriesWithoutOpen(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + entries := m.Entries() + if entries != nil { + t.Errorf("Entries without open should return nil, got %v", entries) + } +} + +func TestSQLiteCloseWithoutOpen(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestSQLiteOpenAppendCreateDir(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "subdir") + m, err := manifest.LoadSQLite(subdir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Errorf("OpenAppend should create dir, got %v", err) + } + m.Close() +} + +func TestSQLiteSave(t *testing.T) { + dir := t.TempDir() + m, err := manifest.LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + if err := m.Save(); err != nil { + t.Errorf("SQLite Save should return nil, got %v", err) + } + m.Close() +} + +func TestOpenDefaultSQLite(t *testing.T) { + dir := t.TempDir() + m, err := manifest.Open(dir, manifest.FormatSQLite) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestOpenDefaultJSONLNoFile(t *testing.T) { + dir := t.TempDir() + m, err := manifest.Open(dir, manifest.FormatJSONL) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestParseFormat(t *testing.T) { + tests := []struct { + input string + want manifest.Format + err bool + }{ + {"jsonl", manifest.FormatJSONL, false}, + {"json", manifest.FormatJSONL, false}, + {"sqlite", manifest.FormatSQLite, false}, + {"db", manifest.FormatSQLite, false}, + {"sqlite3", manifest.FormatSQLite, false}, + {"invalid", "", true}, + } + for _, tt := range tests { + got, err := manifest.ParseFormat(tt.input) + if tt.err && err == nil { + t.Errorf("ParseFormat(%q) expected error", tt.input) + } + if !tt.err && got != tt.want { + t.Errorf("ParseFormat(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestJSONLSaveWithFileSync(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + if err := m.Save(); err != nil { + t.Errorf("Save with open file should succeed, got %v", err) + } + m.Close() +} + +func TestConvertFromJSONLSuccess(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("c1", "photo.jpg", 1024, "cloud") + m.Close() + result, err := manifest.ConvertFromJSONL(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Has("c1") { + t.Error("converted SQLite manifest should have c1") + } + result.Close() + if manifest.FileExists(manifest.JSONLPath(dir)) { + t.Error("JSONL file should be deleted after conversion") + } +} + +func TestConvertFromJSONLSQLiteOpenError(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + m.Close() + subdir := filepath.Join(dir, "readonly") + if err := os.MkdirAll(subdir, 0755); err != nil { + t.Fatal(err) + } + srcPath := manifest.JSONLPath(dir) + dstPath := filepath.Join(subdir, "downloads.jsonl") + data, err := os.ReadFile(srcPath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dstPath, data, 0644); err != nil { + t.Fatal(err) + } +} + +func TestJSONLOpenAppendMkdirError(t *testing.T) { + dir := t.TempDir() + jsonlFile := filepath.Join(dir, "downloads.jsonl") + if err := os.WriteFile(jsonlFile, []byte("{}\n"), 0644); err != nil { + t.Fatal(err) + } + m := manifest.LoadJSONL(dir) + m.Close() +} + +func TestSQLiteOpenAppendMkdirError(t *testing.T) { + m, err := manifest.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 TestSQLiteHasAfterClose(t *testing.T) { + dir := t.TempDir() + m, err := manifest.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 := manifest.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 after Close should return nil, got %d entries", len(entries)) + } +} + +func TestConvertFromJSONLNoFile(t *testing.T) { + dir := t.TempDir() + result, err := manifest.ConvertFromJSONL(dir) + if err != nil { + t.Fatalf("ConvertFromJSONL with no existing file should work: %v", err) + } + result.Close() +} + +func TestConvertFromSQLiteNoFile(t *testing.T) { + dir := t.TempDir() + result, err := manifest.ConvertFromSQLite(dir) + if err != nil { + t.Fatalf("ConvertFromSQLite with no existing file should work: %v", err) + } + result.Close() + if _, err := os.Stat(manifest.JSONLPath(dir)); err != nil { + t.Errorf("JSONL file should exist after conversion: %v", err) + } +} + +func TestConvertFromJSONLCloseSrcError(t *testing.T) { +} + +func TestConvertFromSQLiteSaveError(t *testing.T) { + dir := t.TempDir() + m, err := manifest.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() + jsonlPath := manifest.JSONLPath(dir) + os.WriteFile(jsonlPath, []byte(""), 0444) + defer os.Chmod(jsonlPath, 0644) + _, err = manifest.ConvertFromSQLite(dir) + if err == nil { + t.Error("expected error from dst.Save during ConvertFromSQLite") + } +} + +func TestOpenExistingJSONL(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + m.Save() + m.Close() + m2, err := manifest.Open(dir, manifest.FormatJSONL) + if err != nil { + t.Fatal(err) + } + if !m2.Has("x1") { + t.Error("should have x1 after reopening") + } + m2.Close() +} + +func TestOpenExistingSQLite(t *testing.T) { + dir := t.TempDir() + m, err := manifest.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() + m2, err := manifest.Open(dir, manifest.FormatSQLite) + if err != nil { + t.Fatal(err) + } + if err := m2.OpenAppend(); err != nil { + t.Fatal(err) + } + if !m2.Has("x1") { + t.Error("should have x1 after reopening") + } + m2.Close() +} + +func TestOpenConvertJSONLToSQLite(t *testing.T) { + dir := t.TempDir() + m := manifest.LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + m.Close() + m2, err := manifest.Open(dir, manifest.FormatSQLite) + if err != nil { + t.Fatal(err) + } + if !m2.Has("x1") { + t.Error("should have x1 after conversion") + } + m2.Close() + if manifest.FileExists(manifest.JSONLPath(dir)) { + t.Error("JSONL file should be deleted after conversion") + } +} + +func TestOpenConvertSQLiteToJSONL(t *testing.T) { + dir := t.TempDir() + m, err := manifest.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() + m2, err := manifest.Open(dir, manifest.FormatJSONL) + if err != nil { + t.Fatal(err) + } + if !m2.Has("x1") { + t.Error("should have x1 after conversion") + } + m2.Close() + if manifest.FileExists(manifest.SQLitePath(dir)) { + t.Error("SQLite file should be deleted after conversion") + } +} + +func TestSQLiteOpenAppendInvalidPath(t *testing.T) { + m, err := manifest.LoadSQLite("/dev/null/impossible/path") + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err == nil { + t.Error("expected error from OpenAppend with invalid path") + m.Close() + } +} + +func TestConvertFromSQLiteJSONLOpenAppendError(t *testing.T) { + dir := t.TempDir() + m, err := manifest.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() + jsonlPath := manifest.JSONLPath(dir) + os.WriteFile(jsonlPath, []byte("{}\n"), 0444) + defer os.Chmod(jsonlPath, 0644) + _, err = manifest.ConvertFromSQLite(dir) + if err == nil { + t.Error("expected error from dst.OpenAppend during ConvertFromSQLite") + } +} + +func TestJSONLOpenAppendMkdirAllError(t *testing.T) { + m := manifest.LoadJSONL("/dev/null/impossible/path/downloads.jsonl") + if err := m.OpenAppend(); err == nil { + t.Error("expected error from OpenAppend with impossible path") + m.Close() + } +} diff --git a/internal/manifest/open.go b/internal/manifest/open.go new file mode 100644 index 0000000..a65f401 --- /dev/null +++ b/internal/manifest/open.go @@ -0,0 +1,106 @@ +package manifest + +import ( + "fmt" + "os" + "strings" +) + +type Format string + +const ( + FormatJSONL Format = "jsonl" + FormatSQLite Format = "sqlite" +) + +func Open(dir string, format Format) (Manifest, error) { + jsonlPath := JSONLPath(dir) + 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) { + src := LoadJSONL(dir) + 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() { + dst.Add(id, e.Filename, e.Size, e.Cloud) + } + + os.Remove(JSONLPath(dir)) + return dst, nil +} + +func ConvertFromSQLite(dir string) (Manifest, error) { + src, _ := LoadSQLite(dir) + 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() { + dst.Add(id, e.Filename, e.Size, e.Cloud) + } + 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 { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} + +func ParseFormat(s string) (Format, error) { + switch strings.ToLower(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) { + if sm, ok := m.(*sqliteManifest); ok && sm.DB() != nil { + return NewSQLiteLogWriter(sm.DB()) + } + return NewFileLogWriter(LogPath(dir)) +} diff --git a/internal/manifest/sqlite.go b/internal/manifest/sqlite.go new file mode 100644 index 0000000..6e7f48c --- /dev/null +++ b/internal/manifest/sqlite.go @@ -0,0 +1,134 @@ +package manifest + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +type sqliteManifest struct { + path string + db *sql.DB + open sqlOpenerFunc + execFunc func(query string, args ...any) (sql.Result, error) +} + +type sqlOpenerFunc func(driverName, dataSourceName string) (*sql.DB, error) + +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") +} + +func LoadSQLite(dir string) (*sqliteManifest, error) { + m := &sqliteManifest{ + path: SQLitePath(dir), + open: defaultSQLOpener(), + } + return m, nil +} + +func (m *sqliteManifest) OpenAppend() error { + if m.db != nil { + return nil + } + if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil { + return err + } + opener := m.open + if opener == nil { + opener = sql.Open + } + db, err := opener("sqlite", m.path) + if err != nil { + return fmt.Errorf("open sqlite: %w", err) + } + execFn := m.execFunc + if execFn == nil { + execFn = db.Exec + } + _, err = execFn(`CREATE TABLE IF NOT EXISTS downloads ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + cloud TEXT NOT NULL DEFAULT '', + exported INTEGER NOT NULL DEFAULT 0 + )`) + if err != nil { + db.Close() + return fmt.Errorf("create table: %w", err) + } + _, err = execFn(`CREATE INDEX IF NOT EXISTS idx_downloads_id ON downloads(id)`) + if err != nil { + db.Close() + return fmt.Errorf("create index: %w", err) + } + m.db = db + return nil +} + +func (m *sqliteManifest) Has(id string) bool { + if m.db == nil { + return false + } + var count int + err := m.db.QueryRow(`SELECT COUNT(*) FROM downloads WHERE id = ?`, id).Scan(&count) + if err != nil { + return false + } + return count > 0 +} + +func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) { + if m.db == nil { + return + } + entry := newEntry(id, filename, size, cloud) + m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, size, cloud, exported) VALUES (?, ?, ?, ?, ?)`, + id, entry.Filename, entry.Size, entry.Cloud, entry.Exported) +} + +func (m *sqliteManifest) Save() error { + return nil +} + +func (m *sqliteManifest) Close() { + if m.db != nil { + m.db.Close() + m.db = nil + } +} + +func (m *sqliteManifest) DB() *sql.DB { + return m.db +} + +func (m *sqliteManifest) Entries() map[string]Entry { + if m.db == nil { + return nil + } + out := make(map[string]Entry) + rows, err := m.db.Query(`SELECT id, filename, size, cloud, exported FROM downloads`) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var e Entry + if err := rows.Scan(&e.ID, &e.Filename, &e.Size, &e.Cloud, &e.Exported); err == nil { + out[e.ID] = e + } + } + return out +} diff --git a/internal/manifest/sqlite_internal_test.go b/internal/manifest/sqlite_internal_test.go new file mode 100644 index 0000000..fc94a20 --- /dev/null +++ b/internal/manifest/sqlite_internal_test.go @@ -0,0 +1,402 @@ +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) { + callCount++ + if callCount == 2 { + 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 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) + } +} diff --git a/internal/manifest/sqlite_log.go b/internal/manifest/sqlite_log.go new file mode 100644 index 0000000..30e8146 --- /dev/null +++ b/internal/manifest/sqlite_log.go @@ -0,0 +1,41 @@ +package manifest + +import ( + "database/sql" +) + +type sqliteLogWriter struct { + db *sql.DB +} + +func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + level TEXT NOT NULL, + event TEXT NOT NULL, + asset_id TEXT, + album TEXT, + filename TEXT, + size INTEGER, + cloud TEXT, + duration_ms INTEGER, + message TEXT + )`) + if err != nil { + return nil, err + } + _, _ = 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)`) + return &sqliteLogWriter{db: db}, nil +} + +func (w *sqliteLogWriter) Log(e LogEntry) { + if w.db == nil { + return + } + w.db.Exec(`INSERT INTO logs (ts, level, event, asset_id, album, filename, size, cloud, duration_ms, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 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 } diff --git a/internal/photos/bridge.go b/internal/photos/bridge.go index 09ed085..a5544a1 100644 --- a/internal/photos/bridge.go +++ b/internal/photos/bridge.go @@ -10,9 +10,9 @@ type Bridge interface { ListAlbums() ([]Album, error) ListAssets(albumID string) ([]Asset, int, error) ListTree() ([]CollectionNode, error) - ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) + ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) - ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) + ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) Cancel() IsCancelled() bool diff --git a/internal/photos/cgo_bridge.go b/internal/photos/cgo_bridge.go index f7a1195..052c3bc 100644 --- a/internal/photos/cgo_bridge.go +++ b/internal/photos/cgo_bridge.go @@ -62,29 +62,29 @@ func (*CgoBridge) IsCancelled() bool { return C.photos_request_is_cancelled() != 0 } -func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { - return exportPreviewWithSlot(assetID, outputDir, targetSize, index, -1) +func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) { + return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, -1) } func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { return exportOriginalWithSlot(assetID, outputDir, index, -1) } -func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { - return exportPreviewWithSlot(assetID, outputDir, targetSize, index, slotIndex) +func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) { + return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, slotIndex) } func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) { return exportOriginalWithSlot(assetID, outputDir, index, slotIndex) } -func exportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { +func exportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) { cid := C.CString(assetID) defer C.free(unsafe.Pointer(cid)) cdir := C.CString(outputDir) defer C.free(unsafe.Pointer(cdir)) - cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex)) + cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(quality), C.int(index), C.int(slotIndex)) if cs == nil { return ExportResult{}, errBridgeNil } @@ -117,8 +117,8 @@ func GetProgressSlots() []ExportProgressSlot { ptr := (*C.export_progress_t)(unsafe.Pointer(uintptr(unsafe.Pointer(slots)) + uintptr(i)*unsafe.Sizeof(C.export_progress_t{}))) result[i] = ExportProgressSlot{ Active: ptr.active != 0, - Progress: float64(ptr.progress), - BytesDone: int64(ptr.bytes_done), + Progress: float64(ptr.progress), + BytesDone: int64(ptr.bytes_done), BytesTotal: int64(ptr.bytes_total), } } @@ -129,6 +129,10 @@ func ResetProgressSlots() { C.photos_reset_progress_slots() } +func GetProgressSlotCount() int { + return int(C.photos_get_progress_slot_count()) +} + type ExportProgressSlot struct { Active bool Progress float64 diff --git a/internal/photos/cgo_bridge_test_impl.go b/internal/photos/cgo_bridge_test_impl.go index eca8168..034a6c8 100644 --- a/internal/photos/cgo_bridge_test_impl.go +++ b/internal/photos/cgo_bridge_test_impl.go @@ -17,7 +17,11 @@ void photos_test_set_assets_null(void); void photos_test_set_tree_null(void); void photos_request_cancel(void); void photos_test_set_export_preview_json(const char *json); +void photos_test_set_export_preview_json_null(void); void photos_test_set_export_original_json(const char *json); +void photos_test_set_export_original_json_null(void); +void photos_test_set_progress_slot_count(int count); +void photos_test_set_progress_slots_null(int val); */ import "C" @@ -27,15 +31,19 @@ type CgoBridge struct{} var DefaultBridge Bridge = &CgoBridge{} -func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) } -func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) } -func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) } -func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) } -func SetTestAlbumsNull() { C.photos_test_set_albums_null() } -func SetTestAssetsNull() { C.photos_test_set_assets_null() } -func SetTestTreeNull() { C.photos_test_set_tree_null() } -func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) } +func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) } +func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) } +func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) } +func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) } +func SetTestAlbumsNull() { C.photos_test_set_albums_null() } +func SetTestAssetsNull() { C.photos_test_set_assets_null() } +func SetTestTreeNull() { C.photos_test_set_tree_null() } +func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) } +func SetTestExportPreviewJSONNull() { C.photos_test_set_export_preview_json_null() } func SetTestExportOriginalJSON(json string) { C.photos_test_set_export_original_json(C.CString(json)) } +func SetTestExportOriginalJSONNull() { C.photos_test_set_export_original_json_null() } +func SetTestProgressSlotCount(count int) { C.photos_test_set_progress_slot_count(C.int(count)) } +func SetTestProgressSlotsNull(val int) { C.photos_test_set_progress_slots_null(C.int(val)) } func (*CgoBridge) RequestAccess() error { rc := C.photos_request_access() @@ -82,28 +90,28 @@ func (*CgoBridge) IsCancelled() bool { return C.photos_request_is_cancelled() != 0 } -func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { - return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, -1) +func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) { + return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, -1) } func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { return exportOriginalWithSlotTest(assetID, outputDir, index, -1) } -func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { - return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, slotIndex) +func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) { + return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, slotIndex) } func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) { return exportOriginalWithSlotTest(assetID, outputDir, index, slotIndex) } -func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { +func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) { cid := C.CString(assetID) defer C.free(unsafe.Pointer(cid)) cdir := C.CString(outputDir) defer C.free(unsafe.Pointer(cdir)) - cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex)) + cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(quality), C.int(index), C.int(slotIndex)) if cs == nil { return ExportResult{}, errBridgeNil } @@ -132,8 +140,31 @@ type ExportProgressSlot struct { } func GetProgressSlots() []ExportProgressSlot { - return nil + count := int(C.photos_get_progress_slot_count()) + if count <= 0 { + return nil + } + cSlots := C.photos_get_progress_slots() + if cSlots == nil { + return nil + } + slots := make([]ExportProgressSlot, count) + for i := 0; i < count; i++ { + s := C.photos_get_progress_slot(cSlots, C.int(i)) + slots[i] = ExportProgressSlot{ + Active: s.active != 0, + Progress: float64(s.progress), + BytesDone: int64(s.bytes_done), + BytesTotal: int64(s.bytes_total), + } + } + return slots } func ResetProgressSlots() { + C.photos_reset_progress_slots() +} + +func GetProgressSlotCount() int { + return int(C.photos_get_progress_slot_count()) } diff --git a/internal/photos/photos.go b/internal/photos/photos.go index eb2b313..d2d3c5b 100644 --- a/internal/photos/photos.go +++ b/internal/photos/photos.go @@ -5,4 +5,4 @@ package photos import "fmt" var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security") -var errBridgeNil = fmt.Errorf("bridge returned nil") \ No newline at end of file +var errBridgeNil = fmt.Errorf("bridge returned nil") diff --git a/internal/photos/photos_test.go b/internal/photos/photos_test.go index c45b42d..8430f5b 100644 --- a/internal/photos/photos_test.go +++ b/internal/photos/photos_test.go @@ -35,9 +35,9 @@ func TestParseAlbumsJSON(t *testing.T) { wantErr: true, }, { - name: "missing albums key", - json: `{}`, - want: []Album{}, + name: "missing albums key", + json: `{}`, + want: []Album{}, }, } @@ -66,23 +66,23 @@ func TestParseAlbumsJSON(t *testing.T) { func TestParseAssetsJSON(t *testing.T) { tests := []struct { - name string - json string - want []Asset + name string + json string + want []Asset wantTotal int - wantErr bool - errMsg string + wantErr bool + errMsg string }{ { - name: "empty assets", - json: `{"assets":[],"total":0}`, - want: []Asset{}, + name: "empty assets", + json: `{"assets":[],"total":0}`, + want: []Asset{}, wantTotal: 0, }, { - name: "single asset", - json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`, - want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}}, + name: "single asset", + json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`, + want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}}, wantTotal: 1, }, { @@ -106,9 +106,9 @@ func TestParseAssetsJSON(t *testing.T) { wantTotal: 1, }, { - name: "multiple assets", - json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`, - want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}}, + name: "multiple assets", + json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`, + want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}}, wantTotal: 3, }, { @@ -132,9 +132,9 @@ func TestParseAssetsJSON(t *testing.T) { wantErr: true, }, { - name: "empty error is not an error", - json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`, - want: []Asset{{ID: "a1", Filename: "IMG.JPG"}}, + name: "empty error is not an error", + json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`, + want: []Asset{{ID: "a1", Filename: "IMG.JPG"}}, wantTotal: 1, }, } @@ -407,11 +407,11 @@ func TestErrBridgeNilMessage(t *testing.T) { func TestParseExportResultJSON(t *testing.T) { tests := []struct { - name string - json string - want ExportResult - wantErr bool - errMsg string + name string + json string + want ExportResult + wantErr bool + errMsg string }{ { name: "success", @@ -488,3 +488,244 @@ func equalAsset(a, b Asset) bool { } return true } + +func TestCgoBridgeExportPreviewViaStub(t *testing.T) { + SetTestExportPreviewJSON(`{"filename":"0001_img.jpg","size":2048,"cloud":"local"}`) + defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`) + bridge := &CgoBridge{} + result, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0) + if err != nil { + t.Fatal(err) + } + if result.Filename != "0001_img.jpg" { + t.Errorf("got filename %q, want %q", result.Filename, "0001_img.jpg") + } + if result.Size != 2048 { + t.Errorf("got size %d, want %d", result.Size, 2048) + } + if result.Cloud != "local" { + t.Errorf("got cloud %q, want %q", result.Cloud, "local") + } +} + +func TestCgoBridgeExportPreviewWithSlotViaStub(t *testing.T) { + SetTestExportPreviewJSON(`{"filename":"slot_img.jpg","size":4096,"cloud":"cloud"}`) + defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`) + bridge := &CgoBridge{} + result, err := bridge.ExportPreviewWithSlot("asset-1", "/tmp", 2048, 85, 0, 1) + if err != nil { + t.Fatal(err) + } + if result.Filename != "slot_img.jpg" { + t.Errorf("got filename %q", result.Filename) + } + if result.Cloud != "cloud" { + t.Errorf("got cloud %q, want %q", result.Cloud, "cloud") + } +} + +func TestCgoBridgeExportOriginalViaStub(t *testing.T) { + SetTestExportOriginalJSON(`{"filename":"original.jpg","size":8192,"cloud":"local"}`) + defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`) + bridge := &CgoBridge{} + result, err := bridge.ExportOriginal("asset-1", "/tmp", 0) + if err != nil { + t.Fatal(err) + } + if result.Filename != "original.jpg" { + t.Errorf("got filename %q, want %q", result.Filename, "original.jpg") + } + if result.Size != 8192 { + t.Errorf("got size %d, want %d", result.Size, 8192) + } +} + +func TestCgoBridgeExportOriginalWithSlotViaStub(t *testing.T) { + SetTestExportOriginalJSON(`{"filename":"slot_orig.heic","size":16384,"cloud":"local"}`) + defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`) + bridge := &CgoBridge{} + result, err := bridge.ExportOriginalWithSlot("asset-1", "/tmp", 0, 2) + if err != nil { + t.Fatal(err) + } + if result.Filename != "slot_orig.heic" { + t.Errorf("got filename %q", result.Filename) + } +} + +func TestCgoBridgeExportPreviewErrorViaStub(t *testing.T) { + SetTestExportPreviewJSON(`{"error":"disk full","cloud":"local"}`) + defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`) + bridge := &CgoBridge{} + _, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "disk full" { + t.Errorf("got error %q, want %q", err.Error(), "disk full") + } +} + +func TestCgoBridgeExportOriginalErrorViaStub(t *testing.T) { + SetTestExportOriginalJSON(`{"error":"write failed","cloud":"local"}`) + defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`) + bridge := &CgoBridge{} + _, err := bridge.ExportOriginal("asset-1", "/tmp", 0) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "write failed" { + t.Errorf("got error %q, want %q", err.Error(), "write failed") + } +} + +func TestCgoBridgeExportSkippedResult(t *testing.T) { + SetTestExportPreviewJSON(`{"filename":"skip.jpg","size":0,"cloud":"local","skipped":true}`) + defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`) + bridge := &CgoBridge{} + result, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0) + if err != nil { + t.Fatal(err) + } + if !result.Skipped { + t.Error("expected Skipped to be true") + } +} + +func TestCgoBridgeCancelAndIsCancelled(t *testing.T) { + bridge := &CgoBridge{} + if bridge.IsCancelled() { + t.Error("should not be cancelled initially") + } + bridge.Cancel() + if !bridge.IsCancelled() { + t.Error("should be cancelled after Cancel()") + } +} + +func TestGetProgressSlotsReturnsSlots(t *testing.T) { + slots := GetProgressSlots() + if slots == nil { + t.Errorf("GetProgressSlots should return slots in test build") + } + if len(slots) != 16 { + t.Errorf("GetProgressSlots should return 16 slots, got %d", len(slots)) + } +} + +func TestResetProgressSlotsNoPanic(t *testing.T) { + ResetProgressSlots() +} + +func TestGetProgressSlotCount(t *testing.T) { + count := GetProgressSlotCount() + if count != 16 { + t.Errorf("expected 16, got %d", count) + } +} + +func TestCgoBridgeExportPreviewNilStub(t *testing.T) { + SetTestExportPreviewJSONNull() + defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`) + bridge := &CgoBridge{} + _, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0) + if err == nil { + t.Fatal("expected errBridgeNil") + } + if err != errBridgeNil { + t.Errorf("got %v, want %v", err, errBridgeNil) + } +} + +func TestCgoBridgeExportOriginalNilStub(t *testing.T) { + SetTestExportOriginalJSONNull() + defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`) + bridge := &CgoBridge{} + _, err := bridge.ExportOriginal("asset-1", "/tmp", 0) + if err == nil { + t.Fatal("expected errBridgeNil") + } + if err != errBridgeNil { + t.Errorf("got %v, want %v", err, errBridgeNil) + } +} + +func TestCgoBridgeExportPreviewWithSlotNilStub(t *testing.T) { + SetTestExportPreviewJSONNull() + defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`) + bridge := &CgoBridge{} + _, err := bridge.ExportPreviewWithSlot("asset-1", "/tmp", 1024, 85, 0, 0) + if err == nil { + t.Fatal("expected errBridgeNil") + } + if err != errBridgeNil { + t.Errorf("got %v, want %v", err, errBridgeNil) + } +} + +func TestCgoBridgeExportOriginalWithSlotNilStub(t *testing.T) { + SetTestExportOriginalJSONNull() + defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`) + bridge := &CgoBridge{} + _, err := bridge.ExportOriginalWithSlot("asset-1", "/tmp", 0, 0) + if err == nil { + t.Fatal("expected errBridgeNil") + } + if err != errBridgeNil { + t.Errorf("got %v, want %v", err, errBridgeNil) + } +} + +func TestGetProgressSlotsWithActiveSlot(t *testing.T) { + ResetProgressSlots() + slots := GetProgressSlots() + if len(slots) != 16 { + t.Errorf("expected 16 slots, got %d", len(slots)) + } + for i, s := range slots { + if s.Active { + t.Errorf("slot %d should not be active after reset", i) + } + } +} + +func TestResetProgressSlotsClearsState(t *testing.T) { + ResetProgressSlots() + slots := GetProgressSlots() + if len(slots) > 0 && slots[0].Active { + t.Errorf("slot should be inactive after reset") + } +} + +func TestGetProgressSlotsZeroCount(t *testing.T) { + SetTestProgressSlotCount(0) + defer SetTestProgressSlotCount(3) + slots := GetProgressSlots() + if slots != nil { + t.Errorf("expected nil with zero count, got %v", slots) + } +} + +func TestGetProgressSlotsNullPointer(t *testing.T) { + SetTestProgressSlotsNull(1) + defer SetTestProgressSlotsNull(0) + slots := GetProgressSlots() + if slots != nil { + t.Errorf("expected nil with null pointer, got %v", slots) + } +} + +func TestExportProgressSlotType(t *testing.T) { + slot := ExportProgressSlot{ + Active: true, + Progress: 0.75, + BytesDone: 512, + BytesTotal: 1024, + } + if !slot.Active { + t.Error("expected Active to be true") + } + if slot.Progress != 0.75 { + t.Errorf("expected Progress 0.75, got %f", slot.Progress) + } +} diff --git a/internal/photos/types.go b/internal/photos/types.go index 8f484c4..abfc78f 100644 --- a/internal/photos/types.go +++ b/internal/photos/types.go @@ -6,17 +6,17 @@ type Album struct { } type Asset struct { - ID string `json:"id"` - Filename string `json:"filename"` - Cloud string `json:"cloud"` - MediaType string `json:"mediaType"` - PixelWidth int `json:"pixelWidth"` - PixelHeight int `json:"pixelHeight"` - CreationDate *string `json:"creationDate,omitempty"` - Duration float64 `json:"duration,omitempty"` - IsFavorite bool `json:"isFavorite,omitempty"` - HasAdjustments bool `json:"hasAdjustments,omitempty"` - Resources []AssetResource `json:"resources,omitempty"` + ID string `json:"id"` + Filename string `json:"filename"` + Cloud string `json:"cloud"` + MediaType string `json:"mediaType"` + PixelWidth int `json:"pixelWidth"` + PixelHeight int `json:"pixelHeight"` + CreationDate *string `json:"creationDate,omitempty"` + Duration float64 `json:"duration,omitempty"` + IsFavorite bool `json:"isFavorite,omitempty"` + HasAdjustments bool `json:"hasAdjustments,omitempty"` + Resources []AssetResource `json:"resources,omitempty"` } type AssetResource struct {