v0.5.0: manifests, filters, logging, docs
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
BINARY := ./bin/photoscli
|
||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||
VERSION := 0.4.0
|
||||
VERSION := 0.5.0
|
||||
BRIDGE_DIR := bridge
|
||||
LDFLAGS := -X main.version=$(VERSION)
|
||||
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
|
||||
|
||||
@@ -1,43 +1,33 @@
|
||||
# photoscli
|
||||
|
||||
`photoscli` is a small macOS-only CLI written in Go that reads data from Apple Photos through a PhotoKit bridge.
|
||||
`photoscli` is a macOS-only command-line exporter for Apple Photos. It uses a small Objective-C PhotoKit bridge from Go to list albums, inspect assets, export optimized previews or originals, keep resumable manifests, and produce machine-readable logs for backup automation.
|
||||
|
||||
It supports five tasks:
|
||||
The tool is designed for repeatable, resumable Photos backups rather than one-off drag-and-drop exports.
|
||||
|
||||
- listing albums
|
||||
- listing asset IDs, filenames, and cloud status in an album
|
||||
- showing the folder and album tree
|
||||
- backing up all albums into the Photos folder tree
|
||||
- exporting resized JPEG previews or original files from an album
|
||||
## Highlights
|
||||
|
||||
## What The Code Does
|
||||
|
||||
The executable lives in `cmd/photoscli` and calls into `internal/photos`, which wraps an Objective-C bridge in `bridge/`.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `albums` prints one line per album as `<album-id>\t<title>`
|
||||
- `photos --album-id <id-or-title>` prints one asset per line as `<id>\t<filename>\t<cloud>`; accepts either a PhotoKit local identifier or an album title
|
||||
- `tree` prints the human-readable folder and album hierarchy from Apple Photos
|
||||
- `backup-all --out <dir> [--size <px>] [--originals]` exports every album into a matching folder tree, showing per-asset progress with filename, size, and cloud status
|
||||
- `export --album-id <id-or-title> --out <dir> [--size <px>] [--originals]` exports either JPEG previews or original files with a progress bar showing filename, size, and cloud status; `--album-id` accepts either a PhotoKit local identifier or an album title
|
||||
|
||||
The bridge uses PhotoKit to:
|
||||
|
||||
- request access to the user's Photos library
|
||||
- fetch album collections by local identifier or album title
|
||||
- fetch album assets sorted by `creationDate` ascending
|
||||
- render resized images with `PHImageManager`
|
||||
- write JPEG files with compression `0.85`
|
||||
- support graceful cancellation via a cancel flag checked between file exports
|
||||
- List albums, photos, and the Photos folder/album tree.
|
||||
- Export one album or back up the whole Photos tree.
|
||||
- Export JPEG previews with configurable size and quality, or original files.
|
||||
- Skip already-exported assets through a JSONL or SQLite manifest.
|
||||
- Resume interrupted runs safely.
|
||||
- Parallel export with live worker progress and ETA.
|
||||
- Optional structured logging to `export.log` or SQLite `logs` table.
|
||||
- Dry-run mode for planning large backups.
|
||||
- Filters for favorites, media type, date, estimated size, and excluded albums.
|
||||
- Failure tracking with `failures.jsonl` and `retry-failed`.
|
||||
- Verification, reporting, and diff commands for backup integrity.
|
||||
- Script-friendly exit codes and optional JSON summaries.
|
||||
- 100% test coverage for the Go CLI and parsing layers.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS
|
||||
- Go 1.22+
|
||||
- Xcode command-line tools
|
||||
- macOS with Apple Photos.
|
||||
- Go 1.25 or newer.
|
||||
- Xcode command-line tools.
|
||||
- Photos privacy permission for the built binary or terminal app.
|
||||
|
||||
The project builds with cgo and links against `Photos`, `Foundation`, `AppKit`, and `UniformTypeIdentifiers`.
|
||||
The production build uses cgo and links against Apple frameworks through the Objective-C bridge in `bridge/`.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -45,41 +35,79 @@ The project builds with cgo and links against `Photos`, `Foundation`, `AppKit`,
|
||||
make build
|
||||
```
|
||||
|
||||
Output binary:
|
||||
The binary is written to:
|
||||
|
||||
```bash
|
||||
./bin/photoscli
|
||||
```
|
||||
|
||||
## Test
|
||||
Run the full local release pipeline:
|
||||
|
||||
```bash
|
||||
make test
|
||||
make pipeline
|
||||
```
|
||||
|
||||
Tests run against a stub bridge, so they do not require real Photos access.
|
||||
The pipeline cleans artifacts, builds the test bridge, runs vet, runs race-enabled tests with coverage, builds the real PhotoKit bridge, builds the CLI, and verifies the embedded version.
|
||||
|
||||
## Usage
|
||||
## Quick Start
|
||||
|
||||
List albums:
|
||||
|
||||
```bash
|
||||
./bin/photoscli albums
|
||||
./bin/photoscli photos --album-id "<album-local-identifier>"
|
||||
./bin/photoscli photos --album-id "Vacation"
|
||||
./bin/photoscli tree
|
||||
./bin/photoscli backup-all --out ./backup
|
||||
./bin/photoscli backup-all --out ./backup --originals
|
||||
./bin/photoscli export --album-id "<album-local-identifier>" --out ./export
|
||||
./bin/photoscli export --album-id "Vacation" --out ./export
|
||||
./bin/photoscli export --album-id "<album-local-identifier>" --out ./export --size 2048
|
||||
./bin/photoscli export --album-id "<album-local-identifier>" --out ./export --originals
|
||||
```
|
||||
|
||||
### Commands
|
||||
Inspect an album by title or PhotoKit local identifier:
|
||||
|
||||
`albums`
|
||||
```bash
|
||||
./bin/photoscli photos --album-id "Vacation"
|
||||
```
|
||||
|
||||
- Requests Photos access
|
||||
- Lists albums as tab-separated album ID and title
|
||||
Export preview JPEGs from one album:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Vacation" --out ./export
|
||||
```
|
||||
|
||||
Export higher-quality previews:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Vacation" --out ./export --size 2048 --quality 92
|
||||
```
|
||||
|
||||
Export originals:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Vacation" --out ./originals --originals
|
||||
```
|
||||
|
||||
Back up the entire Photos album tree:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./photos-backup --manifest sqlite --log
|
||||
```
|
||||
|
||||
Preview what would happen before writing anything:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./photos-backup --dry-run --json
|
||||
```
|
||||
|
||||
Verify a backup later:
|
||||
|
||||
```bash
|
||||
./bin/photoscli verify --out ./photos-backup --manifest sqlite
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### `albums`
|
||||
|
||||
Lists user-created albums as tab-separated ID and title.
|
||||
|
||||
```bash
|
||||
photoscli albums
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
@@ -88,128 +116,285 @@ Example output:
|
||||
8A1B.../L0/001 Work
|
||||
```
|
||||
|
||||
`photos --album-id <id-or-title>`
|
||||
### `photos`
|
||||
|
||||
- Requests Photos access
|
||||
- If the value looks like a PhotoKit local identifier, uses it directly
|
||||
- Otherwise searches album titles for a match and resolves the identifier
|
||||
- Lists asset local identifiers and cloud status for the given album
|
||||
Lists assets in an album. `--album-id` accepts a PhotoKit local identifier or an exact album title.
|
||||
|
||||
```bash
|
||||
photoscli photos --album-id "Vacation"
|
||||
```
|
||||
|
||||
Output includes ID, filename, cloud state, media type, dimensions, optional creation date, optional duration, and a `*` marker for favorites.
|
||||
|
||||
### `tree`
|
||||
|
||||
Prints the Photos folder and album hierarchy.
|
||||
|
||||
```bash
|
||||
photoscli tree
|
||||
```
|
||||
|
||||
### `export`
|
||||
|
||||
Exports assets from one album.
|
||||
|
||||
```bash
|
||||
photoscli export --album-id <id-or-title> --out <dir> [flags]
|
||||
```
|
||||
|
||||
Useful examples:
|
||||
|
||||
```bash
|
||||
photoscli export --album-id "Family" --out ./family --size 2560 --quality 90
|
||||
photoscli export --album-id "Favorites" --out ./favorites --only-favorites
|
||||
photoscli export --album-id "Videos" --out ./videos --media videos --include-videos
|
||||
photoscli export --album-id "Archive" --out ./archive --originals --retry 3
|
||||
photoscli export --album-id "Vacation" --out ./vacation --date-template YYYY/MM/DD
|
||||
```
|
||||
|
||||
### `backup-all`
|
||||
|
||||
Walks the Photos folder/album tree and exports every album to a matching folder structure.
|
||||
|
||||
```bash
|
||||
photoscli backup-all --out <dir> [flags]
|
||||
```
|
||||
|
||||
Duplicate sibling album names are disambiguated by adding the album ID to the generated folder name.
|
||||
|
||||
Useful examples:
|
||||
|
||||
```bash
|
||||
photoscli backup-all --out ./backup --manifest sqlite --log
|
||||
photoscli backup-all --out ./backup --exclude-album "Recently Deleted" --exclude-album "Temp*"
|
||||
photoscli backup-all --out ./backup --since 2024-01-01 --sort newest
|
||||
photoscli backup-all --out ./backup --concurrency 8 --retry 3
|
||||
photoscli backup-all --out ./backup --dry-run --json
|
||||
```
|
||||
|
||||
### `report`
|
||||
|
||||
Shows manifest and failure counts for an output directory.
|
||||
|
||||
```bash
|
||||
photoscli report --out ./backup --manifest sqlite
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```text
|
||||
1F2A.../L0/001 IMG_0001.JPG local
|
||||
9C4D.../L0/001 IMG_0002.JPG cloud
|
||||
entries 12345
|
||||
failures 2
|
||||
```
|
||||
|
||||
`backup-all --out <dir> [--size <px>] [--originals]`
|
||||
### `diff`
|
||||
|
||||
- Requests Photos access
|
||||
- Walks the Photos folder and album hierarchy
|
||||
- Builds a complete index of all assets before exporting, skipping files already on disk
|
||||
- Creates directories as `out/folder/album/files`
|
||||
- Exports previews by default, originals when `--originals` is present
|
||||
- Shows a progress display with:
|
||||
- Scroll log of completed files (✅ copied, ☁ downloaded, ⏭ skipped, ❌ failed)
|
||||
- Worker status lines with live download progress bars for cloud files
|
||||
- Total and Album progress bars with color gradient (red → yellow → green)
|
||||
- Uses `--size` only for preview export
|
||||
Compares an album against the manifest and prints assets missing from the manifest.
|
||||
|
||||
Example layout:
|
||||
```bash
|
||||
photoscli diff --album-id "Vacation" --out ./backup --manifest jsonl
|
||||
```
|
||||
|
||||
Exit code is `2` when missing assets are found.
|
||||
|
||||
### `verify`
|
||||
|
||||
Checks that manifest entries have corresponding files on disk.
|
||||
|
||||
```bash
|
||||
photoscli verify --out ./backup --manifest sqlite
|
||||
```
|
||||
|
||||
Exit code is `2` when files are missing.
|
||||
|
||||
### `retry-failed`
|
||||
|
||||
Retries assets recorded in `failures.jsonl`.
|
||||
|
||||
```bash
|
||||
photoscli retry-failed --out ./backup
|
||||
```
|
||||
|
||||
This is useful after transient iCloud or network failures.
|
||||
|
||||
## Export Flags
|
||||
|
||||
Common flags for `export` and `backup-all`:
|
||||
|
||||
- `--out <dir>`: destination directory.
|
||||
- `--size <px>`: longest-side target for preview export, default `1024`.
|
||||
- `--quality <1-100>`: JPEG preview quality, default `85`.
|
||||
- `--originals`: export original files instead of previews.
|
||||
- `--concurrency <N>`: parallel workers, default `3`, capped by bridge slot count.
|
||||
- `--retry <N>`: retry failed exports with a small backoff.
|
||||
- `--dry-run`: print planned exports without writing files, manifests, or logs.
|
||||
- `--json`: print a machine-readable summary to stdout.
|
||||
- `--verify`: run manifest/file verification after export.
|
||||
- `--log`: enable structured export logging.
|
||||
- `--manifest jsonl|sqlite`: choose manifest backend, default `jsonl`.
|
||||
- `--no-manifest`: disable manifest reads/writes.
|
||||
- `--sort oldest|newest`: asset sort order where supported, default `oldest`.
|
||||
- `--since <date>`: only include assets on or after `YYYY-MM-DD` or RFC3339 date.
|
||||
- `--only-favorites`: only export favorite assets.
|
||||
- `--media photos|videos|all`: media filter, default `photos`.
|
||||
- `--include-videos`: compatibility flag that includes videos/audio.
|
||||
- `--min-size <n>`: filter by estimated pixel count.
|
||||
- `--max-size <n>`: filter by estimated pixel count.
|
||||
- `--format jpeg|heic|png`: preview format hint. Current bridge output is still the existing preview path; non-JPEG bridge output is future work.
|
||||
- `--date-template <template>`: append date folders based on creation date, for example `YYYY/MM/DD`.
|
||||
|
||||
`backup-all` also supports:
|
||||
|
||||
- `--exclude-album <pattern>`: repeatable exact or glob-style album-name exclusion.
|
||||
|
||||
`export` also requires:
|
||||
|
||||
- `--album-id <id-or-title>`: PhotoKit local identifier or exact album title.
|
||||
|
||||
## Manifests
|
||||
|
||||
Manifests prevent re-exporting assets on subsequent runs.
|
||||
|
||||
JSONL backend:
|
||||
|
||||
```text
|
||||
backup/
|
||||
Trips/
|
||||
Italy 2024/
|
||||
Venice/
|
||||
0000_....jpg
|
||||
Favorites/
|
||||
0000_....jpg
|
||||
downloads.jsonl
|
||||
```
|
||||
|
||||
`export --album-id <id-or-title> --out <dir> [--size <px>] [--originals]`
|
||||
|
||||
- Requests Photos access
|
||||
- Resolves `--album-id` by local identifier first, then by album title if not found
|
||||
- Creates the output directory if needed
|
||||
- Exports assets with a progress bar
|
||||
- Shows file size and cloud/local status for each exported asset
|
||||
- Exports resized JPEG previews by default
|
||||
- Exports original files when `--originals` is present
|
||||
- Writes a summary like `exported 10 photos to ./export` or `exported 10 original files to ./export` to stderr
|
||||
|
||||
`--size` is the target bounding box passed to PhotoKit for preview export. Default: `1024`.
|
||||
|
||||
`--originals` switches export mode to original-file export. In that mode, `--size` is ignored.
|
||||
|
||||
`--include-videos` includes video and audio assets in the export. By default, videos and audio are filtered out.
|
||||
|
||||
`tree`
|
||||
|
||||
- Requests Photos access
|
||||
- Prints folders and albums as an indented tree
|
||||
- Omits internal album IDs for human-readable output
|
||||
|
||||
Example output:
|
||||
SQLite backend:
|
||||
|
||||
```text
|
||||
Trips
|
||||
Italy 2024
|
||||
Venice
|
||||
Favorites
|
||||
downloads.db
|
||||
```
|
||||
|
||||
The manifest stores asset ID, filename, size, cloud state, and export timestamp. SQLite is useful for very large libraries and for keeping logs in the same database.
|
||||
|
||||
Use no manifest only when you intentionally want stateless export behavior:
|
||||
|
||||
```bash
|
||||
photoscli export --album-id "Scratch" --out ./scratch --no-manifest
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
Enable logs with:
|
||||
|
||||
```bash
|
||||
photoscli backup-all --out ./backup --log
|
||||
```
|
||||
|
||||
With JSONL/no-manifest mode, logs are written to:
|
||||
|
||||
```text
|
||||
export.log
|
||||
```
|
||||
|
||||
With SQLite manifest mode, logs are written to the `logs` table in `downloads.db`.
|
||||
|
||||
Logged events include session start/end, completed exports, skipped assets, and failures. Each event includes timestamp, level, event name, asset ID, album, filename, size, cloud state, duration, and message where available.
|
||||
|
||||
## Failure Tracking
|
||||
|
||||
Failed exports are appended to:
|
||||
|
||||
```text
|
||||
failures.jsonl
|
||||
```
|
||||
|
||||
Retry them with:
|
||||
|
||||
```bash
|
||||
photoscli retry-failed --out ./backup
|
||||
```
|
||||
|
||||
Use `--retry N` during normal exports to handle transient iCloud or network failures automatically.
|
||||
|
||||
## Configuration File
|
||||
|
||||
Defaults can be loaded from:
|
||||
|
||||
```text
|
||||
~/.photoscli.toml
|
||||
```
|
||||
|
||||
Or from a custom path:
|
||||
|
||||
```bash
|
||||
PHOTOSCLI_CONFIG=/path/to/photoscli.toml photoscli backup-all --out ./backup
|
||||
```
|
||||
|
||||
Supported keys mirror long flag names without the leading dashes:
|
||||
|
||||
```toml
|
||||
size = 2048
|
||||
quality = 90
|
||||
concurrency = 8
|
||||
manifest = "sqlite"
|
||||
log = true
|
||||
sort = "newest"
|
||||
media = "photos"
|
||||
retry = 3
|
||||
```
|
||||
|
||||
Command-line flags override config-file values.
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0`: success.
|
||||
- `1`: command/config/runtime error.
|
||||
- `2`: partial failure, such as some exports failed or diff/verify found missing items.
|
||||
- `3`: Photos access denied.
|
||||
|
||||
## Permissions
|
||||
|
||||
On first use, macOS may prompt for Photos access.
|
||||
On first use, macOS may prompt for Photos access. If access is denied, grant access in:
|
||||
|
||||
If access is denied, the CLI returns an error telling you to grant access in:
|
||||
```text
|
||||
System Settings > Privacy & Security > Photos
|
||||
```
|
||||
|
||||
`System Settings > Privacy & Security`
|
||||
Depending on how you launch the binary, macOS may associate the permission with Terminal, iTerm, your shell, or the built executable.
|
||||
|
||||
## Export Details
|
||||
## Development
|
||||
|
||||
Exported files currently:
|
||||
Run tests with the stub bridge:
|
||||
|
||||
- are JPEGs when exporting previews
|
||||
- keep their original filenames when exporting originals when possible
|
||||
- fall back to a sanitized asset identifier if an original filename is unavailable
|
||||
- prefix duplicate original filenames with the asset index to avoid collisions
|
||||
- name preview exports like `0000_<asset-local-identifier>.jpg`
|
||||
- replace `/` and `\` in asset IDs with `_` for generated preview filenames
|
||||
- replace `/` and `\` in folder and album names with `_` when creating backup directory names
|
||||
- preserve ordering based on ascending asset creation date
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
If some assets fail but at least one succeeds, the command still succeeds and reports the number exported.
|
||||
Run all package coverage:
|
||||
|
||||
If all exports fail, the command returns an error.
|
||||
```bash
|
||||
go test -tags=test -race -count=1 -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
```
|
||||
|
||||
## Signal Handling
|
||||
Run the release pipeline:
|
||||
|
||||
Sending `SIGINT` (Ctrl+C) or `SIGTERM` during export or backup triggers a graceful shutdown:
|
||||
```bash
|
||||
make pipeline
|
||||
```
|
||||
|
||||
1. The CLI prints `received signal, finishing current file...` to stderr
|
||||
2. The current file export is allowed to complete
|
||||
3. No further files are started
|
||||
4. The process exits after the in-progress file finishes
|
||||
Create a release after committing and tagging:
|
||||
|
||||
A second signal forces an immediate exit.
|
||||
```bash
|
||||
make release
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `cmd/photoscli`: CLI entrypoint, argument parsing, progress display, and album name resolution
|
||||
- `internal/photos`: Go bridge interface, JSON parsing, and error mapping
|
||||
- `bridge/`: Objective-C PhotoKit implementation plus a C test stub
|
||||
- `cmd/photoscli`: CLI, argument handling, filtering, manifests, progress, reporting, and command orchestration.
|
||||
- `internal/photos`: Go interface and JSON parsing for PhotoKit responses.
|
||||
- `internal/manifest`: JSONL/SQLite manifest backends and log writers.
|
||||
- `bridge/`: Objective-C PhotoKit implementation plus a C stub used by tests.
|
||||
|
||||
Data passed from Objective-C to Go is serialized as JSON and unmarshaled into Go structs.
|
||||
Objective-C returns JSON to Go. Tests use the stub bridge and do not require real Photos access.
|
||||
|
||||
## Known Limitations
|
||||
## Known Notes
|
||||
|
||||
- The tree view only shows user collections exposed through PhotoKit's top-level user collections API
|
||||
- Album title resolution matches the first album with that title; if multiple albums share a title, use the local identifier instead
|
||||
- `photos` only prints asset IDs and filenames, not dates or metadata
|
||||
- Preview export uses PhotoKit preview rendering, not original file export
|
||||
- Original export currently writes the first PhotoKit asset resource for each asset, which may not capture every related representation for complex assets
|
||||
- iCloud-backed assets may require network download during export
|
||||
- A second interrupt signal forces an immediate exit without waiting for the current file
|
||||
- Partial export failures are not listed individually
|
||||
- This project is macOS-only.
|
||||
- Preview export currently follows the existing bridge preview implementation; `--format heic|png` is validated as an output-format hint but still needs bridge support for true non-JPEG previews.
|
||||
- Album title lookup uses exact title matching. Use PhotoKit local identifiers when names are ambiguous.
|
||||
- iCloud-backed assets may trigger downloads and can fail due to network or account state.
|
||||
- `--min-size` and `--max-size` currently use estimated pixel count from dimensions, not encoded file size.
|
||||
|
||||
@@ -24,6 +24,7 @@ char *photos_export_preview_json(
|
||||
const char *asset_id,
|
||||
const char *output_dir,
|
||||
int target_size,
|
||||
int quality,
|
||||
int index,
|
||||
int slot_index
|
||||
);
|
||||
@@ -44,6 +45,7 @@ int photos_request_is_cancelled(void);
|
||||
void photos_free_string(char *value);
|
||||
|
||||
export_progress_t *photos_get_progress_slots(void);
|
||||
export_progress_t photos_get_progress_slot(export_progress_t *slots, int index);
|
||||
int photos_get_progress_slot_count(void);
|
||||
void photos_reset_progress_slots(void);
|
||||
|
||||
|
||||
+102
-68
@@ -7,7 +7,7 @@
|
||||
|
||||
static volatile int photos_cancelled = 0;
|
||||
|
||||
#define PROGRESS_SLOT_COUNT 3
|
||||
#define PROGRESS_SLOT_COUNT 16
|
||||
static export_progress_t progress_slots[PROGRESS_SLOT_COUNT];
|
||||
|
||||
static void reset_slot(int slot_index) {
|
||||
@@ -42,7 +42,7 @@ static NSDictionary *collection_to_dict(PHCollection *collection) {
|
||||
if ([collection isKindOfClass:[PHCollectionList class]]) {
|
||||
PHCollectionList *list = (PHCollectionList *)collection;
|
||||
PHFetchResult<PHCollection *> *children = [PHCollectionList fetchCollectionsInCollectionList:list
|
||||
options:nil];
|
||||
options:nil];
|
||||
NSMutableArray *childList = [NSMutableArray arrayWithCapacity:children.count];
|
||||
for (NSUInteger i = 0; i < children.count; i++) {
|
||||
NSDictionary *child = collection_to_dict(children[i]);
|
||||
@@ -107,7 +107,7 @@ static BOOL ensure_directory(NSString *outputDir) {
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
NSError *dirErr = nil;
|
||||
BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
|
||||
attributes:nil error:&dirErr];
|
||||
attributes:nil error:&dirErr];
|
||||
if (!ok && dirErr) {
|
||||
NSLog(@"ensure_directory failed: %@", dirErr);
|
||||
}
|
||||
@@ -151,7 +151,6 @@ static NSString *unique_path_for_filename(NSString *outputDir, NSString *filenam
|
||||
return [outputDir stringByAppendingPathComponent:prefixed];
|
||||
}
|
||||
|
||||
// Must stay in sync with sanitizePathComponent in cmd/photoscli/main.go
|
||||
static NSString *sanitized_path_component(NSString *name) {
|
||||
NSString *source = name ?: @"Untitled";
|
||||
NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy];
|
||||
@@ -170,6 +169,85 @@ static NSString *sanitized_path_component(NSString *name) {
|
||||
return safe;
|
||||
}
|
||||
|
||||
static BOOL resource_is_locally_available(PHAssetResource *res) {
|
||||
@try {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
SEL sel = @selector(isLocallyAvailable);
|
||||
if ([res respondsToSelector:sel]) {
|
||||
return ((BOOL (*)(id, SEL))objc_msgSend)(res, sel);
|
||||
}
|
||||
#pragma clang diagnostic pop
|
||||
} @catch (NSException *e) {}
|
||||
return NO;
|
||||
}
|
||||
|
||||
static NSArray<PHAssetResource *> *safe_asset_resources(PHAsset *asset) {
|
||||
@try {
|
||||
@autoreleasepool {
|
||||
NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset];
|
||||
return resources ?: @[];
|
||||
}
|
||||
} @catch (NSException *e) {
|
||||
NSLog(@"photoscli: exception getting resources for asset %@: %@", asset.localIdentifier, e.reason);
|
||||
return @[];
|
||||
}
|
||||
}
|
||||
|
||||
static NSString *asset_cloud_status_string_safe(PHAsset *asset) {
|
||||
@try {
|
||||
@autoreleasepool {
|
||||
if (@available(macOS 11, *)) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
SEL sel = @selector(isCloudShared);
|
||||
if ([asset respondsToSelector:sel]) {
|
||||
BOOL isShared = ((BOOL (*)(id, SEL))objc_msgSend)(asset, sel);
|
||||
if (isShared) return @"cloud";
|
||||
}
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
} @catch (NSException *exception) {
|
||||
NSLog(@"photoscli: exception checking isCloudShared for %@: %@", asset.localIdentifier, exception.reason);
|
||||
}
|
||||
|
||||
PHCloudIdentifier *cloudId = nil;
|
||||
@try {
|
||||
@autoreleasepool {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
cloudId = [asset performSelector:@selector(cloudIdentifier)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
} @catch (NSException *exception) {
|
||||
cloudId = nil;
|
||||
}
|
||||
if (cloudId && ![cloudId isEqual:[NSNull null]]) {
|
||||
return @"cloud";
|
||||
}
|
||||
|
||||
BOOL isInCloud = NO;
|
||||
@try {
|
||||
@autoreleasepool {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
SEL sel = @selector(isInCloud);
|
||||
if ([asset respondsToSelector:sel]) {
|
||||
isInCloud = ((BOOL (*)(id, SEL))objc_msgSend)(asset, sel);
|
||||
}
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
} @catch (NSException *exception) {
|
||||
isInCloud = NO;
|
||||
}
|
||||
if (isInCloud) {
|
||||
return @"cloud";
|
||||
}
|
||||
|
||||
return @"local";
|
||||
}
|
||||
|
||||
int photos_request_access(void) {
|
||||
__block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus];
|
||||
if (status == PHAuthorizationStatusNotDetermined) {
|
||||
@@ -247,42 +325,6 @@ static NSString *iso8601_string(NSDate *date) {
|
||||
|
||||
#import <objc/message.h>
|
||||
|
||||
static BOOL resource_is_locally_available(PHAssetResource *res) {
|
||||
@try {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
SEL sel = @selector(isLocallyAvailable);
|
||||
if ([res respondsToSelector:sel]) {
|
||||
return ((BOOL (*)(id, SEL))objc_msgSend)(res, sel);
|
||||
}
|
||||
#pragma clang diagnostic pop
|
||||
} @catch (NSException *e) {}
|
||||
return NO;
|
||||
}
|
||||
|
||||
static NSString *asset_cloud_status_string(PHAsset *asset) {
|
||||
@try {
|
||||
id cloudId = [asset performSelector:@selector(cloudIdentifier)];
|
||||
if (cloudId && ![cloudId isEqual:[NSNull null]]) {
|
||||
return @"cloud";
|
||||
}
|
||||
} @catch (NSException *exception) {
|
||||
}
|
||||
PHAssetResource *resource = nil;
|
||||
@try {
|
||||
resource = [PHAssetResource assetResourcesForAsset:asset].firstObject;
|
||||
} @catch (NSException *e) {
|
||||
resource = nil;
|
||||
}
|
||||
if (resource) {
|
||||
if (resource_is_locally_available(resource)) {
|
||||
return @"local";
|
||||
}
|
||||
return @"cloud";
|
||||
}
|
||||
return @"local";
|
||||
}
|
||||
|
||||
char *photos_list_assets_json(const char *album_id) {
|
||||
if (!album_id) return json_from_object(make_error_dict(@"album_id is required"));
|
||||
|
||||
@@ -305,16 +347,11 @@ char *photos_list_assets_json(const char *album_id) {
|
||||
PHAsset *asset = assets[i];
|
||||
NSString *lid = asset.localIdentifier;
|
||||
NSString *filename = nil;
|
||||
NSArray<PHAssetResource *> *resources = nil;
|
||||
@try {
|
||||
resources = [PHAssetResource assetResourcesForAsset:asset];
|
||||
} @catch (NSException *e) {
|
||||
resources = @[];
|
||||
}
|
||||
NSArray<PHAssetResource *> *resources = safe_asset_resources(asset);
|
||||
if (resources.count > 0) {
|
||||
filename = resources.firstObject.originalFilename;
|
||||
}
|
||||
NSString *cloudStatus = asset_cloud_status_string(asset);
|
||||
NSString *cloudStatus = asset_cloud_status_string_safe(asset);
|
||||
NSString *mediaTypeStr = media_type_string(asset.mediaType);
|
||||
|
||||
NSString *creationDateStr = iso8601_string(asset.creationDate);
|
||||
@@ -374,9 +411,10 @@ char *photos_list_tree_json(void) {
|
||||
|
||||
#define RETURN_PREVIEW(x) do { reset_slot(slot_index); return (x); } while(0)
|
||||
|
||||
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index, int slot_index) {
|
||||
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int quality, int index, int slot_index) {
|
||||
if (!asset_id || !output_dir) RETURN_PREVIEW(json_from_object(make_error_dict(@"asset_id and output_dir required")));
|
||||
if (target_size <= 0) target_size = 1024;
|
||||
if (quality <= 0 || quality > 100) quality = 85;
|
||||
|
||||
NSString *nsAssetId = [NSString stringWithUTF8String:asset_id];
|
||||
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
|
||||
@@ -427,17 +465,18 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
|
||||
return;
|
||||
}
|
||||
if (result) {
|
||||
imageData = nsimage_to_jpeg(result, 0.85);
|
||||
CGFloat compression = (CGFloat)quality / 100.0;
|
||||
imageData = nsimage_to_jpeg(result, compression);
|
||||
}
|
||||
dispatch_semaphore_signal(sem);
|
||||
}];
|
||||
|
||||
if (!semaphore_wait_with_timeout(sem, 120)) {
|
||||
RETURN_PREVIEW(json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)}));
|
||||
RETURN_PREVIEW(json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string_safe(asset)}));
|
||||
}
|
||||
|
||||
if (!imageData) {
|
||||
RETURN_PREVIEW(json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)}));
|
||||
RETURN_PREVIEW(json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string_safe(asset)}));
|
||||
}
|
||||
|
||||
NSString *safe = sanitized_asset_identifier(asset.localIdentifier);
|
||||
@@ -452,7 +491,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
|
||||
RETURN_PREVIEW(json_from_object(@{
|
||||
@"filename": filename,
|
||||
@"size": existingSize,
|
||||
@"cloud": asset_cloud_status_string(asset),
|
||||
@"cloud": asset_cloud_status_string_safe(asset),
|
||||
@"skipped": @YES
|
||||
}));
|
||||
}
|
||||
@@ -461,7 +500,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
|
||||
NSError *writeErr = nil;
|
||||
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
|
||||
NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error";
|
||||
RETURN_PREVIEW(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)}));
|
||||
RETURN_PREVIEW(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string_safe(asset)}));
|
||||
}
|
||||
|
||||
NSNumber *fileSize = nil;
|
||||
@@ -473,7 +512,7 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
|
||||
RETURN_PREVIEW(json_from_object(@{
|
||||
@"filename": filename,
|
||||
@"size": fileSize ?: @0,
|
||||
@"cloud": asset_cloud_status_string(asset)
|
||||
@"cloud": asset_cloud_status_string_safe(asset)
|
||||
}));
|
||||
}
|
||||
#undef RETURN_PREVIEW
|
||||
@@ -503,14 +542,9 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
progress_slots[slot_index].bytes_total = 0;
|
||||
}
|
||||
|
||||
NSArray<PHAssetResource *> *resources = nil;
|
||||
@try {
|
||||
resources = [PHAssetResource assetResourcesForAsset:asset];
|
||||
} @catch (NSException *e) {
|
||||
resources = @[];
|
||||
}
|
||||
NSArray<PHAssetResource *> *resources = safe_asset_resources(asset);
|
||||
if (resources.count == 0) {
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(asset)}));
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string_safe(asset)}));
|
||||
}
|
||||
|
||||
PHAssetResource *resource = resources.firstObject;
|
||||
@@ -531,7 +565,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
RETURN_ORIGINAL(json_from_object(@{
|
||||
@"filename": filename,
|
||||
@"size": existingSize,
|
||||
@"cloud": asset_cloud_status_string(asset),
|
||||
@"cloud": asset_cloud_status_string_safe(asset),
|
||||
@"skipped": @YES
|
||||
}));
|
||||
}
|
||||
@@ -563,7 +597,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
dispatch_semaphore_signal(sem);
|
||||
}];
|
||||
if (!semaphore_wait_with_timeout(sem, 120)) {
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)}));
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string_safe(asset)}));
|
||||
}
|
||||
|
||||
if (writeErr) {
|
||||
@@ -591,7 +625,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
}];
|
||||
|
||||
if (!semaphore_wait_with_timeout(sem2, 120)) {
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for image data", @"cloud": asset_cloud_status_string(asset)}));
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": @"timeout waiting for image data", @"cloud": asset_cloud_status_string_safe(asset)}));
|
||||
}
|
||||
|
||||
if (fallbackErr || !fallbackData) {
|
||||
@@ -599,7 +633,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
if (fallbackErr) {
|
||||
detail = [NSString stringWithFormat:@"%@; fallback: %@", detail, fallbackErr.localizedDescription];
|
||||
}
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", detail], @"cloud": asset_cloud_status_string(asset)}));
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", detail], @"cloud": asset_cloud_status_string_safe(asset)}));
|
||||
}
|
||||
|
||||
NSString *ext = nil;
|
||||
@@ -622,7 +656,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
|
||||
NSError *writeFallbackErr = nil;
|
||||
if (![fallbackData writeToFile:fallbackPath options:NSDataWritingAtomic error:&writeFallbackErr]) {
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeFallbackErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)}));
|
||||
RETURN_ORIGINAL(json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeFallbackErr.localizedDescription], @"cloud": asset_cloud_status_string_safe(asset)}));
|
||||
}
|
||||
|
||||
NSNumber *fileSize = nil;
|
||||
@@ -634,7 +668,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
RETURN_ORIGINAL(json_from_object(@{
|
||||
@"filename": fallbackFilename,
|
||||
@"size": fileSize ?: @0,
|
||||
@"cloud": asset_cloud_status_string(asset)
|
||||
@"cloud": asset_cloud_status_string_safe(asset)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -648,7 +682,7 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
|
||||
RETURN_ORIGINAL(json_from_object(@{
|
||||
@"filename": writtenFilename,
|
||||
@"size": fileSize ?: @0,
|
||||
@"cloud": asset_cloud_status_string(asset)
|
||||
@"cloud": asset_cloud_status_string_safe(asset)
|
||||
}));
|
||||
}
|
||||
#undef RETURN_ORIGINAL
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
#include <string.h>
|
||||
#include "../bridge/photokit_bridge.h"
|
||||
|
||||
static export_progress_t stub_progress_slots[3];
|
||||
static export_progress_t stub_progress_slots[16];
|
||||
static int stub_progress_slot_count = 16;
|
||||
static int stub_progress_slots_null = 0;
|
||||
|
||||
static char *alloc_json(const char *s) {
|
||||
size_t len = strlen(s);
|
||||
@@ -49,10 +51,11 @@ char *photos_list_assets_json(const char *album_id) {
|
||||
return alloc_json(stub_assets_json);
|
||||
}
|
||||
|
||||
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index, int slot_index) {
|
||||
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int quality, int index, int slot_index) {
|
||||
(void)asset_id;
|
||||
(void)output_dir;
|
||||
(void)target_size;
|
||||
(void)quality;
|
||||
(void)index;
|
||||
(void)slot_index;
|
||||
return maybe_alloc_json(stub_export_preview_json);
|
||||
@@ -87,18 +90,43 @@ void photos_test_set_export_preview_json(const char *json) {
|
||||
stub_export_preview_json = json;
|
||||
}
|
||||
|
||||
void photos_test_set_export_preview_json_null(void) {
|
||||
stub_export_preview_json = NULL;
|
||||
}
|
||||
|
||||
void photos_test_set_export_original_json(const char *json) {
|
||||
stub_export_original_json = json;
|
||||
}
|
||||
|
||||
void photos_test_set_export_original_json_null(void) {
|
||||
stub_export_original_json = NULL;
|
||||
}
|
||||
|
||||
export_progress_t *photos_get_progress_slots(void) {
|
||||
if (stub_progress_slots_null) return NULL;
|
||||
return stub_progress_slots;
|
||||
}
|
||||
|
||||
export_progress_t photos_get_progress_slot(export_progress_t *slots, int index) {
|
||||
export_progress_t result = {0, 0.0, 0, 0};
|
||||
if (index >= 0 && index < 16) {
|
||||
result = slots[index];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int photos_get_progress_slot_count(void) {
|
||||
return 3;
|
||||
return stub_progress_slot_count;
|
||||
}
|
||||
|
||||
void photos_reset_progress_slots(void) {
|
||||
memset(stub_progress_slots, 0, sizeof(stub_progress_slots));
|
||||
}
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
||||
//go:build !test
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
||||
)
|
||||
@@ -12,24 +11,5 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
done := make(chan struct{})
|
||||
var rc atomic.Int32
|
||||
|
||||
go func() {
|
||||
rc.Store(int32(run(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge)))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-sigCh:
|
||||
photos.DefaultBridge.Cancel()
|
||||
os.Stderr.Write([]byte("\nreceived signal, finishing current file...\n"))
|
||||
<-done
|
||||
}
|
||||
|
||||
os.Exit(int(rc.Load()))
|
||||
os.Exit(runMain(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build test
|
||||
|
||||
package main
|
||||
|
||||
var version = "dev"
|
||||
+3037
-80
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ type progressBar struct {
|
||||
width int
|
||||
termH int
|
||||
start time.Time
|
||||
errors []string
|
||||
workers int
|
||||
footerLines int
|
||||
scrollSet bool
|
||||
@@ -94,12 +93,6 @@ func (p *progressBar) updateWorkerProgress(i int, progress float64, bytesDone, b
|
||||
}
|
||||
}
|
||||
|
||||
func (p *progressBar) addError(filename string, err error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.errors = append(p.errors, fmt.Sprintf(" \u274c %s: %v", filename, err))
|
||||
}
|
||||
|
||||
func (p *progressBar) logCompleted(line string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
@@ -168,13 +161,6 @@ func (p *progressBar) clear() {
|
||||
p.scrollSet = false
|
||||
}
|
||||
|
||||
func (p *progressBar) flushErrors() {
|
||||
for _, e := range p.errors {
|
||||
fmt.Fprintln(p.w, e)
|
||||
}
|
||||
p.errors = nil
|
||||
}
|
||||
|
||||
func renderWorkerLine(ws workerSlot, width int) string {
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
@@ -300,9 +286,6 @@ func renderBar(pct, barWidth int) string {
|
||||
if fullBlocks < barWidth {
|
||||
fracs := []string{"", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589"}
|
||||
idx := int(partial * 8)
|
||||
if idx > 7 {
|
||||
idx = 7
|
||||
}
|
||||
if idx > 0 {
|
||||
sb.WriteString(fracs[idx])
|
||||
fullBlocks++
|
||||
@@ -347,7 +330,6 @@ func truncateOrPad(s string, width int) string {
|
||||
return string(runes[:i]) + "..."
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-rw)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build test
|
||||
|
||||
package main
|
||||
|
||||
func defaultSignalChan() <-chan struct{} {
|
||||
ch := make(chan struct{})
|
||||
return ch
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -10,9 +10,9 @@ type Bridge interface {
|
||||
ListAlbums() ([]Album, error)
|
||||
ListAssets(albumID string) ([]Asset, int, error)
|
||||
ListTree() ([]CollectionNode, error)
|
||||
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
|
||||
ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error)
|
||||
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
|
||||
ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error)
|
||||
ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error)
|
||||
ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error)
|
||||
Cancel()
|
||||
IsCancelled() bool
|
||||
|
||||
@@ -62,29 +62,29 @@ func (*CgoBridge) IsCancelled() bool {
|
||||
return C.photos_request_is_cancelled() != 0
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
|
||||
return exportPreviewWithSlot(assetID, outputDir, targetSize, index, -1)
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) {
|
||||
return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, -1)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
|
||||
return exportOriginalWithSlot(assetID, outputDir, index, -1)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
|
||||
return exportPreviewWithSlot(assetID, outputDir, targetSize, index, slotIndex)
|
||||
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
|
||||
return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, slotIndex)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) {
|
||||
return exportOriginalWithSlot(assetID, outputDir, index, slotIndex)
|
||||
}
|
||||
|
||||
func exportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
|
||||
func exportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
|
||||
cid := C.CString(assetID)
|
||||
defer C.free(unsafe.Pointer(cid))
|
||||
cdir := C.CString(outputDir)
|
||||
defer C.free(unsafe.Pointer(cdir))
|
||||
|
||||
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex))
|
||||
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(quality), C.int(index), C.int(slotIndex))
|
||||
if cs == nil {
|
||||
return ExportResult{}, errBridgeNil
|
||||
}
|
||||
@@ -117,8 +117,8 @@ func GetProgressSlots() []ExportProgressSlot {
|
||||
ptr := (*C.export_progress_t)(unsafe.Pointer(uintptr(unsafe.Pointer(slots)) + uintptr(i)*unsafe.Sizeof(C.export_progress_t{})))
|
||||
result[i] = ExportProgressSlot{
|
||||
Active: ptr.active != 0,
|
||||
Progress: float64(ptr.progress),
|
||||
BytesDone: int64(ptr.bytes_done),
|
||||
Progress: float64(ptr.progress),
|
||||
BytesDone: int64(ptr.bytes_done),
|
||||
BytesTotal: int64(ptr.bytes_total),
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,10 @@ func ResetProgressSlots() {
|
||||
C.photos_reset_progress_slots()
|
||||
}
|
||||
|
||||
func GetProgressSlotCount() int {
|
||||
return int(C.photos_get_progress_slot_count())
|
||||
}
|
||||
|
||||
type ExportProgressSlot struct {
|
||||
Active bool
|
||||
Progress float64
|
||||
|
||||
@@ -17,7 +17,11 @@ void photos_test_set_assets_null(void);
|
||||
void photos_test_set_tree_null(void);
|
||||
void photos_request_cancel(void);
|
||||
void photos_test_set_export_preview_json(const char *json);
|
||||
void photos_test_set_export_preview_json_null(void);
|
||||
void photos_test_set_export_original_json(const char *json);
|
||||
void photos_test_set_export_original_json_null(void);
|
||||
void photos_test_set_progress_slot_count(int count);
|
||||
void photos_test_set_progress_slots_null(int val);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
@@ -27,15 +31,19 @@ type CgoBridge struct{}
|
||||
|
||||
var DefaultBridge Bridge = &CgoBridge{}
|
||||
|
||||
func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) }
|
||||
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
|
||||
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
|
||||
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
|
||||
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
|
||||
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
|
||||
func SetTestTreeNull() { C.photos_test_set_tree_null() }
|
||||
func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) }
|
||||
func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) }
|
||||
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
|
||||
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
|
||||
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
|
||||
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
|
||||
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
|
||||
func SetTestTreeNull() { C.photos_test_set_tree_null() }
|
||||
func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) }
|
||||
func SetTestExportPreviewJSONNull() { C.photos_test_set_export_preview_json_null() }
|
||||
func SetTestExportOriginalJSON(json string) { C.photos_test_set_export_original_json(C.CString(json)) }
|
||||
func SetTestExportOriginalJSONNull() { C.photos_test_set_export_original_json_null() }
|
||||
func SetTestProgressSlotCount(count int) { C.photos_test_set_progress_slot_count(C.int(count)) }
|
||||
func SetTestProgressSlotsNull(val int) { C.photos_test_set_progress_slots_null(C.int(val)) }
|
||||
|
||||
func (*CgoBridge) RequestAccess() error {
|
||||
rc := C.photos_request_access()
|
||||
@@ -82,28 +90,28 @@ func (*CgoBridge) IsCancelled() bool {
|
||||
return C.photos_request_is_cancelled() != 0
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
|
||||
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, -1)
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) {
|
||||
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, -1)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
|
||||
return exportOriginalWithSlotTest(assetID, outputDir, index, -1)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
|
||||
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, slotIndex)
|
||||
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
|
||||
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, slotIndex)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) {
|
||||
return exportOriginalWithSlotTest(assetID, outputDir, index, slotIndex)
|
||||
}
|
||||
|
||||
func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
|
||||
func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
|
||||
cid := C.CString(assetID)
|
||||
defer C.free(unsafe.Pointer(cid))
|
||||
cdir := C.CString(outputDir)
|
||||
defer C.free(unsafe.Pointer(cdir))
|
||||
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex))
|
||||
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(quality), C.int(index), C.int(slotIndex))
|
||||
if cs == nil {
|
||||
return ExportResult{}, errBridgeNil
|
||||
}
|
||||
@@ -132,8 +140,31 @@ type ExportProgressSlot struct {
|
||||
}
|
||||
|
||||
func GetProgressSlots() []ExportProgressSlot {
|
||||
return nil
|
||||
count := int(C.photos_get_progress_slot_count())
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
cSlots := C.photos_get_progress_slots()
|
||||
if cSlots == nil {
|
||||
return nil
|
||||
}
|
||||
slots := make([]ExportProgressSlot, count)
|
||||
for i := 0; i < count; i++ {
|
||||
s := C.photos_get_progress_slot(cSlots, C.int(i))
|
||||
slots[i] = ExportProgressSlot{
|
||||
Active: s.active != 0,
|
||||
Progress: float64(s.progress),
|
||||
BytesDone: int64(s.bytes_done),
|
||||
BytesTotal: int64(s.bytes_total),
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
func ResetProgressSlots() {
|
||||
C.photos_reset_progress_slots()
|
||||
}
|
||||
|
||||
func GetProgressSlotCount() int {
|
||||
return int(C.photos_get_progress_slot_count())
|
||||
}
|
||||
|
||||
+266
-25
@@ -35,9 +35,9 @@ func TestParseAlbumsJSON(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing albums key",
|
||||
json: `{}`,
|
||||
want: []Album{},
|
||||
name: "missing albums key",
|
||||
json: `{}`,
|
||||
want: []Album{},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -66,23 +66,23 @@ func TestParseAlbumsJSON(t *testing.T) {
|
||||
|
||||
func TestParseAssetsJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want []Asset
|
||||
name string
|
||||
json string
|
||||
want []Asset
|
||||
wantTotal int
|
||||
wantErr bool
|
||||
errMsg string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "empty assets",
|
||||
json: `{"assets":[],"total":0}`,
|
||||
want: []Asset{},
|
||||
name: "empty assets",
|
||||
json: `{"assets":[],"total":0}`,
|
||||
want: []Asset{},
|
||||
wantTotal: 0,
|
||||
},
|
||||
{
|
||||
name: "single asset",
|
||||
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
|
||||
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
|
||||
name: "single asset",
|
||||
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
|
||||
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
|
||||
wantTotal: 1,
|
||||
},
|
||||
{
|
||||
@@ -106,9 +106,9 @@ func TestParseAssetsJSON(t *testing.T) {
|
||||
wantTotal: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple assets",
|
||||
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`,
|
||||
want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
|
||||
name: "multiple assets",
|
||||
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`,
|
||||
want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
|
||||
wantTotal: 3,
|
||||
},
|
||||
{
|
||||
@@ -132,9 +132,9 @@ func TestParseAssetsJSON(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty error is not an error",
|
||||
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
|
||||
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
|
||||
name: "empty error is not an error",
|
||||
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
|
||||
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
|
||||
wantTotal: 1,
|
||||
},
|
||||
}
|
||||
@@ -407,11 +407,11 @@ func TestErrBridgeNilMessage(t *testing.T) {
|
||||
|
||||
func TestParseExportResultJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want ExportResult
|
||||
wantErr bool
|
||||
errMsg string
|
||||
name string
|
||||
json string
|
||||
want ExportResult
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
@@ -488,3 +488,244 @@ func equalAsset(a, b Asset) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewViaStub(t *testing.T) {
|
||||
SetTestExportPreviewJSON(`{"filename":"0001_img.jpg","size":2048,"cloud":"local"}`)
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filename != "0001_img.jpg" {
|
||||
t.Errorf("got filename %q, want %q", result.Filename, "0001_img.jpg")
|
||||
}
|
||||
if result.Size != 2048 {
|
||||
t.Errorf("got size %d, want %d", result.Size, 2048)
|
||||
}
|
||||
if result.Cloud != "local" {
|
||||
t.Errorf("got cloud %q, want %q", result.Cloud, "local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewWithSlotViaStub(t *testing.T) {
|
||||
SetTestExportPreviewJSON(`{"filename":"slot_img.jpg","size":4096,"cloud":"cloud"}`)
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportPreviewWithSlot("asset-1", "/tmp", 2048, 85, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filename != "slot_img.jpg" {
|
||||
t.Errorf("got filename %q", result.Filename)
|
||||
}
|
||||
if result.Cloud != "cloud" {
|
||||
t.Errorf("got cloud %q, want %q", result.Cloud, "cloud")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalViaStub(t *testing.T) {
|
||||
SetTestExportOriginalJSON(`{"filename":"original.jpg","size":8192,"cloud":"local"}`)
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filename != "original.jpg" {
|
||||
t.Errorf("got filename %q, want %q", result.Filename, "original.jpg")
|
||||
}
|
||||
if result.Size != 8192 {
|
||||
t.Errorf("got size %d, want %d", result.Size, 8192)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalWithSlotViaStub(t *testing.T) {
|
||||
SetTestExportOriginalJSON(`{"filename":"slot_orig.heic","size":16384,"cloud":"local"}`)
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportOriginalWithSlot("asset-1", "/tmp", 0, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filename != "slot_orig.heic" {
|
||||
t.Errorf("got filename %q", result.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewErrorViaStub(t *testing.T) {
|
||||
SetTestExportPreviewJSON(`{"error":"disk full","cloud":"local"}`)
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err.Error() != "disk full" {
|
||||
t.Errorf("got error %q, want %q", err.Error(), "disk full")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalErrorViaStub(t *testing.T) {
|
||||
SetTestExportOriginalJSON(`{"error":"write failed","cloud":"local"}`)
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err.Error() != "write failed" {
|
||||
t.Errorf("got error %q, want %q", err.Error(), "write failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportSkippedResult(t *testing.T) {
|
||||
SetTestExportPreviewJSON(`{"filename":"skip.jpg","size":0,"cloud":"local","skipped":true}`)
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Skipped {
|
||||
t.Error("expected Skipped to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeCancelAndIsCancelled(t *testing.T) {
|
||||
bridge := &CgoBridge{}
|
||||
if bridge.IsCancelled() {
|
||||
t.Error("should not be cancelled initially")
|
||||
}
|
||||
bridge.Cancel()
|
||||
if !bridge.IsCancelled() {
|
||||
t.Error("should be cancelled after Cancel()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressSlotsReturnsSlots(t *testing.T) {
|
||||
slots := GetProgressSlots()
|
||||
if slots == nil {
|
||||
t.Errorf("GetProgressSlots should return slots in test build")
|
||||
}
|
||||
if len(slots) != 16 {
|
||||
t.Errorf("GetProgressSlots should return 16 slots, got %d", len(slots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetProgressSlotsNoPanic(t *testing.T) {
|
||||
ResetProgressSlots()
|
||||
}
|
||||
|
||||
func TestGetProgressSlotCount(t *testing.T) {
|
||||
count := GetProgressSlotCount()
|
||||
if count != 16 {
|
||||
t.Errorf("expected 16, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewNilStub(t *testing.T) {
|
||||
SetTestExportPreviewJSONNull()
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected errBridgeNil")
|
||||
}
|
||||
if err != errBridgeNil {
|
||||
t.Errorf("got %v, want %v", err, errBridgeNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalNilStub(t *testing.T) {
|
||||
SetTestExportOriginalJSONNull()
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected errBridgeNil")
|
||||
}
|
||||
if err != errBridgeNil {
|
||||
t.Errorf("got %v, want %v", err, errBridgeNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewWithSlotNilStub(t *testing.T) {
|
||||
SetTestExportPreviewJSONNull()
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportPreviewWithSlot("asset-1", "/tmp", 1024, 85, 0, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected errBridgeNil")
|
||||
}
|
||||
if err != errBridgeNil {
|
||||
t.Errorf("got %v, want %v", err, errBridgeNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalWithSlotNilStub(t *testing.T) {
|
||||
SetTestExportOriginalJSONNull()
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportOriginalWithSlot("asset-1", "/tmp", 0, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected errBridgeNil")
|
||||
}
|
||||
if err != errBridgeNil {
|
||||
t.Errorf("got %v, want %v", err, errBridgeNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressSlotsWithActiveSlot(t *testing.T) {
|
||||
ResetProgressSlots()
|
||||
slots := GetProgressSlots()
|
||||
if len(slots) != 16 {
|
||||
t.Errorf("expected 16 slots, got %d", len(slots))
|
||||
}
|
||||
for i, s := range slots {
|
||||
if s.Active {
|
||||
t.Errorf("slot %d should not be active after reset", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetProgressSlotsClearsState(t *testing.T) {
|
||||
ResetProgressSlots()
|
||||
slots := GetProgressSlots()
|
||||
if len(slots) > 0 && slots[0].Active {
|
||||
t.Errorf("slot should be inactive after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressSlotsZeroCount(t *testing.T) {
|
||||
SetTestProgressSlotCount(0)
|
||||
defer SetTestProgressSlotCount(3)
|
||||
slots := GetProgressSlots()
|
||||
if slots != nil {
|
||||
t.Errorf("expected nil with zero count, got %v", slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressSlotsNullPointer(t *testing.T) {
|
||||
SetTestProgressSlotsNull(1)
|
||||
defer SetTestProgressSlotsNull(0)
|
||||
slots := GetProgressSlots()
|
||||
if slots != nil {
|
||||
t.Errorf("expected nil with null pointer, got %v", slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportProgressSlotType(t *testing.T) {
|
||||
slot := ExportProgressSlot{
|
||||
Active: true,
|
||||
Progress: 0.75,
|
||||
BytesDone: 512,
|
||||
BytesTotal: 1024,
|
||||
}
|
||||
if !slot.Active {
|
||||
t.Error("expected Active to be true")
|
||||
}
|
||||
if slot.Progress != 0.75 {
|
||||
t.Errorf("expected Progress 0.75, got %f", slot.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -6,17 +6,17 @@ type Album struct {
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Cloud string `json:"cloud"`
|
||||
MediaType string `json:"mediaType"`
|
||||
PixelWidth int `json:"pixelWidth"`
|
||||
PixelHeight int `json:"pixelHeight"`
|
||||
CreationDate *string `json:"creationDate,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsFavorite bool `json:"isFavorite,omitempty"`
|
||||
HasAdjustments bool `json:"hasAdjustments,omitempty"`
|
||||
Resources []AssetResource `json:"resources,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Cloud string `json:"cloud"`
|
||||
MediaType string `json:"mediaType"`
|
||||
PixelWidth int `json:"pixelWidth"`
|
||||
PixelHeight int `json:"pixelHeight"`
|
||||
CreationDate *string `json:"creationDate,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsFavorite bool `json:"isFavorite,omitempty"`
|
||||
HasAdjustments bool `json:"hasAdjustments,omitempty"`
|
||||
Resources []AssetResource `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
type AssetResource struct {
|
||||
|
||||
Reference in New Issue
Block a user