v0.5.0: manifests, filters, logging, docs
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 00:00:06 +02:00
parent 3d3c4a4742
commit 2e73d01b40
33 changed files with 7238 additions and 512 deletions
+34
View File
@@ -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
+48
View File
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.4.0 VERSION := 0.5.0
BRIDGE_DIR := bridge BRIDGE_DIR := bridge
LDFLAGS := -X main.version=$(VERSION) LDFLAGS := -X main.version=$(VERSION)
OBJ := $(BRIDGE_DIR)/photokit_bridge.o OBJ := $(BRIDGE_DIR)/photokit_bridge.o
@@ -56,4 +56,4 @@ pipeline: clean test build
@echo "--- verifying version ---" @echo "--- verifying version ---"
$(BINARY) version $(BINARY) version
@echo "--- all checks passed, ready to release ---" @echo "--- all checks passed, ready to release ---"
@echo "run: make release" @echo "run: make release"
+323 -138
View File
@@ -1,43 +1,33 @@
# photoscli # 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 ## Highlights
- 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
## What The Code Does - List albums, photos, and the Photos folder/album tree.
- Export one album or back up the whole Photos tree.
The executable lives in `cmd/photoscli` and calls into `internal/photos`, which wraps an Objective-C bridge in `bridge/`. - Export JPEG previews with configurable size and quality, or original files.
- Skip already-exported assets through a JSONL or SQLite manifest.
Current behavior: - Resume interrupted runs safely.
- Parallel export with live worker progress and ETA.
- `albums` prints one line per album as `<album-id>\t<title>` - Optional structured logging to `export.log` or SQLite `logs` table.
- `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 - Dry-run mode for planning large backups.
- `tree` prints the human-readable folder and album hierarchy from Apple Photos - Filters for favorites, media type, date, estimated size, and excluded albums.
- `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 - Failure tracking with `failures.jsonl` and `retry-failed`.
- `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 - Verification, reporting, and diff commands for backup integrity.
- Script-friendly exit codes and optional JSON summaries.
The bridge uses PhotoKit to: - 100% test coverage for the Go CLI and parsing layers.
- 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
## Requirements ## Requirements
- macOS - macOS with Apple Photos.
- Go 1.22+ - Go 1.25 or newer.
- Xcode command-line tools - 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 ## Build
@@ -45,41 +35,79 @@ The project builds with cgo and links against `Photos`, `Foundation`, `AppKit`,
make build make build
``` ```
Output binary: The binary is written to:
```bash ```bash
./bin/photoscli ./bin/photoscli
``` ```
## Test Run the full local release pipeline:
```bash ```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 ```bash
./bin/photoscli albums ./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 Export preview JPEGs from one album:
- Lists albums as tab-separated album ID and title
```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: Example output:
@@ -88,128 +116,285 @@ Example output:
8A1B.../L0/001 Work 8A1B.../L0/001 Work
``` ```
`photos --album-id <id-or-title>` ### `photos`
- Requests Photos access Lists assets in an album. `--album-id` accepts a PhotoKit local identifier or an exact album title.
- If the value looks like a PhotoKit local identifier, uses it directly
- Otherwise searches album titles for a match and resolves the identifier ```bash
- Lists asset local identifiers and cloud status for the given album 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: Example output:
```text ```text
1F2A.../L0/001 IMG_0001.JPG local entries 12345
9C4D.../L0/001 IMG_0002.JPG cloud failures 2
``` ```
`backup-all --out <dir> [--size <px>] [--originals]` ### `diff`
- Requests Photos access Compares an album against the manifest and prints assets missing from the manifest.
- 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
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 ```text
backup/ downloads.jsonl
Trips/
Italy 2024/
Venice/
0000_....jpg
Favorites/
0000_....jpg
``` ```
`export --album-id <id-or-title> --out <dir> [--size <px>] [--originals]` SQLite backend:
- 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:
```text ```text
Trips downloads.db
Italy 2024
Venice
Favorites
``` ```
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 ## 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 ```bash
- keep their original filenames when exporting originals when possible make test
- 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
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 Create a release after committing and tagging:
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
A second signal forces an immediate exit. ```bash
make release
```
## Architecture ## Architecture
- `cmd/photoscli`: CLI entrypoint, argument parsing, progress display, and album name resolution - `cmd/photoscli`: CLI, argument handling, filtering, manifests, progress, reporting, and command orchestration.
- `internal/photos`: Go bridge interface, JSON parsing, and error mapping - `internal/photos`: Go interface and JSON parsing for PhotoKit responses.
- `bridge/`: Objective-C PhotoKit implementation plus a C test stub - `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 - This project is macOS-only.
- Album title resolution matches the first album with that title; if multiple albums share a title, use the local identifier instead - 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.
- `photos` only prints asset IDs and filenames, not dates or metadata - Album title lookup uses exact title matching. Use PhotoKit local identifiers when names are ambiguous.
- Preview export uses PhotoKit preview rendering, not original file export - iCloud-backed assets may trigger downloads and can fail due to network or account state.
- Original export currently writes the first PhotoKit asset resource for each asset, which may not capture every related representation for complex assets - `--min-size` and `--max-size` currently use estimated pixel count from dimensions, not encoded file size.
- 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
+2
View File
@@ -24,6 +24,7 @@ char *photos_export_preview_json(
const char *asset_id, const char *asset_id,
const char *output_dir, const char *output_dir,
int target_size, int target_size,
int quality,
int index, int index,
int slot_index int slot_index
); );
@@ -44,6 +45,7 @@ int photos_request_is_cancelled(void);
void photos_free_string(char *value); void photos_free_string(char *value);
export_progress_t *photos_get_progress_slots(void); 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); int photos_get_progress_slot_count(void);
void photos_reset_progress_slots(void); void photos_reset_progress_slots(void);
+103 -69
View File
@@ -7,7 +7,7 @@
static volatile int photos_cancelled = 0; 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 export_progress_t progress_slots[PROGRESS_SLOT_COUNT];
static void reset_slot(int slot_index) { static void reset_slot(int slot_index) {
@@ -42,7 +42,7 @@ static NSDictionary *collection_to_dict(PHCollection *collection) {
if ([collection isKindOfClass:[PHCollectionList class]]) { if ([collection isKindOfClass:[PHCollectionList class]]) {
PHCollectionList *list = (PHCollectionList *)collection; PHCollectionList *list = (PHCollectionList *)collection;
PHFetchResult<PHCollection *> *children = [PHCollectionList fetchCollectionsInCollectionList:list PHFetchResult<PHCollection *> *children = [PHCollectionList fetchCollectionsInCollectionList:list
options:nil]; options:nil];
NSMutableArray *childList = [NSMutableArray arrayWithCapacity:children.count]; NSMutableArray *childList = [NSMutableArray arrayWithCapacity:children.count];
for (NSUInteger i = 0; i < children.count; i++) { for (NSUInteger i = 0; i < children.count; i++) {
NSDictionary *child = collection_to_dict(children[i]); NSDictionary *child = collection_to_dict(children[i]);
@@ -107,7 +107,7 @@ static BOOL ensure_directory(NSString *outputDir) {
NSFileManager *fm = [NSFileManager defaultManager]; NSFileManager *fm = [NSFileManager defaultManager];
NSError *dirErr = nil; NSError *dirErr = nil;
BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
attributes:nil error:&dirErr]; attributes:nil error:&dirErr];
if (!ok && dirErr) { if (!ok && dirErr) {
NSLog(@"ensure_directory failed: %@", dirErr); NSLog(@"ensure_directory failed: %@", dirErr);
} }
@@ -151,7 +151,6 @@ static NSString *unique_path_for_filename(NSString *outputDir, NSString *filenam
return [outputDir stringByAppendingPathComponent:prefixed]; return [outputDir stringByAppendingPathComponent:prefixed];
} }
// Must stay in sync with sanitizePathComponent in cmd/photoscli/main.go
static NSString *sanitized_path_component(NSString *name) { static NSString *sanitized_path_component(NSString *name) {
NSString *source = name ?: @"Untitled"; NSString *source = name ?: @"Untitled";
NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy]; NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy];
@@ -170,6 +169,85 @@ static NSString *sanitized_path_component(NSString *name) {
return safe; 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) { int photos_request_access(void) {
__block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus]; __block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
if (status == PHAuthorizationStatusNotDetermined) { if (status == PHAuthorizationStatusNotDetermined) {
@@ -247,42 +325,6 @@ static NSString *iso8601_string(NSDate *date) {
#import <objc/message.h> #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) { char *photos_list_assets_json(const char *album_id) {
if (!album_id) return json_from_object(make_error_dict(@"album_id is required")); 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]; PHAsset *asset = assets[i];
NSString *lid = asset.localIdentifier; NSString *lid = asset.localIdentifier;
NSString *filename = nil; NSString *filename = nil;
NSArray<PHAssetResource *> *resources = nil; NSArray<PHAssetResource *> *resources = safe_asset_resources(asset);
@try {
resources = [PHAssetResource assetResourcesForAsset:asset];
} @catch (NSException *e) {
resources = @[];
}
if (resources.count > 0) { if (resources.count > 0) {
filename = resources.firstObject.originalFilename; 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 *mediaTypeStr = media_type_string(asset.mediaType);
NSString *creationDateStr = iso8601_string(asset.creationDate); 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) #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 (!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 (target_size <= 0) target_size = 1024;
if (quality <= 0 || quality > 100) quality = 85;
NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; NSString *nsAssetId = [NSString stringWithUTF8String:asset_id];
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; 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; return;
} }
if (result) { 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); dispatch_semaphore_signal(sem);
}]; }];
if (!semaphore_wait_with_timeout(sem, 120)) { 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) { 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); 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(@{ RETURN_PREVIEW(json_from_object(@{
@"filename": filename, @"filename": filename,
@"size": existingSize, @"size": existingSize,
@"cloud": asset_cloud_status_string(asset), @"cloud": asset_cloud_status_string_safe(asset),
@"skipped": @YES @"skipped": @YES
})); }));
} }
@@ -461,7 +500,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
NSError *writeErr = nil; NSError *writeErr = nil;
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error"; 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; 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(@{ RETURN_PREVIEW(json_from_object(@{
@"filename": filename, @"filename": filename,
@"size": fileSize ?: @0, @"size": fileSize ?: @0,
@"cloud": asset_cloud_status_string(asset) @"cloud": asset_cloud_status_string_safe(asset)
})); }));
} }
#undef RETURN_PREVIEW #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; progress_slots[slot_index].bytes_total = 0;
} }
NSArray<PHAssetResource *> *resources = nil; NSArray<PHAssetResource *> *resources = safe_asset_resources(asset);
@try {
resources = [PHAssetResource assetResourcesForAsset:asset];
} @catch (NSException *e) {
resources = @[];
}
if (resources.count == 0) { 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; 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(@{ RETURN_ORIGINAL(json_from_object(@{
@"filename": filename, @"filename": filename,
@"size": existingSize, @"size": existingSize,
@"cloud": asset_cloud_status_string(asset), @"cloud": asset_cloud_status_string_safe(asset),
@"skipped": @YES @"skipped": @YES
})); }));
} }
@@ -563,7 +597,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
dispatch_semaphore_signal(sem); dispatch_semaphore_signal(sem);
}]; }];
if (!semaphore_wait_with_timeout(sem, 120)) { 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) { 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)) { 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) { if (fallbackErr || !fallbackData) {
@@ -599,7 +633,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
if (fallbackErr) { if (fallbackErr) {
detail = [NSString stringWithFormat:@"%@; fallback: %@", detail, fallbackErr.localizedDescription]; 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; NSString *ext = nil;
@@ -622,7 +656,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
NSError *writeFallbackErr = nil; NSError *writeFallbackErr = nil;
if (![fallbackData writeToFile:fallbackPath options:NSDataWritingAtomic error:&writeFallbackErr]) { 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; 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(@{ RETURN_ORIGINAL(json_from_object(@{
@"filename": fallbackFilename, @"filename": fallbackFilename,
@"size": fileSize ?: @0, @"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(@{ RETURN_ORIGINAL(json_from_object(@{
@"filename": writtenFilename, @"filename": writtenFilename,
@"size": fileSize ?: @0, @"size": fileSize ?: @0,
@"cloud": asset_cloud_status_string(asset) @"cloud": asset_cloud_status_string_safe(asset)
})); }));
} }
#undef RETURN_ORIGINAL #undef RETURN_ORIGINAL
@@ -675,4 +709,4 @@ int photos_get_progress_slot_count(void) {
void photos_reset_progress_slots(void) { void photos_reset_progress_slots(void) {
memset(progress_slots, 0, sizeof(progress_slots)); memset(progress_slots, 0, sizeof(progress_slots));
} }
+32 -4
View File
@@ -2,7 +2,9 @@
#include <string.h> #include <string.h>
#include "../bridge/photokit_bridge.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) { static char *alloc_json(const char *s) {
size_t len = strlen(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); 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)asset_id;
(void)output_dir; (void)output_dir;
(void)target_size; (void)target_size;
(void)quality;
(void)index; (void)index;
(void)slot_index; (void)slot_index;
return maybe_alloc_json(stub_export_preview_json); 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; 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) { void photos_test_set_export_original_json(const char *json) {
stub_export_original_json = 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) { export_progress_t *photos_get_progress_slots(void) {
if (stub_progress_slots_null) return NULL;
return stub_progress_slots; 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) { int photos_get_progress_slot_count(void) {
return 3; return stub_progress_slot_count;
} }
void photos_reset_progress_slots(void) { void photos_reset_progress_slots(void) {
memset(stub_progress_slots, 0, sizeof(stub_progress_slots)); memset(stub_progress_slots, 0, sizeof(stub_progress_slots));
} }
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;
}
+1000 -115
View File
File diff suppressed because it is too large Load Diff
+3 -23
View File
@@ -1,10 +1,9 @@
//go:build !test
package main package main
import ( import (
"os" "os"
"os/signal"
"sync/atomic"
"syscall"
"gitea.k3s.k0.nu/tools/photocli/internal/photos" "gitea.k3s.k0.nu/tools/photocli/internal/photos"
) )
@@ -12,24 +11,5 @@ import (
var version = "dev" var version = "dev"
func main() { func main() {
sigCh := make(chan os.Signal, 1) os.Exit(runMain(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge))
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()))
} }
+5
View File
@@ -0,0 +1,5 @@
//go:build test
package main
var version = "dev"
+3037 -80
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -14,7 +14,6 @@ type progressBar struct {
width int width int
termH int termH int
start time.Time start time.Time
errors []string
workers int workers int
footerLines int footerLines int
scrollSet bool 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) { func (p *progressBar) logCompleted(line string) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@@ -168,13 +161,6 @@ func (p *progressBar) clear() {
p.scrollSet = false 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 { func renderWorkerLine(ws workerSlot, width int) string {
if width <= 0 { if width <= 0 {
width = 80 width = 80
@@ -300,9 +286,6 @@ func renderBar(pct, barWidth int) string {
if fullBlocks < barWidth { if fullBlocks < barWidth {
fracs := []string{"", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589"} fracs := []string{"", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589"}
idx := int(partial * 8) idx := int(partial * 8)
if idx > 7 {
idx = 7
}
if idx > 0 { if idx > 0 {
sb.WriteString(fracs[idx]) sb.WriteString(fracs[idx])
fullBlocks++ fullBlocks++
@@ -347,7 +330,6 @@ func truncateOrPad(s string, width int) string {
return string(runes[:i]) + "..." return string(runes[:i]) + "..."
} }
} }
return s
} }
return s + strings.Repeat(" ", width-rw) return s + strings.Repeat(" ", width-rw)
} }
+33
View File
@@ -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())
}
+20
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
//go:build test
package main
func defaultSignalChan() <-chan struct{} {
ch := make(chan struct{})
return ch
}
+15 -1
View File
@@ -1,3 +1,17 @@
module gitea.k3s.k0.nu/tools/photocli module gitea.k3s.k0.nu/tools/photocli
go 1.22 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
)
+51
View File
@@ -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=
+39
View File
@@ -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
}
}
+139
View File
@@ -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
}
+30
View File
@@ -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"
}
+211
View File
@@ -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")
}
}
+33
View File
@@ -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(),
}
}
File diff suppressed because it is too large Load Diff
+106
View File
@@ -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))
}
+134
View File
@@ -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
}
+402
View File
@@ -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)
}
}
+41
View File
@@ -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 }
+2 -2
View File
@@ -10,9 +10,9 @@ type Bridge interface {
ListAlbums() ([]Album, error) ListAlbums() ([]Album, error)
ListAssets(albumID string) ([]Asset, int, error) ListAssets(albumID string) ([]Asset, int, error)
ListTree() ([]CollectionNode, 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) 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) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error)
Cancel() Cancel()
IsCancelled() bool IsCancelled() bool
+12 -8
View File
@@ -62,29 +62,29 @@ func (*CgoBridge) IsCancelled() bool {
return C.photos_request_is_cancelled() != 0 return C.photos_request_is_cancelled() != 0
} }
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) {
return exportPreviewWithSlot(assetID, outputDir, targetSize, index, -1) return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, -1)
} }
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
return exportOriginalWithSlot(assetID, outputDir, index, -1) return exportOriginalWithSlot(assetID, outputDir, index, -1)
} }
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
return exportPreviewWithSlot(assetID, outputDir, targetSize, index, slotIndex) return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, slotIndex)
} }
func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) { func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) {
return exportOriginalWithSlot(assetID, outputDir, index, slotIndex) 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) cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid)) defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir) cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir)) 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 { if cs == nil {
return ExportResult{}, errBridgeNil 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{}))) ptr := (*C.export_progress_t)(unsafe.Pointer(uintptr(unsafe.Pointer(slots)) + uintptr(i)*unsafe.Sizeof(C.export_progress_t{})))
result[i] = ExportProgressSlot{ result[i] = ExportProgressSlot{
Active: ptr.active != 0, Active: ptr.active != 0,
Progress: float64(ptr.progress), Progress: float64(ptr.progress),
BytesDone: int64(ptr.bytes_done), BytesDone: int64(ptr.bytes_done),
BytesTotal: int64(ptr.bytes_total), BytesTotal: int64(ptr.bytes_total),
} }
} }
@@ -129,6 +129,10 @@ func ResetProgressSlots() {
C.photos_reset_progress_slots() C.photos_reset_progress_slots()
} }
func GetProgressSlotCount() int {
return int(C.photos_get_progress_slot_count())
}
type ExportProgressSlot struct { type ExportProgressSlot struct {
Active bool Active bool
Progress float64 Progress float64
+46 -15
View File
@@ -17,7 +17,11 @@ void photos_test_set_assets_null(void);
void photos_test_set_tree_null(void); void photos_test_set_tree_null(void);
void photos_request_cancel(void); void photos_request_cancel(void);
void photos_test_set_export_preview_json(const char *json); 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(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" import "C"
@@ -27,15 +31,19 @@ type CgoBridge struct{}
var DefaultBridge Bridge = &CgoBridge{} var DefaultBridge Bridge = &CgoBridge{}
func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) } 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 SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(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 SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
func SetTestAlbumsNull() { C.photos_test_set_albums_null() } func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
func SetTestAssetsNull() { C.photos_test_set_assets_null() } func SetTestAssetsNull() { C.photos_test_set_assets_null() }
func SetTestTreeNull() { C.photos_test_set_tree_null() } func SetTestTreeNull() { C.photos_test_set_tree_null() }
func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) } 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 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 { func (*CgoBridge) RequestAccess() error {
rc := C.photos_request_access() rc := C.photos_request_access()
@@ -82,28 +90,28 @@ func (*CgoBridge) IsCancelled() bool {
return C.photos_request_is_cancelled() != 0 return C.photos_request_is_cancelled() != 0
} }
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) {
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, -1) return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, -1)
} }
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
return exportOriginalWithSlotTest(assetID, outputDir, index, -1) return exportOriginalWithSlotTest(assetID, outputDir, index, -1)
} }
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) { func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, slotIndex) return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, slotIndex)
} }
func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) { func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) {
return exportOriginalWithSlotTest(assetID, outputDir, index, slotIndex) 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) cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid)) defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir) cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir)) 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 { if cs == nil {
return ExportResult{}, errBridgeNil return ExportResult{}, errBridgeNil
} }
@@ -132,8 +140,31 @@ type ExportProgressSlot struct {
} }
func GetProgressSlots() []ExportProgressSlot { 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() { func ResetProgressSlots() {
C.photos_reset_progress_slots()
}
func GetProgressSlotCount() int {
return int(C.photos_get_progress_slot_count())
} }
+1 -1
View File
@@ -5,4 +5,4 @@ package photos
import "fmt" import "fmt"
var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security") 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") var errBridgeNil = fmt.Errorf("bridge returned nil")
+266 -25
View File
@@ -35,9 +35,9 @@ func TestParseAlbumsJSON(t *testing.T) {
wantErr: true, wantErr: true,
}, },
{ {
name: "missing albums key", name: "missing albums key",
json: `{}`, json: `{}`,
want: []Album{}, want: []Album{},
}, },
} }
@@ -66,23 +66,23 @@ func TestParseAlbumsJSON(t *testing.T) {
func TestParseAssetsJSON(t *testing.T) { func TestParseAssetsJSON(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
json string json string
want []Asset want []Asset
wantTotal int wantTotal int
wantErr bool wantErr bool
errMsg string errMsg string
}{ }{
{ {
name: "empty assets", name: "empty assets",
json: `{"assets":[],"total":0}`, json: `{"assets":[],"total":0}`,
want: []Asset{}, want: []Asset{},
wantTotal: 0, wantTotal: 0,
}, },
{ {
name: "single asset", name: "single asset",
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`, json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}}, want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
wantTotal: 1, wantTotal: 1,
}, },
{ {
@@ -106,9 +106,9 @@ func TestParseAssetsJSON(t *testing.T) {
wantTotal: 1, wantTotal: 1,
}, },
{ {
name: "multiple assets", name: "multiple assets",
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`, 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"}}, want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
wantTotal: 3, wantTotal: 3,
}, },
{ {
@@ -132,9 +132,9 @@ func TestParseAssetsJSON(t *testing.T) {
wantErr: true, wantErr: true,
}, },
{ {
name: "empty error is not an error", name: "empty error is not an error",
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`, json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}}, want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
wantTotal: 1, wantTotal: 1,
}, },
} }
@@ -407,11 +407,11 @@ func TestErrBridgeNilMessage(t *testing.T) {
func TestParseExportResultJSON(t *testing.T) { func TestParseExportResultJSON(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
json string json string
want ExportResult want ExportResult
wantErr bool wantErr bool
errMsg string errMsg string
}{ }{
{ {
name: "success", name: "success",
@@ -488,3 +488,244 @@ func equalAsset(a, b Asset) bool {
} }
return true 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)
}
}
+11 -11
View File
@@ -6,17 +6,17 @@ type Album struct {
} }
type Asset struct { type Asset struct {
ID string `json:"id"` ID string `json:"id"`
Filename string `json:"filename"` Filename string `json:"filename"`
Cloud string `json:"cloud"` Cloud string `json:"cloud"`
MediaType string `json:"mediaType"` MediaType string `json:"mediaType"`
PixelWidth int `json:"pixelWidth"` PixelWidth int `json:"pixelWidth"`
PixelHeight int `json:"pixelHeight"` PixelHeight int `json:"pixelHeight"`
CreationDate *string `json:"creationDate,omitempty"` CreationDate *string `json:"creationDate,omitempty"`
Duration float64 `json:"duration,omitempty"` Duration float64 `json:"duration,omitempty"`
IsFavorite bool `json:"isFavorite,omitempty"` IsFavorite bool `json:"isFavorite,omitempty"`
HasAdjustments bool `json:"hasAdjustments,omitempty"` HasAdjustments bool `json:"hasAdjustments,omitempty"`
Resources []AssetResource `json:"resources,omitempty"` Resources []AssetResource `json:"resources,omitempty"`
} }
type AssetResource struct { type AssetResource struct {