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