Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf922fee03 | |||
| c9ac014473 | |||
| 9cd048d9f3 | |||
| 98320c8235 |
@@ -2,3 +2,4 @@ bin/
|
|||||||
bridge/*.o
|
bridge/*.o
|
||||||
bridge/*.a
|
bridge/*.a
|
||||||
coverage.out
|
coverage.out
|
||||||
|
ROADMAP.md
|
||||||
@@ -2,6 +2,39 @@
|
|||||||
|
|
||||||
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
|
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
|
||||||
|
|
||||||
|
## v0.10.0
|
||||||
|
|
||||||
|
Ports and adapters refactor.
|
||||||
|
|
||||||
|
- Extract shared manifest types into `internal/manifest/types` leaf package.
|
||||||
|
- Extract SQLite adapter into `internal/manifest/sqlite`: Store, Adapter, LogWriter.
|
||||||
|
- Extract JSONL adapter into `internal/manifest/jsonl`: Store, Adapter.
|
||||||
|
- `modernc.org/sqlite` import isolated to `internal/manifest/sqlite/adapter.go`.
|
||||||
|
- Registry pattern: `manifest.Default` provides adapter-backed `ParseFormat`/`Open`/`OpenLogWriter`.
|
||||||
|
- Adapter-agnostic `ConvertManifest` in `types/` for JSONL↔SQLite conversion.
|
||||||
|
- `MemoryAdapter` for in-memory manifest testing.
|
||||||
|
- CLI uses `manifest.Default` registry directly; zero concrete adapter references.
|
||||||
|
- SQLite `LogWriter` type assertion moved from central code into `SQLiteAdapter`.
|
||||||
|
- `Manifest` interface now includes `Entries()`; `EntryReader` removed.
|
||||||
|
- No behavior changes, no new features.
|
||||||
|
- 100% statement coverage across all 6 packages.
|
||||||
|
|
||||||
|
## v0.9.4
|
||||||
|
|
||||||
|
Doctor release.
|
||||||
|
|
||||||
|
- Add `doctor` to check Photos access and optional backup health.
|
||||||
|
- Add `doctor --out <dir>` to report backup directory, manifest entries, and failure count.
|
||||||
|
- Add `doctor --json` for scriptable diagnostics.
|
||||||
|
|
||||||
|
## v0.9.3
|
||||||
|
|
||||||
|
Cleanup release.
|
||||||
|
|
||||||
|
- Add `cleanup --out <dir>` to remove files not referenced by the manifest.
|
||||||
|
- Add `cleanup --dry-run` to preview orphaned files before deletion.
|
||||||
|
- Preserve manifest/log/failure files, `.photoscli`, and sidecars for manifest-referenced media.
|
||||||
|
|
||||||
## v0.9.2
|
## v0.9.2
|
||||||
|
|
||||||
Manifest repair release.
|
Manifest repair release.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
BINARY := ./bin/photoscli
|
BINARY := ./bin/photoscli
|
||||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||||
VERSION := 0.9.2
|
VERSION := 0.10.0
|
||||||
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
||||||
RELEASE_NOTES := RELEASE_NOTES.md
|
RELEASE_NOTES := RELEASE_NOTES.md
|
||||||
BRIDGE_DIR := bridge
|
BRIDGE_DIR := bridge
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ Verify a backup later:
|
|||||||
./bin/photoscli verify --out ./photos-backup --manifest sqlite
|
./bin/photoscli verify --out ./photos-backup --manifest sqlite
|
||||||
./bin/photoscli verify --out ./photos-backup --deep
|
./bin/photoscli verify --out ./photos-backup --deep
|
||||||
./bin/photoscli manifest repair --out ./photos-backup --checksum sha256 --dry-run
|
./bin/photoscli manifest repair --out ./photos-backup --checksum sha256 --dry-run
|
||||||
|
./bin/photoscli cleanup --out ./photos-backup --dry-run
|
||||||
|
./bin/photoscli doctor --out ./photos-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|||||||
+14
-7
@@ -1,18 +1,25 @@
|
|||||||
# v0.9.2
|
# v0.10.0
|
||||||
|
|
||||||
This release adds manifest repair for existing backups.
|
Ports and adapters refactor. No user-visible changes.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Add `manifest repair --out <dir>` to fill missing size metadata from existing files.
|
- Extract manifest types, registry, and conversion logic into `internal/manifest/types`.
|
||||||
- Add `manifest repair --checksum sha256` to fill missing checksums.
|
- Extract SQLite adapter into `internal/manifest/sqlite` with its own store, adapter, and log writer.
|
||||||
- Add `manifest repair --dry-run` to preview repairs without writing updates.
|
- Extract JSONL adapter into `internal/manifest/jsonl` with its own store and adapter.
|
||||||
- Repair works with JSONL and SQLite manifests.
|
- Isolate `modernc.org/sqlite` import to the SQLite adapter package only.
|
||||||
|
- Replace central `Open`/`ParseFormat`/`OpenLogWriter` with adapter-backed registry.
|
||||||
|
- Adapter-agnostic manifest conversion via `ConvertManifest`.
|
||||||
|
- SQLite log writer selection moved from central code into `SQLiteAdapter`.
|
||||||
|
- CLI uses `manifest.Default` registry; no direct references to concrete JSONL or SQLite types.
|
||||||
|
- `MemoryAdapter` available for in-memory manifest tests.
|
||||||
|
- 100% statement coverage across all 6 packages.
|
||||||
|
- No behavior changes.
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
||||||
- `photoscli-0.9.2-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
- `photoscli-0.10.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
||||||
- `USERGUIDE.md`: standalone user guide.
|
- `USERGUIDE.md`: standalone user guide.
|
||||||
|
|
||||||
Intel Macs are not currently a supported release target.
|
Intel Macs are not currently a supported release target.
|
||||||
|
|||||||
@@ -847,6 +847,25 @@ Repair missing manifest metadata from files already present on disk:
|
|||||||
|
|
||||||
Repair fills missing size/checksum metadata and skips missing, zero-byte, or unreadable files.
|
Repair fills missing size/checksum metadata and skips missing, zero-byte, or unreadable files.
|
||||||
|
|
||||||
|
Preview and remove files not referenced by the manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli cleanup --out ./PhotosBackup --dry-run
|
||||||
|
./bin/photoscli cleanup --out ./PhotosBackup
|
||||||
|
```
|
||||||
|
|
||||||
|
Cleanup preserves manifest/log/failure files, `.photoscli`, and sidecars next to manifest-referenced media.
|
||||||
|
|
||||||
|
Run read-only diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli doctor
|
||||||
|
./bin/photoscli doctor --out ./PhotosBackup
|
||||||
|
./bin/photoscli doctor --out ./PhotosBackup --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Doctor checks Photos access and, when `--out` is provided, backup directory status, manifest entries, and failure count.
|
||||||
|
|
||||||
## Safe Operating Practices
|
## Safe Operating Practices
|
||||||
|
|
||||||
- Run `--dry-run` before the first large backup.
|
- Run `--dry-run` before the first large backup.
|
||||||
|
|||||||
+155
-17
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -33,6 +34,7 @@ var (
|
|||||||
renameFunc = os.Rename
|
renameFunc = os.Rename
|
||||||
openFileFunc = os.OpenFile
|
openFileFunc = os.OpenFile
|
||||||
removeFunc = os.Remove
|
removeFunc = os.Remove
|
||||||
|
reg = manifest.Default
|
||||||
)
|
)
|
||||||
|
|
||||||
type exportOptions struct {
|
type exportOptions struct {
|
||||||
@@ -94,6 +96,10 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
|||||||
return cmdVerify(args[1:], stdout, stderr)
|
return cmdVerify(args[1:], stdout, stderr)
|
||||||
case "manifest":
|
case "manifest":
|
||||||
return cmdManifest(args[1:], stdout, stderr)
|
return cmdManifest(args[1:], stdout, stderr)
|
||||||
|
case "cleanup":
|
||||||
|
return cmdCleanup(args[1:], stdout, stderr)
|
||||||
|
case "doctor":
|
||||||
|
return cmdDoctor(args[1:], stdout, stderr, bridge)
|
||||||
case "retry-failed":
|
case "retry-failed":
|
||||||
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
|
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
|
||||||
case "failures":
|
case "failures":
|
||||||
@@ -140,6 +146,8 @@ USAGE
|
|||||||
photoscli diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite]
|
photoscli diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite]
|
||||||
photoscli verify --out <dir> [--manifest jsonl|sqlite]
|
photoscli verify --out <dir> [--manifest jsonl|sqlite]
|
||||||
photoscli manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
|
photoscli manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
|
||||||
|
photoscli cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
|
||||||
|
photoscli doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
|
||||||
photoscli retry-failed --out <dir>
|
photoscli retry-failed --out <dir>
|
||||||
photoscli retry-failed --out <dir> --clear-on-success
|
photoscli retry-failed --out <dir> --clear-on-success
|
||||||
photoscli failures list --out <dir>
|
photoscli failures list --out <dir>
|
||||||
@@ -188,6 +196,12 @@ COMMANDS
|
|||||||
manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
|
manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
|
||||||
Fill missing manifest size/checksum metadata from files that exist on disk.
|
Fill missing manifest size/checksum metadata from files that exist on disk.
|
||||||
|
|
||||||
|
cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
|
||||||
|
Remove files not referenced by the manifest. Use --dry-run first.
|
||||||
|
|
||||||
|
doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
|
||||||
|
Check Photos access and optional backup/manifest health without changing data.
|
||||||
|
|
||||||
retry-failed --out <dir>
|
retry-failed --out <dir>
|
||||||
Retry assets previously written to failures.jsonl.
|
Retry assets previously written to failures.jsonl.
|
||||||
|
|
||||||
@@ -487,7 +501,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
|
||||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
mf, mfErr := reg.ParseFormat(manifestFmt)
|
||||||
if mfErr != nil {
|
if mfErr != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", mfErr)
|
fmt.Fprintf(stderr, "error: %v\n", mfErr)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -590,7 +604,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
}
|
}
|
||||||
var exported, failed int
|
var exported, failed int
|
||||||
if opts.metadataOnly {
|
if opts.metadataOnly {
|
||||||
m, _ := manifest.Open(outDir, mf)
|
m, _ := reg.Open(outDir, mf)
|
||||||
defer m.Close()
|
defer m.Close()
|
||||||
entries := manifestEntries(m)
|
entries := manifestEntries(m)
|
||||||
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
|
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
|
||||||
@@ -660,7 +674,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
|
||||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
mf, mfErr := reg.ParseFormat(manifestFmt)
|
||||||
if mfErr != nil {
|
if mfErr != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", mfErr)
|
fmt.Fprintf(stderr, "error: %v\n", mfErr)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -734,7 +748,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
}
|
}
|
||||||
var totalAssets, failed int
|
var totalAssets, failed int
|
||||||
if opts.metadataOnly {
|
if opts.metadataOnly {
|
||||||
m, _ := manifest.Open(outDir, mf)
|
m, _ := reg.Open(outDir, mf)
|
||||||
entries := manifestEntries(m)
|
entries := manifestEntries(m)
|
||||||
m.Close()
|
m.Close()
|
||||||
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
|
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
|
||||||
@@ -1286,10 +1300,7 @@ func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals b
|
|||||||
}
|
}
|
||||||
|
|
||||||
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
|
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
|
||||||
if r, ok := m.(manifest.EntryReader); ok {
|
return m.Entries()
|
||||||
return r.Entries()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
|
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
|
||||||
@@ -1415,7 +1426,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
|
|||||||
var m manifest.Manifest
|
var m manifest.Manifest
|
||||||
if !noManifest {
|
if !noManifest {
|
||||||
var err error
|
var err error
|
||||||
m, err = manifest.Open(outDir, mf)
|
m, err = reg.Open(outDir, mf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err)
|
fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -1428,7 +1439,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
|
|||||||
var lw manifest.LogWriter = manifest.NoopLogWriter
|
var lw manifest.LogWriter = manifest.NoopLogWriter
|
||||||
if enableLog {
|
if enableLog {
|
||||||
var err error
|
var err error
|
||||||
lw, err = manifest.OpenLogWriter(m, outDir)
|
lw, err = reg.OpenLogWriter(m, outDir, mf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err)
|
fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err)
|
||||||
lw = manifest.NoopLogWriter
|
lw = manifest.NoopLogWriter
|
||||||
@@ -1704,7 +1715,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con
|
|||||||
var m manifest.Manifest
|
var m manifest.Manifest
|
||||||
if !noManifest {
|
if !noManifest {
|
||||||
var err error
|
var err error
|
||||||
m, err = manifest.Open(outDir, mf)
|
m, err = reg.Open(outDir, mf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err)
|
fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -1717,7 +1728,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con
|
|||||||
var lw manifest.LogWriter = manifest.NoopLogWriter
|
var lw manifest.LogWriter = manifest.NoopLogWriter
|
||||||
if enableLog {
|
if enableLog {
|
||||||
var err error
|
var err error
|
||||||
lw, err = manifest.OpenLogWriter(m, outDir)
|
lw, err = reg.OpenLogWriter(m, outDir, mf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err)
|
fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err)
|
||||||
lw = manifest.NoopLogWriter
|
lw = manifest.NoopLogWriter
|
||||||
@@ -2217,7 +2228,7 @@ func cmdReport(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stderr, "error: --out is required")
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -2246,7 +2257,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int
|
|||||||
fmt.Fprintln(stderr, "error: --album-id and --out are required")
|
fmt.Fprintln(stderr, "error: --album-id and --out are required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -2291,7 +2302,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintln(stderr, "error: --out is required")
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -2389,6 +2400,133 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool)
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||||
|
result := map[string]any{"photos_access": "ok"}
|
||||||
|
problems := 0
|
||||||
|
if rc := mustAuth(stderr, bridge); rc != exitOK {
|
||||||
|
result["photos_access"] = "denied"
|
||||||
|
problems++
|
||||||
|
}
|
||||||
|
outDir := flagVal(args, "--out")
|
||||||
|
if outDir != "" {
|
||||||
|
result["out"] = outDir
|
||||||
|
if info, err := statFunc(outDir); err != nil || !info.IsDir() {
|
||||||
|
result["backup_dir"] = "missing"
|
||||||
|
problems++
|
||||||
|
} else {
|
||||||
|
result["backup_dir"] = "ok"
|
||||||
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
entries, err := loadManifestEntries(outDir, mf)
|
||||||
|
if err != nil {
|
||||||
|
result["manifest"] = "error"
|
||||||
|
problems++
|
||||||
|
} else {
|
||||||
|
result["manifest"] = string(mf)
|
||||||
|
result["entries"] = len(entries)
|
||||||
|
}
|
||||||
|
failures := loadFailures(outDir)
|
||||||
|
result["failures"] = len(failures)
|
||||||
|
if len(failures) > 0 {
|
||||||
|
problems++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasFlag(args, "--json") {
|
||||||
|
result["problems"] = problems
|
||||||
|
if err := json.NewEncoder(stdout).Encode(result); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keys := make([]string, 0, len(result))
|
||||||
|
for k := range result {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(stdout, "%s\t%v\n", k, result[k])
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "problems\t%d\n", problems)
|
||||||
|
}
|
||||||
|
if problems > 0 {
|
||||||
|
return exitPartial
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdCleanup(args []string, stdout, stderr io.Writer) int {
|
||||||
|
outDir := flagVal(args, "--out")
|
||||||
|
if outDir == "" {
|
||||||
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
entries, err := loadManifestEntries(outDir, mf)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
keep := map[string]bool{
|
||||||
|
"downloads.jsonl": true,
|
||||||
|
"downloads.db": true,
|
||||||
|
"export.log": true,
|
||||||
|
"failures.jsonl": true,
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
checkPath := e.Path
|
||||||
|
if checkPath == "" {
|
||||||
|
checkPath = e.Filename
|
||||||
|
}
|
||||||
|
if checkPath == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(checkPath)
|
||||||
|
if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keep[clean] = true
|
||||||
|
keep[filepath.Clean(sidecarPath(clean))] = true
|
||||||
|
keep[filepath.Clean(jsonSidecarPath(clean))] = true
|
||||||
|
}
|
||||||
|
dryRun := hasFlag(args, "--dry-run")
|
||||||
|
removed := 0
|
||||||
|
skipped := 0
|
||||||
|
_ = filepath.WalkDir(outDir, func(path string, d fs.DirEntry, _ error) error {
|
||||||
|
if path == outDir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(outDir, path)
|
||||||
|
rel = filepath.Clean(rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
if rel == ".photoscli" || strings.HasPrefix(rel, ".photoscli"+string(os.PathSeparator)) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if keep[rel] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "%s\torphan\n", rel)
|
||||||
|
removed++
|
||||||
|
if !dryRun {
|
||||||
|
if err := removeFunc(path); err != nil {
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
fmt.Fprintf(stdout, "removed\t%d\nskipped\t%d\n", removed, skipped)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
func cmdManifest(args []string, stdout, stderr io.Writer) int {
|
func cmdManifest(args []string, stdout, stderr io.Writer) int {
|
||||||
if len(args) < 1 || args[0] != "repair" {
|
if len(args) < 1 || args[0] != "repair" {
|
||||||
fmt.Fprintln(stderr, "error: expected manifest repair")
|
fmt.Fprintln(stderr, "error: expected manifest repair")
|
||||||
@@ -2404,7 +2542,7 @@ func cmdManifest(args []string, stdout, stderr io.Writer) int {
|
|||||||
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", checksumMode)
|
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", checksumMode)
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
@@ -2615,7 +2753,7 @@ func cmdStatus(args []string, stdout, stderr io.Writer) int {
|
|||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
|
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
|
||||||
mf, err := manifest.ParseFormat(manifestFmt)
|
mf, err := reg.ParseFormat(manifestFmt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
return exitErr
|
||||||
|
|||||||
+141
-10
@@ -50,12 +50,13 @@ func (t testFileInfo) Sys() any { return nil }
|
|||||||
|
|
||||||
type noEntryManifest struct{}
|
type noEntryManifest struct{}
|
||||||
|
|
||||||
func (noEntryManifest) Has(string) bool { return false }
|
func (noEntryManifest) Has(string) bool { return false }
|
||||||
func (noEntryManifest) Add(string, string, int64, string) {}
|
func (noEntryManifest) Add(string, string, int64, string) {}
|
||||||
func (noEntryManifest) AddEntry(manifest.Entry) {}
|
func (noEntryManifest) AddEntry(manifest.Entry) {}
|
||||||
func (noEntryManifest) Save() error { return nil }
|
func (noEntryManifest) Save() error { return nil }
|
||||||
func (noEntryManifest) Close() {}
|
func (noEntryManifest) Close() {}
|
||||||
func (noEntryManifest) OpenAppend() error { return nil }
|
func (noEntryManifest) OpenAppend() error { return nil }
|
||||||
|
func (noEntryManifest) Entries() map[string]manifest.Entry { return nil }
|
||||||
|
|
||||||
func (m *mockBridge) RequestAccess() error { return m.accessErr }
|
func (m *mockBridge) RequestAccess() error { return m.accessErr }
|
||||||
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
|
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
|
||||||
@@ -4424,6 +4425,135 @@ func TestManifestRepairBranches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "orphan.jpg", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
|
||||||
|
full := filepath.Join(dir, path)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := manifest.LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(manifest.Entry{ID: "x1", Filename: "keep.jpg", Path: "keep.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "fallback", Filename: "fallback.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "empty", Exported: time.Now().Unix()})
|
||||||
|
m.AddEntry(manifest.Entry{ID: "badrel", Filename: "../bad.jpg", Path: "../bad.jpg", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "fallback.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, stderr, rc := runWith([]string{"cleanup", "--out", dir, "--dry-run"}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "orphan.jpg\torphan") || !strings.Contains(out, "removed\t1") {
|
||||||
|
t.Fatalf("cleanup dry rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); err != nil {
|
||||||
|
t.Fatalf("dry-run removed orphan: %v", err)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "removed\t1") {
|
||||||
|
t.Fatalf("cleanup rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("orphan still exists or bad error: %v", err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "fallback.jpg", "downloads.jsonl", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, path)); err != nil {
|
||||||
|
t.Fatalf("kept file missing %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, stderr, rc := runWith([]string{"cleanup"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "--out") {
|
||||||
|
t.Fatalf("cleanup missing out rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"cleanup", "--out", dir, "--manifest", "bad"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "manifest") {
|
||||||
|
t.Fatalf("cleanup bad manifest rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
badDBDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"cleanup", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "error:") {
|
||||||
|
t.Fatalf("cleanup load error rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"cleanup", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
|
||||||
|
if rc != exitOK || stderr != "" {
|
||||||
|
t.Fatalf("cleanup missing root rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
dir = t.TempDir()
|
||||||
|
oldRemove := removeFunc
|
||||||
|
removeFunc = func(string) error { return fmt.Errorf("remove") }
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "orphan.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, stderr, rc := runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
|
||||||
|
removeFunc = oldRemove
|
||||||
|
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t1") {
|
||||||
|
t.Fatalf("cleanup remove error rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctor(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := manifest.LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{})
|
||||||
|
if rc != exitOK || !strings.Contains(stderr, "access granted") || !strings.Contains(out, "backup_dir\tok") || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "problems\t0") {
|
||||||
|
t.Fatalf("doctor rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"doctor", "--out", dir, "--json"}, &mockBridge{})
|
||||||
|
if rc != exitOK || !strings.Contains(out, `"entries":1`) || !strings.Contains(out, `"problems":0`) {
|
||||||
|
t.Fatalf("doctor json rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDoctorProblems(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}}, fmt.Errorf("failed"))
|
||||||
|
out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{accessErr: fmt.Errorf("denied")})
|
||||||
|
if rc != exitPartial || !strings.Contains(stderr, "denied") || !strings.Contains(out, "photos_access\tdenied") || !strings.Contains(out, "failures\t1") {
|
||||||
|
t.Fatalf("doctor problems rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
_, stderr, rc = runWith([]string{"doctor", "--out", dir, "--manifest", "bad"}, &mockBridge{})
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "manifest") {
|
||||||
|
t.Fatalf("doctor bad manifest rc=%d stderr=%q", rc, stderr)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"doctor", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
|
||||||
|
if rc != exitPartial || !strings.Contains(out, "backup_dir\tmissing") {
|
||||||
|
t.Fatalf("doctor missing dir rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
badDBDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, stderr, rc = runWith([]string{"doctor", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
|
||||||
|
if rc != exitPartial || !strings.Contains(out, "manifest\terror") {
|
||||||
|
t.Fatalf("doctor manifest error rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
stderrBuf := &bytes.Buffer{}
|
||||||
|
if rc := cmdDoctor([]string{"--json"}, errWriter{}, stderrBuf, &mockBridge{}); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") {
|
||||||
|
t.Fatalf("doctor json writer error rc=%d stderr=%q", rc, stderrBuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVerifySidecarBranches(t *testing.T) {
|
func TestVerifySidecarBranches(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
subdir := filepath.Join(dir, "sub")
|
subdir := filepath.Join(dir, "sub")
|
||||||
@@ -5326,7 +5456,8 @@ func (m *mockManifest) Has(string) bool { return false }
|
|||||||
func (m *mockManifest) Add(id string, filename string, size int64, cloud string) {
|
func (m *mockManifest) Add(id string, filename string, size int64, cloud string) {
|
||||||
m.last = manifest.NewEntry(id, filename, filename, size, cloud)
|
m.last = manifest.NewEntry(id, filename, filename, size, cloud)
|
||||||
}
|
}
|
||||||
func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
|
func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
|
||||||
func (m *mockManifest) Save() error { return nil }
|
func (m *mockManifest) Save() error { return nil }
|
||||||
func (m *mockManifest) Close() {}
|
func (m *mockManifest) Close() {}
|
||||||
func (m *mockManifest) OpenAppend() error { return nil }
|
func (m *mockManifest) OpenAppend() error { return nil }
|
||||||
|
func (m *mockManifest) Entries() map[string]manifest.Entry { return nil }
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestNewEntryPath(t *testing.T) {
|
|
||||||
e := newEntry("id1", "file.jpg", 123, "local")
|
|
||||||
if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 {
|
|
||||||
t.Fatalf("unexpected entry: %+v", e)
|
|
||||||
}
|
|
||||||
e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "cloud")
|
|
||||||
if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" {
|
|
||||||
t.Fatalf("unexpected entry with path: %+v", e)
|
|
||||||
}
|
|
||||||
e = NewEntryWithChecksum("id3", "file3.jpg", "Album/file3.jpg", 789, "local", "sha256:abc")
|
|
||||||
if e.Checksum != "sha256:abc" || e.Path != "Album/file3.jpg" {
|
|
||||||
t.Fatalf("unexpected checksum entry: %+v", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddEntryDefaultsPath(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
jm := LoadJSONL(dir)
|
|
||||||
if err := jm.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
jm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"})
|
|
||||||
jm.Close()
|
|
||||||
loaded := LoadJSONL(dir).Entries()["x1"]
|
|
||||||
if got := loaded.Path; got != "file.jpg" {
|
|
||||||
t.Fatalf("jsonl path = %q", got)
|
|
||||||
}
|
|
||||||
if loaded.Checksum != "sha256:abc" {
|
|
||||||
t.Fatalf("jsonl checksum = %q", loaded.Checksum)
|
|
||||||
}
|
|
||||||
|
|
||||||
sdir := t.TempDir()
|
|
||||||
sm, err := LoadSQLite(sdir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := sm.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:def"})
|
|
||||||
if _, err := sm.db.Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
sloaded := sm.Entries()["x1"]
|
|
||||||
if got := sloaded.Path; got != "file.jpg" {
|
|
||||||
t.Fatalf("sqlite path = %q", got)
|
|
||||||
}
|
|
||||||
if sloaded.Checksum != "sha256:def" {
|
|
||||||
t.Fatalf("sqlite checksum = %q", sloaded.Checksum)
|
|
||||||
}
|
|
||||||
sm.Close()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package jsonl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Adapter struct{}
|
||||||
|
|
||||||
|
func (Adapter) Format() types.Format { return types.FormatJSONL }
|
||||||
|
func (Adapter) Aliases() []string { return []string{"json"} }
|
||||||
|
func (Adapter) Path(dir string) string { return Path(dir) }
|
||||||
|
func (Adapter) Exists(dir string) bool { return FileExists(Path(dir)) }
|
||||||
|
func (Adapter) Open(dir string) (types.Manifest, error) { return Load(dir), nil }
|
||||||
|
|
||||||
|
func (Adapter) OpenLogWriter(_ types.Manifest, dir string) (types.LogWriter, error) {
|
||||||
|
return types.NewFileLogWriter(types.LogPath(dir))
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package jsonl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdapter(t *testing.T) {
|
||||||
|
a := Adapter{}
|
||||||
|
if a.Format() != types.FormatJSONL {
|
||||||
|
t.Fatal("expected JSONL format")
|
||||||
|
}
|
||||||
|
if len(a.Aliases()) != 1 || a.Aliases()[0] != "json" {
|
||||||
|
t.Fatal("expected json alias")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if a.Path(dir) != Path(dir) {
|
||||||
|
t.Fatal("expected path match")
|
||||||
|
}
|
||||||
|
if a.Exists(dir) {
|
||||||
|
t.Fatal("expected not to exist in empty dir")
|
||||||
|
}
|
||||||
|
m, err := a.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
w, err := a.OpenLogWriter(nil, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadEmpty(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected non-nil store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadNonexistent(t *testing.T) {
|
||||||
|
m := Load("/nonexistent/path")
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected non-nil store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreAddAndHas(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if m.Has("x") {
|
||||||
|
t.Fatal("expected Has to return false")
|
||||||
|
}
|
||||||
|
m.Add("x", "photo.jpg", 42, "s3")
|
||||||
|
if !m.Has("x") {
|
||||||
|
t.Fatal("expected Has to return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSaveAndReload(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("id1", "file1.jpg", 10, "aws")
|
||||||
|
m.Add("id2", "file2.jpg", 20, "gcs")
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
|
||||||
|
m2 := Load(dir)
|
||||||
|
if !m2.Has("id1") {
|
||||||
|
t.Fatal("expected id1 after reload")
|
||||||
|
}
|
||||||
|
if !m2.Has("id2") {
|
||||||
|
t.Fatal("expected id2 after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendCreatesDirs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
subdir := dir + "/a/b"
|
||||||
|
m := Load(subdir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCloseIdempotent(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendIdempotent(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntries(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
m.Add("e1", "f1.jpg", 1, "c1")
|
||||||
|
m.Add("e2", "f2.jpg", 2, "c2")
|
||||||
|
entries := m.Entries()
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreManifestFormat(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if m.ManifestFormat() != types.FormatJSONL {
|
||||||
|
t.Fatal("expected JSONL format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendMkdirError(t *testing.T) {
|
||||||
|
m := Load("/proc/cannot-create-dir-here")
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSaveWithNoFile(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadFromExistingFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("abc", "a.jpg", 100, "gcs")
|
||||||
|
m.Close()
|
||||||
|
m2 := Load(dir)
|
||||||
|
if !m2.Has("abc") {
|
||||||
|
t.Fatal("expected abc after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileExists(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if FileExists(dir) {
|
||||||
|
t.Fatal("expected false for directory")
|
||||||
|
}
|
||||||
|
if FileExists("/nonexistent/file") {
|
||||||
|
t.Fatal("expected false for nonexistent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadWithPathFallback(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := Path(dir)
|
||||||
|
osWriteFile(path, []byte(`{"id":"abc","filename":"a.jpg","size":100,"cloud":"gcs","exported":1234}`+"\n"), 0644)
|
||||||
|
m := Load(dir)
|
||||||
|
if !m.Has("abc") {
|
||||||
|
t.Fatal("expected abc")
|
||||||
|
}
|
||||||
|
if e := m.Entries()["abc"]; e.Path != "a.jpg" {
|
||||||
|
t.Fatalf("expected path fallback to filename, got %q", e.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAppendAlreadyOpen(t *testing.T) {
|
||||||
|
m := Load(t.TempDir())
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAppendOpenFileError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.MkdirAll(Path(dir), 0755)
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Fatal("expected error when path is a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAppendMkdirError(t *testing.T) {
|
||||||
|
m := Load("/proc/cannot-create-dir-here")
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var osWriteFile = os.WriteFile
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package manifest
|
package jsonl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -6,32 +6,34 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type jsonlManifest struct {
|
type Store struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
entries map[string]Entry
|
entries map[string]types.Entry
|
||||||
path string
|
path string
|
||||||
file *os.File
|
file *os.File
|
||||||
syncFunc func() error
|
SyncFunc func() error
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsonlSaveHook func() error
|
var saveHook func() error
|
||||||
|
|
||||||
func SetJSONLSaveHook(fn func() error) func() error {
|
func SetSaveHook(fn func() error) func() error {
|
||||||
old := jsonlSaveHook
|
old := saveHook
|
||||||
jsonlSaveHook = fn
|
saveHook = fn
|
||||||
return old
|
return old
|
||||||
}
|
}
|
||||||
|
|
||||||
func JSONLPath(dir string) string {
|
func Path(dir string) string {
|
||||||
return filepath.Join(dir, "downloads.jsonl")
|
return filepath.Join(dir, "downloads.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadJSONL(dir string) *jsonlManifest {
|
func Load(dir string) *Store {
|
||||||
m := &jsonlManifest{
|
m := &Store{
|
||||||
entries: make(map[string]Entry),
|
entries: make(map[string]types.Entry),
|
||||||
path: JSONLPath(dir),
|
path: Path(dir),
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(m.path)
|
data, err := os.ReadFile(m.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,7 +58,7 @@ func LoadJSONL(dir string) *jsonlManifest {
|
|||||||
if raw.Path == "" {
|
if raw.Path == "" {
|
||||||
raw.Path = raw.Filename
|
raw.Path = raw.Filename
|
||||||
}
|
}
|
||||||
m.entries[raw.ID] = Entry{
|
m.entries[raw.ID] = types.Entry{
|
||||||
ID: raw.ID,
|
ID: raw.ID,
|
||||||
Filename: raw.Filename,
|
Filename: raw.Filename,
|
||||||
Path: raw.Path,
|
Path: raw.Path,
|
||||||
@@ -70,18 +72,22 @@ func LoadJSONL(dir string) *jsonlManifest {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Has(id string) bool {
|
func (m *Store) Has(id string) bool {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
_, ok := m.entries[id]
|
_, ok := m.entries[id]
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) {
|
func (m *Store) ManifestFormat() types.Format {
|
||||||
m.AddEntry(newEntry(id, filename, size, cloud))
|
return types.FormatJSONL
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) AddEntry(entry Entry) {
|
func (m *Store) Add(id string, filename string, size int64, cloud string) {
|
||||||
|
m.AddEntry(types.NewEntry(id, filename, filename, size, cloud))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Store) AddEntry(entry types.Entry) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if entry.Path == "" {
|
if entry.Path == "" {
|
||||||
@@ -103,14 +109,14 @@ func (m *jsonlManifest) AddEntry(entry Entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Save() error {
|
func (m *Store) Save() error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.syncFunc != nil {
|
if m.SyncFunc != nil {
|
||||||
return m.syncFunc()
|
return m.SyncFunc()
|
||||||
}
|
}
|
||||||
if jsonlSaveHook != nil {
|
if saveHook != nil {
|
||||||
return jsonlSaveHook()
|
return saveHook()
|
||||||
}
|
}
|
||||||
if m.file != nil {
|
if m.file != nil {
|
||||||
return m.file.Sync()
|
return m.file.Sync()
|
||||||
@@ -118,7 +124,7 @@ func (m *jsonlManifest) Save() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Close() {
|
func (m *Store) Close() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.file != nil {
|
if m.file != nil {
|
||||||
@@ -127,7 +133,7 @@ func (m *jsonlManifest) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) OpenAppend() error {
|
func (m *Store) OpenAppend() error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.file != nil {
|
if m.file != nil {
|
||||||
@@ -144,12 +150,20 @@ func (m *jsonlManifest) OpenAppend() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *jsonlManifest) Entries() map[string]Entry {
|
func (m *Store) Entries() map[string]types.Entry {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
out := make(map[string]Entry, len(m.entries))
|
out := make(map[string]types.Entry, len(m.entries))
|
||||||
for k, v := range m.entries {
|
for k, v := range m.entries {
|
||||||
out[k] = v
|
out[k] = v
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !info.IsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package jsonl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetSaveHook(t *testing.T) {
|
||||||
|
old := SetSaveHook(func() error { return fmt.Errorf("hook error") })
|
||||||
|
if old != nil {
|
||||||
|
t.Error("expected nil old hook")
|
||||||
|
}
|
||||||
|
restore := SetSaveHook(old)
|
||||||
|
if restore == nil {
|
||||||
|
t.Error("expected non-nil restore function")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveHookError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
old := SetSaveHook(func() error { return fmt.Errorf("hook error") })
|
||||||
|
defer SetSaveHook(old)
|
||||||
|
if err := m.Save(); err == nil {
|
||||||
|
t.Error("expected hook error from Save")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncFuncError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
m.SyncFunc = func() error { return fmt.Errorf("sync func error") }
|
||||||
|
if err := m.Save(); err == nil {
|
||||||
|
t.Error("expected syncFunc error from Save")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
m.file.Close()
|
||||||
|
if err := m.Save(); err == nil {
|
||||||
|
t.Error("expected Sync error on closed file")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEntryDefaultsPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := Load(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(types.Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"})
|
||||||
|
m.Close()
|
||||||
|
loaded := Load(dir).Entries()["x1"]
|
||||||
|
if got := loaded.Path; got != "file.jpg" {
|
||||||
|
t.Fatalf("jsonl path = %q", got)
|
||||||
|
}
|
||||||
|
if loaded.Checksum != "sha256:abc" {
|
||||||
|
t.Fatalf("jsonl checksum = %q", loaded.Checksum)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
type LogEntry struct {
|
|
||||||
Timestamp int64 `json:"ts"`
|
|
||||||
Level string `json:"level"`
|
|
||||||
Event string `json:"event"`
|
|
||||||
AssetID string `json:"asset_id,omitempty"`
|
|
||||||
Album string `json:"album,omitempty"`
|
|
||||||
Filename string `json:"filename,omitempty"`
|
|
||||||
Size int64 `json:"size,omitempty"`
|
|
||||||
Cloud string `json:"cloud,omitempty"`
|
|
||||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogWriter interface {
|
|
||||||
Log(entry LogEntry)
|
|
||||||
Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
type noopLogWriter struct{}
|
|
||||||
|
|
||||||
func (noopLogWriter) Log(LogEntry) { _ = struct{}{} }
|
|
||||||
func (noopLogWriter) Close() { _ = struct{}{} }
|
|
||||||
|
|
||||||
var NoopLogWriter LogWriter = noopLogWriter{}
|
|
||||||
|
|
||||||
func LogPath(dir string) string {
|
|
||||||
return dir + "/export.log"
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNoopLogWriter(t *testing.T) {
|
|
||||||
lw := NoopLogWriter
|
|
||||||
lw.Log(LogEntry{Event: "test"})
|
|
||||||
lw.Close()
|
|
||||||
noopLogWriter{}.Log(LogEntry{Event: "test"})
|
|
||||||
noopLogWriter{}.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSQLiteLogWriter(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(dir, "test.db")
|
|
||||||
db, err := sql.Open("sqlite", dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
lw, err := NewSQLiteLogWriter(db)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lw.Log(LogEntry{
|
|
||||||
Timestamp: 1700000000,
|
|
||||||
Level: "info",
|
|
||||||
Event: "export_done",
|
|
||||||
AssetID: "asset-1",
|
|
||||||
Album: "Favorites",
|
|
||||||
Filename: "photo.jpg",
|
|
||||||
Size: 1024,
|
|
||||||
Cloud: "local",
|
|
||||||
DurationMs: 500,
|
|
||||||
Message: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
lw.Log(LogEntry{
|
|
||||||
Timestamp: 1700000001,
|
|
||||||
Level: "error",
|
|
||||||
Event: "export_fail",
|
|
||||||
AssetID: "asset-2",
|
|
||||||
Filename: "bad.jpg",
|
|
||||||
Message: "timeout",
|
|
||||||
})
|
|
||||||
|
|
||||||
lw.Close()
|
|
||||||
|
|
||||||
var count int
|
|
||||||
err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if count != 2 {
|
|
||||||
t.Errorf("expected 2 log entries, got %d", count)
|
|
||||||
}
|
|
||||||
|
|
||||||
var event, level, assetID string
|
|
||||||
err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if event != "export_done" || level != "info" || assetID != "asset-1" {
|
|
||||||
t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteLogWriterNilDB(t *testing.T) {
|
|
||||||
w := &sqliteLogWriter{db: nil}
|
|
||||||
w.Log(LogEntry{Event: "test"})
|
|
||||||
w.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSQLiteLogWriterError(t *testing.T) {
|
|
||||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := db.Close(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := NewSQLiteLogWriter(db); err == nil {
|
|
||||||
t.Error("expected error for closed db")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteLogWriterCloseConcrete(t *testing.T) {
|
|
||||||
(&sqliteLogWriter{}).Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewFileLogWriter(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "export.log")
|
|
||||||
lw, err := NewFileLogWriter(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lw.Log(LogEntry{
|
|
||||||
Timestamp: 1700000000,
|
|
||||||
Level: "info",
|
|
||||||
Event: "export_done",
|
|
||||||
Filename: "photo.jpg",
|
|
||||||
Size: 2048,
|
|
||||||
})
|
|
||||||
|
|
||||||
lw.Close()
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(data) == 0 {
|
|
||||||
t.Error("expected log data")
|
|
||||||
}
|
|
||||||
if data[len(data)-1] != '\n' {
|
|
||||||
t.Error("expected trailing newline")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileLogWriterClosed(t *testing.T) {
|
|
||||||
w := &fileLogWriter{f: nil}
|
|
||||||
w.Log(LogEntry{Event: "test"})
|
|
||||||
w.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewFileLogWriterError(t *testing.T) {
|
|
||||||
_, err := NewFileLogWriter("/nonexistent/dir/export.log")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for bad path")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileLogWriterDoubleClose(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "export.log")
|
|
||||||
lw, err := NewFileLogWriter(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lw.Close()
|
|
||||||
lw.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLogPath(t *testing.T) {
|
|
||||||
p := LogPath("/tmp/out")
|
|
||||||
if p != "/tmp/out/export.log" {
|
|
||||||
t.Errorf("expected /tmp/out/export.log, got %s", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenLogWriterSQLite(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer m.Close()
|
|
||||||
|
|
||||||
lw, err := OpenLogWriter(m, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
|
||||||
lw.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenLogWriterJSONL(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer m.Close()
|
|
||||||
|
|
||||||
lw, err := OpenLogWriter(m, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
|
||||||
lw.Close()
|
|
||||||
|
|
||||||
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
|
|
||||||
t.Error("expected export.log to exist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpenLogWriterNilManifest(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
lw, err := OpenLogWriter(nil, dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
|
||||||
lw.Close()
|
|
||||||
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
|
|
||||||
t.Error("expected export.log to exist for nil manifest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,37 @@
|
|||||||
package manifest
|
package manifest
|
||||||
|
|
||||||
import "time"
|
import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
|
||||||
type Entry struct {
|
type Entry = types.Entry
|
||||||
ID string
|
|
||||||
Filename string
|
|
||||||
Path string
|
|
||||||
Size int64
|
|
||||||
Cloud string
|
|
||||||
Checksum string
|
|
||||||
Exported int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type Manifest interface {
|
type Manifest = types.Manifest
|
||||||
Has(id string) bool
|
|
||||||
Add(id string, filename string, size int64, cloud string)
|
|
||||||
AddEntry(entry Entry)
|
|
||||||
Save() error
|
|
||||||
Close()
|
|
||||||
OpenAppend() error
|
|
||||||
}
|
|
||||||
|
|
||||||
type EntryReader interface {
|
type EntryReader = types.EntryReader
|
||||||
Entries() map[string]Entry
|
|
||||||
}
|
|
||||||
|
|
||||||
func newEntry(id, filename string, size int64, cloud string) Entry {
|
type Format = types.Format
|
||||||
return Entry{
|
|
||||||
ID: id,
|
|
||||||
Filename: filename,
|
|
||||||
Path: filename,
|
|
||||||
Size: size,
|
|
||||||
Cloud: cloud,
|
|
||||||
Exported: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEntry(id, filename, path string, size int64, cloud string) Entry {
|
const (
|
||||||
e := newEntry(id, filename, size, cloud)
|
FormatJSONL = types.FormatJSONL
|
||||||
if path != "" {
|
FormatSQLite = types.FormatSQLite
|
||||||
e.Path = path
|
)
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry {
|
type LogEntry = types.LogEntry
|
||||||
e := NewEntry(id, filename, path, size, cloud)
|
|
||||||
e.Checksum = checksum
|
type LogWriter = types.LogWriter
|
||||||
return e
|
|
||||||
}
|
var NoopLogWriter = types.NoopLogWriter
|
||||||
|
|
||||||
|
type Adapter = types.Adapter
|
||||||
|
|
||||||
|
type Registry = types.Registry
|
||||||
|
|
||||||
|
type FormatReporter = types.FormatReporter
|
||||||
|
|
||||||
|
var (
|
||||||
|
NewRegistry = types.NewRegistry
|
||||||
|
NewEntry = types.NewEntry
|
||||||
|
NewEntryWithChecksum = types.NewEntryWithChecksum
|
||||||
|
LogPath = types.LogPath
|
||||||
|
ConvertManifest = types.ConvertManifest
|
||||||
|
NewFileLogWriter = types.NewFileLogWriter
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
|
||||||
|
type memoryStore struct {
|
||||||
|
entries map[string]types.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryStore() *memoryStore {
|
||||||
|
return &memoryStore{entries: make(map[string]types.Entry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Has(id string) bool {
|
||||||
|
_, ok := m.entries[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Add(id string, filename string, size int64, cloud string) {
|
||||||
|
m.AddEntry(types.NewEntry(id, filename, filename, size, cloud))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) AddEntry(entry types.Entry) {
|
||||||
|
if entry.Path == "" {
|
||||||
|
entry.Path = entry.Filename
|
||||||
|
}
|
||||||
|
m.entries[entry.ID] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Save() error { return nil }
|
||||||
|
func (m *memoryStore) Close() { _ = m }
|
||||||
|
|
||||||
|
func (m *memoryStore) OpenAppend() error { return nil }
|
||||||
|
|
||||||
|
func (m *memoryStore) Entries() map[string]types.Entry {
|
||||||
|
out := make(map[string]types.Entry, len(m.entries))
|
||||||
|
for k, v := range m.entries {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemoryAdapter struct{}
|
||||||
|
|
||||||
|
func (MemoryAdapter) Format() types.Format { return types.FormatJSONL }
|
||||||
|
func (MemoryAdapter) Aliases() []string { return nil }
|
||||||
|
func (MemoryAdapter) Path(string) string { return "" }
|
||||||
|
func (MemoryAdapter) Exists(string) bool { return false }
|
||||||
|
func (MemoryAdapter) Open(string) (types.Manifest, error) { return newMemoryStore(), nil }
|
||||||
|
|
||||||
|
func (MemoryAdapter) OpenLogWriter(types.Manifest, string) (types.LogWriter, error) {
|
||||||
|
return types.NoopLogWriter, nil
|
||||||
|
}
|
||||||
+17
-78
@@ -1,84 +1,21 @@
|
|||||||
package manifest
|
package manifest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Format string
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
|
||||||
const (
|
|
||||||
FormatJSONL Format = "jsonl"
|
|
||||||
FormatSQLite Format = "sqlite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Open(dir string, format Format) (Manifest, error) {
|
func Open(dir string, format Format) (Manifest, error) {
|
||||||
jsonlPath := JSONLPath(dir)
|
return Default.Open(dir, format)
|
||||||
sqlitePath := SQLitePath(dir)
|
|
||||||
jsonlExists := FileExists(jsonlPath)
|
|
||||||
sqliteExists := FileExists(sqlitePath)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case format == FormatJSONL && jsonlExists:
|
|
||||||
return LoadJSONL(dir), nil
|
|
||||||
case format == FormatSQLite && sqliteExists:
|
|
||||||
return LoadSQLite(dir)
|
|
||||||
case format == FormatJSONL && sqliteExists:
|
|
||||||
return ConvertFromSQLite(dir)
|
|
||||||
case format == FormatSQLite && jsonlExists:
|
|
||||||
return ConvertFromJSONL(dir)
|
|
||||||
default:
|
|
||||||
if format == FormatJSONL {
|
|
||||||
return LoadJSONL(dir), nil
|
|
||||||
}
|
|
||||||
return LoadSQLite(dir)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConvertFromJSONL(dir string) (Manifest, error) {
|
func ConvertFromJSONL(dir string) (Manifest, error) {
|
||||||
src := LoadJSONL(dir)
|
return types.ConvertManifest(dir, JSONLAdapter, SQLiteAdapter)
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open jsonl for read: %w", err)
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
dst, _ := LoadSQLite(dir)
|
|
||||||
if err := dst.OpenAppend(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open sqlite for write: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for id, e := range src.Entries() {
|
|
||||||
e.ID = id
|
|
||||||
dst.AddEntry(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Remove(JSONLPath(dir))
|
|
||||||
return dst, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConvertFromSQLite(dir string) (Manifest, error) {
|
func ConvertFromSQLite(dir string) (Manifest, error) {
|
||||||
src, _ := LoadSQLite(dir)
|
return types.ConvertManifest(dir, SQLiteAdapter, JSONLAdapter)
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open sqlite for read: %w", err)
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
dst := LoadJSONL(dir)
|
|
||||||
if err := dst.OpenAppend(); err != nil {
|
|
||||||
return nil, fmt.Errorf("open jsonl for write: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for id, e := range src.Entries() {
|
|
||||||
e.ID = id
|
|
||||||
dst.AddEntry(e)
|
|
||||||
}
|
|
||||||
if err := dst.Save(); err != nil {
|
|
||||||
return nil, fmt.Errorf("save jsonl: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Remove(SQLitePath(dir))
|
|
||||||
return dst, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func FileExists(path string) bool {
|
func FileExists(path string) bool {
|
||||||
@@ -90,19 +27,21 @@ func FileExists(path string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ParseFormat(s string) (Format, error) {
|
func ParseFormat(s string) (Format, error) {
|
||||||
switch strings.ToLower(s) {
|
return Default.ParseFormat(s)
|
||||||
case "jsonl", "json":
|
|
||||||
return FormatJSONL, nil
|
|
||||||
case "sqlite", "db", "sqlite3":
|
|
||||||
return FormatSQLite, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenLogWriter(m Manifest, dir string) (LogWriter, error) {
|
func OpenLogWriter(m Manifest, dir string) (LogWriter, error) {
|
||||||
if sm, ok := m.(*sqliteManifest); ok && sm.DB() != nil {
|
format := FormatJSONL
|
||||||
return NewSQLiteLogWriter(sm.DB())
|
if reporter, ok := m.(types.FormatReporter); ok {
|
||||||
|
format = reporter.ManifestFormat()
|
||||||
}
|
}
|
||||||
return NewFileLogWriter(LogPath(dir))
|
return Default.OpenLogWriter(m, dir, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func osRemove(path string) error {
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
types.SetRemoveFunc(osRemove)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
jsonladapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/jsonl"
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/sqlite"
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
JSONLAdapter = jsonladapter.Adapter{}
|
||||||
|
SQLiteAdapter = sqlite.Adapter{}
|
||||||
|
)
|
||||||
|
|
||||||
|
var Default = types.NewRegistry(JSONLAdapter, SQLiteAdapter)
|
||||||
|
|
||||||
|
func LoadJSONL(dir string) *jsonladapter.Store {
|
||||||
|
return jsonladapter.Load(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func JSONLPath(dir string) string {
|
||||||
|
return jsonladapter.Path(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetJSONLSaveHook(fn func() error) func() error {
|
||||||
|
return jsonladapter.SetSaveHook(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadSQLite(dir string) (Manifest, error) {
|
||||||
|
return sqlite.Load(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SQLitePath(dir string) string {
|
||||||
|
return sqlite.Path(dir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
jsonladapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/jsonl"
|
||||||
|
sqliteadapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/sqlite"
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegistryParseFormatAliases(t *testing.T) {
|
||||||
|
r := Default
|
||||||
|
cases := map[string]Format{
|
||||||
|
"jsonl": FormatJSONL,
|
||||||
|
"json": FormatJSONL,
|
||||||
|
"sqlite": FormatSQLite,
|
||||||
|
"db": FormatSQLite,
|
||||||
|
"sqlite3": FormatSQLite,
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
got, err := r.ParseFormat(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFormat(%q): %v", input, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ParseFormat(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryParseFormatUnknown(t *testing.T) {
|
||||||
|
if _, err := Default.ParseFormat("bad"); err == nil {
|
||||||
|
t.Fatal("expected unknown format error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenConvertsExistingManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 12, "local")
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
|
||||||
|
converted, err := Default.Open(dir, FormatSQLite)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer converted.Close()
|
||||||
|
if !converted.Has("x1") {
|
||||||
|
t.Fatal("expected converted sqlite manifest to contain entry")
|
||||||
|
}
|
||||||
|
if FileExists(JSONLPath(dir)) {
|
||||||
|
t.Fatal("expected source jsonl manifest to be removed")
|
||||||
|
}
|
||||||
|
if !FileExists(SQLitePath(dir)) {
|
||||||
|
t.Fatal("expected sqlite manifest to exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryAdapterStore(t *testing.T) {
|
||||||
|
s := newMemoryStore()
|
||||||
|
if s.Has("x") {
|
||||||
|
t.Fatal("expected empty")
|
||||||
|
}
|
||||||
|
s.Add("x", "photo.jpg", 42, "local")
|
||||||
|
if !s.Has("x") {
|
||||||
|
t.Fatal("expected has after add")
|
||||||
|
}
|
||||||
|
s.AddEntry(Entry{ID: "y", Filename: "file.jpg"})
|
||||||
|
if e := s.Entries()["y"]; e.Path != "file.jpg" {
|
||||||
|
t.Fatal("expected default path")
|
||||||
|
}
|
||||||
|
if err := s.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
if err := s.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := MemoryAdapter{}
|
||||||
|
if adapter.Format() != FormatJSONL {
|
||||||
|
t.Fatal("expected JSONL format")
|
||||||
|
}
|
||||||
|
if len(adapter.Aliases()) != 0 {
|
||||||
|
t.Fatal("expected no aliases")
|
||||||
|
}
|
||||||
|
if adapter.Path("") != "" {
|
||||||
|
t.Fatal("expected empty path")
|
||||||
|
}
|
||||||
|
if adapter.Exists("") {
|
||||||
|
t.Fatal("expected not to exist")
|
||||||
|
}
|
||||||
|
m, err := adapter.Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
w, err := adapter.OpenLogWriter(nil, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenLogWriterUsesAdapter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Default.Open(dir, FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
w, err := Default.OpenLogWriter(m, dir, FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
if !FileExists(LogPath(dir)) {
|
||||||
|
t.Fatal("expected jsonl adapter to create file log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenCreatesRequestedAdapter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Default.Open(dir, FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
if _, ok := m.(*jsonladapter.Store); !ok {
|
||||||
|
t.Fatalf("expected jsonl store, got %T", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryUnknownAdapterErrors(t *testing.T) {
|
||||||
|
r := NewRegistry(JSONLAdapter)
|
||||||
|
if _, err := r.Open(t.TempDir(), FormatSQLite); err == nil {
|
||||||
|
t.Fatal("expected open error for unregistered format")
|
||||||
|
}
|
||||||
|
if _, err := r.OpenLogWriter(LoadJSONL(t.TempDir()), t.TempDir(), FormatSQLite); err == nil {
|
||||||
|
t.Fatal("expected log writer error for unregistered format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAdapterOpenLogWriterUsesSQLite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := LoadSQLite(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
w, err := SQLiteAdapter.OpenLogWriter(m, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
if _, ok := w.(*sqliteadapter.LogWriter); !ok {
|
||||||
|
t.Fatalf("expected sqlite log writer, got %T", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenLogWriterUsesManifestFormat(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := LoadSQLite(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
w, err := OpenLogWriter(m, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
if _, ok := w.(*sqliteadapter.LogWriter); !ok {
|
||||||
|
t.Fatalf("expected sqlite log writer, got %T", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteAdapterOpenLogWriterFallsBackToFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
w, err := SQLiteAdapter.OpenLogWriter(LoadJSONL(dir), dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
if !FileExists(LogPath(dir)) {
|
||||||
|
t.Fatal("expected sqlite adapter fallback to create file log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetJSONLSaveHookWrapper(t *testing.T) {
|
||||||
|
old := SetJSONLSaveHook(func() error { return nil })
|
||||||
|
SetJSONLSaveHook(old)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestErrorBranches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if _, err := ConvertManifest(dir, failingAdapter{format: FormatJSONL, openErr: fmt.Errorf("boom")}, JSONLAdapter); err == nil {
|
||||||
|
t.Fatal("expected source open error")
|
||||||
|
}
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: badOpenManifest{}}, JSONLAdapter); err == nil {
|
||||||
|
t.Fatal("expected source OpenAppend error")
|
||||||
|
}
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: noReadManifest{}}, JSONLAdapter); err == nil {
|
||||||
|
t.Fatal("expected entry reader error")
|
||||||
|
}
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, failingAdapter{format: FormatSQLite, openErr: fmt.Errorf("boom")}); err == nil {
|
||||||
|
t.Fatal("expected destination open error")
|
||||||
|
}
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, staticAdapter{format: FormatSQLite, store: badOpenManifest{}}); err == nil {
|
||||||
|
t.Fatal("expected destination OpenAppend error")
|
||||||
|
}
|
||||||
|
oldRemove := types.RemoveFunc()
|
||||||
|
types.SetRemoveFunc(func(string) error { return fmt.Errorf("remove failed") })
|
||||||
|
defer func() { types.SetRemoveFunc(oldRemove) }()
|
||||||
|
if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, staticAdapter{format: FormatSQLite, store: readableManifest{}}); err == nil {
|
||||||
|
t.Fatal("expected remove error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type staticAdapter struct {
|
||||||
|
format Format
|
||||||
|
store Manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a staticAdapter) Format() Format { return a.format }
|
||||||
|
func (a staticAdapter) Aliases() []string { return nil }
|
||||||
|
func (a staticAdapter) Path(string) string { return "manifest.file" }
|
||||||
|
func (a staticAdapter) Exists(string) bool { return true }
|
||||||
|
func (a staticAdapter) Open(string) (Manifest, error) { return a.store, nil }
|
||||||
|
func (a staticAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { return NoopLogWriter, nil }
|
||||||
|
|
||||||
|
type failingAdapter struct {
|
||||||
|
format Format
|
||||||
|
openErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a failingAdapter) Format() Format { return a.format }
|
||||||
|
func (a failingAdapter) Aliases() []string { return nil }
|
||||||
|
func (a failingAdapter) Path(string) string { return "manifest.file" }
|
||||||
|
func (a failingAdapter) Exists(string) bool { return true }
|
||||||
|
func (a failingAdapter) Open(string) (Manifest, error) { return nil, a.openErr }
|
||||||
|
func (a failingAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { return nil, a.openErr }
|
||||||
|
|
||||||
|
type readableManifest struct{}
|
||||||
|
|
||||||
|
func (readableManifest) Has(string) bool { return false }
|
||||||
|
func (readableManifest) Add(string, string, int64, string) {}
|
||||||
|
func (readableManifest) AddEntry(Entry) {}
|
||||||
|
func (readableManifest) Save() error { return nil }
|
||||||
|
func (readableManifest) Close() {}
|
||||||
|
func (readableManifest) OpenAppend() error { return nil }
|
||||||
|
func (readableManifest) Entries() map[string]Entry { return map[string]Entry{"x": {Filename: "x.jpg"}} }
|
||||||
|
|
||||||
|
type noReadManifest struct{}
|
||||||
|
|
||||||
|
func (noReadManifest) Has(string) bool { return false }
|
||||||
|
func (noReadManifest) Add(string, string, int64, string) {}
|
||||||
|
func (noReadManifest) AddEntry(Entry) {}
|
||||||
|
func (noReadManifest) Save() error { return nil }
|
||||||
|
func (noReadManifest) Close() {}
|
||||||
|
func (noReadManifest) OpenAppend() error { return nil }
|
||||||
|
func (noReadManifest) Entries() map[string]Entry { return nil }
|
||||||
|
|
||||||
|
type badOpenManifest struct{ readableManifest }
|
||||||
|
|
||||||
|
func (badOpenManifest) OpenAppend() error { return fmt.Errorf("open failed") }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Adapter struct{}
|
||||||
|
|
||||||
|
func (Adapter) Format() types.Format { return types.FormatSQLite }
|
||||||
|
func (Adapter) Aliases() []string { return []string{"db", "sqlite3"} }
|
||||||
|
func (Adapter) Path(dir string) string { return Path(dir) }
|
||||||
|
func (Adapter) Exists(dir string) bool { return FileExists(Path(dir)) }
|
||||||
|
func (Adapter) Open(dir string) (types.Manifest, error) { return Load(dir) }
|
||||||
|
func (Adapter) OpenLogWriter(m types.Manifest, dir string) (types.LogWriter, error) {
|
||||||
|
if sm, ok := m.(*Store); ok && sm.DB() != nil {
|
||||||
|
return NewLogWriter(sm.DB())
|
||||||
|
}
|
||||||
|
return types.NewFileLogWriter(types.LogPath(dir))
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdapter(t *testing.T) {
|
||||||
|
a := Adapter{}
|
||||||
|
if a.Format() != types.FormatSQLite {
|
||||||
|
t.Fatal("expected SQLite format")
|
||||||
|
}
|
||||||
|
if len(a.Aliases()) != 2 {
|
||||||
|
t.Fatal("expected 2 aliases")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
if a.Path(dir) != Path(dir) {
|
||||||
|
t.Fatal("expected path match")
|
||||||
|
}
|
||||||
|
if a.Exists(dir) {
|
||||||
|
t.Fatal("expected not to exist in empty dir")
|
||||||
|
}
|
||||||
|
m, err := a.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
w, err := a.OpenLogWriter(nil, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapterOpenLogWriterSQLite(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
w, err := Adapter{}.OpenLogWriter(m, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
if _, ok := w.(*LogWriter); !ok {
|
||||||
|
t.Fatalf("expected sqlite log writer, got %T", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadEmpty(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected non-nil store")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreLoadNonexistent(t *testing.T) {
|
||||||
|
m, err := Load("/nonexistent/path")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected non-nil store")
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreAddHasSaveClose(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("sid1", "sfile.jpg", 99, "azure")
|
||||||
|
if !m.Has("sid1") {
|
||||||
|
t.Fatal("expected Has to return true")
|
||||||
|
}
|
||||||
|
if m.Has("nope") {
|
||||||
|
t.Fatal("expected Has to return false")
|
||||||
|
}
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("rid1", "rfile1.jpg", 10, "aws")
|
||||||
|
m.Add("rid2", "rfile2.jpg", 20, "gcs")
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
|
||||||
|
m2, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m2.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m2.Close()
|
||||||
|
if !m2.Has("rid1") {
|
||||||
|
t.Fatal("expected rid1 after reload")
|
||||||
|
}
|
||||||
|
if !m2.Has("rid2") {
|
||||||
|
t.Fatal("expected rid2 after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntries(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
m.Add("se1", "sf1.jpg", 1, "sc1")
|
||||||
|
m.Add("se2", "sf2.jpg", 2, "sc2")
|
||||||
|
entries := m.Entries()
|
||||||
|
if len(entries) != 2 {
|
||||||
|
t.Fatalf("expected 2 entries, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCloseIdempotent(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendIdempotent(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreManifestFormat(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.ManifestFormat() != types.FormatSQLite {
|
||||||
|
t.Fatal("expected SQLite format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSave(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendCreateDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
subdir := filepath.Join(dir, "subdir")
|
||||||
|
m, err := Load(subdir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCloseWithoutOpen(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntriesWithoutOpen(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entries := m.Entries()
|
||||||
|
if entries != nil {
|
||||||
|
t.Errorf("Entries without open should return nil, got %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHasWithoutOpen(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.Has("x") {
|
||||||
|
t.Fatal("Has should return false without open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreAddEntryWithoutOpen(t *testing.T) {
|
||||||
|
m, err := Load(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(types.Entry{ID: "x", Filename: "f.jpg"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetOpener(t *testing.T) {
|
||||||
|
old := SetOpener(func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
return nil, fmt.Errorf("test")
|
||||||
|
})
|
||||||
|
if old != nil {
|
||||||
|
t.Fatal("expected nil old opener")
|
||||||
|
}
|
||||||
|
restore := SetOpener(old)
|
||||||
|
if restore == nil {
|
||||||
|
t.Fatal("expected non-nil restore")
|
||||||
|
}
|
||||||
|
SetOpener(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileExists(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if FileExists(dir) {
|
||||||
|
t.Fatal("expected false for directory")
|
||||||
|
}
|
||||||
|
if FileExists("/nonexistent/file") {
|
||||||
|
t.Fatal("expected false for nonexistent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterClose(t *testing.T) {
|
||||||
|
w := &LogWriter{}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenerWithOverride(t *testing.T) {
|
||||||
|
SetOpener(func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
if opener() == nil {
|
||||||
|
t.Fatal("expected override")
|
||||||
|
}
|
||||||
|
SetOpener(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterCloseDirect(t *testing.T) {
|
||||||
|
(&LogWriter{}).Close()
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
package manifest
|
package sqlite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sqliteLogWriter struct {
|
type LogWriter struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) {
|
func NewLogWriter(db *sql.DB) (types.LogWriter, error) {
|
||||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (
|
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ts INTEGER NOT NULL,
|
ts INTEGER NOT NULL,
|
||||||
@@ -27,10 +29,10 @@ func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) {
|
|||||||
}
|
}
|
||||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`)
|
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`)
|
||||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`)
|
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`)
|
||||||
return &sqliteLogWriter{db: db}, nil
|
return &LogWriter{db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *sqliteLogWriter) Log(e LogEntry) {
|
func (w *LogWriter) Log(e types.LogEntry) {
|
||||||
if w.db == nil {
|
if w.db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -38,4 +40,4 @@ func (w *sqliteLogWriter) Log(e LogEntry) {
|
|||||||
e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message)
|
e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *sqliteLogWriter) Close() { _ = w }
|
func (w *LogWriter) Close() { _ = w }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package manifest
|
package sqlite
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -6,40 +6,46 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type sqliteManifest struct {
|
type OpenerFunc func(driverName, dataSourceName string) (*sql.DB, error)
|
||||||
|
|
||||||
|
var openerOverride OpenerFunc
|
||||||
|
|
||||||
|
func SetOpener(fn OpenerFunc) (old OpenerFunc) {
|
||||||
|
old = openerOverride
|
||||||
|
openerOverride = fn
|
||||||
|
return old
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
path string
|
path string
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
open sqlOpenerFunc
|
open OpenerFunc
|
||||||
execFunc func(query string, args ...any) (sql.Result, error)
|
execFunc func(query string, args ...any) (sql.Result, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type sqlOpenerFunc func(driverName, dataSourceName string) (*sql.DB, error)
|
func Path(dir string) string {
|
||||||
|
|
||||||
var sqliteOpenFunc sqlOpenerFunc
|
|
||||||
|
|
||||||
func defaultSQLOpener() sqlOpenerFunc {
|
|
||||||
if sqliteOpenFunc != nil {
|
|
||||||
return sqliteOpenFunc
|
|
||||||
}
|
|
||||||
return sql.Open
|
|
||||||
}
|
|
||||||
|
|
||||||
func SQLitePath(dir string) string {
|
|
||||||
return filepath.Join(dir, "downloads.db")
|
return filepath.Join(dir, "downloads.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadSQLite(dir string) (*sqliteManifest, error) {
|
func Load(dir string) (*Store, error) {
|
||||||
m := &sqliteManifest{
|
m := &Store{
|
||||||
path: SQLitePath(dir),
|
path: Path(dir),
|
||||||
open: defaultSQLOpener(),
|
open: opener(),
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) OpenAppend() error {
|
func opener() OpenerFunc {
|
||||||
|
if openerOverride != nil {
|
||||||
|
return openerOverride
|
||||||
|
}
|
||||||
|
return sql.Open
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Store) OpenAppend() error {
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -82,7 +88,13 @@ func (m *sqliteManifest) OpenAppend() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) Has(id string) bool {
|
func (m *Store) DB() *sql.DB { return m.db }
|
||||||
|
func (m *Store) SetOpen(fn OpenerFunc) { m.open = fn }
|
||||||
|
func (m *Store) SetExecFunc(fn func(query string, args ...any) (sql.Result, error)) {
|
||||||
|
m.execFunc = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Store) Has(id string) bool {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -94,11 +106,15 @@ func (m *sqliteManifest) Has(id string) bool {
|
|||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) {
|
func (m *Store) ManifestFormat() types.Format {
|
||||||
m.AddEntry(newEntry(id, filename, size, cloud))
|
return types.FormatSQLite
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) AddEntry(entry Entry) {
|
func (m *Store) Add(id string, filename string, size int64, cloud string) {
|
||||||
|
m.AddEntry(types.NewEntry(id, filename, filename, size, cloud))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Store) AddEntry(entry types.Entry) {
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -109,33 +125,27 @@ func (m *sqliteManifest) AddEntry(entry Entry) {
|
|||||||
entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Checksum, entry.Exported)
|
entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Checksum, entry.Exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) Save() error {
|
func (m *Store) Save() error { return nil }
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *sqliteManifest) Close() {
|
func (m *Store) Close() {
|
||||||
if m.db != nil {
|
if m.db != nil {
|
||||||
m.db.Close()
|
m.db.Close()
|
||||||
m.db = nil
|
m.db = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) DB() *sql.DB {
|
func (m *Store) Entries() map[string]types.Entry {
|
||||||
return m.db
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *sqliteManifest) Entries() map[string]Entry {
|
|
||||||
if m.db == nil {
|
if m.db == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make(map[string]Entry)
|
out := make(map[string]types.Entry)
|
||||||
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`)
|
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Entry
|
var e types.Entry
|
||||||
if err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Checksum, &e.Exported); err == nil {
|
if err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Checksum, &e.Exported); err == nil {
|
||||||
if e.Path == "" {
|
if e.Path == "" {
|
||||||
e.Path = e.Filename
|
e.Path = e.Filename
|
||||||
@@ -145,3 +155,11 @@ func (m *sqliteManifest) Entries() map[string]Entry {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !info.IsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreOpenAppendSQLError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.SetOpen(func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
return nil, fmt.Errorf("simulated open error")
|
||||||
|
})
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error from sql.Open failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendCreateTableError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realOpen := m.open
|
||||||
|
m.SetOpen(func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
db, err := realOpen(driverName, dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
return db, nil
|
||||||
|
})
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error from closed DB CREATE TABLE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHasAfterClose(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
m.Close()
|
||||||
|
if m.Has("x1") {
|
||||||
|
t.Error("Has should return false after Close")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntriesAfterClose(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
m.Close()
|
||||||
|
entries := m.Entries()
|
||||||
|
if entries != nil {
|
||||||
|
t.Errorf("Entries should return nil after Close, got %d entries", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendMkdirAllError(t *testing.T) {
|
||||||
|
m, err := Load("/proc/cannot-write")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error creating dir under /proc")
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendNilOpener(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.open = nil
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreOpenAppendCreateIndexError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realOpen := m.open
|
||||||
|
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
db, err := realOpen(driverName, dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.Close()
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error from closed DB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHasQueryError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
closedDB := m.DB()
|
||||||
|
closedDB.Close()
|
||||||
|
if m.Has("x1") {
|
||||||
|
t.Error("Has should return false with broken DB connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntriesQueryError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
closedDB := m.DB()
|
||||||
|
closedDB.Close()
|
||||||
|
entries := m.Entries()
|
||||||
|
if entries == nil {
|
||||||
|
t.Error("Entries should return non-nil map with broken DB connection")
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreHasQueryErrorWithOpenDB(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
closedDB := m.DB()
|
||||||
|
closedDB.Close()
|
||||||
|
if m.Has("x1") {
|
||||||
|
t.Error("Has should return false with closed DB")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreEntriesQueryErrorWithOpenDB(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Add("x1", "photo.jpg", 1024, "local")
|
||||||
|
closedDB := m.DB()
|
||||||
|
closedDB.Close()
|
||||||
|
entries := m.Entries()
|
||||||
|
if entries != nil && len(entries) != 0 {
|
||||||
|
t.Errorf("Entries should be empty with closed DB, got %d", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreCreateIndexError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
realOpen := m.open
|
||||||
|
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||||
|
db, err := realOpen(driverName, dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.SetExecFunc(func(query string, args ...any) (sql.Result, error) {
|
||||||
|
if strings.Contains(query, "CREATE INDEX") {
|
||||||
|
return nil, fmt.Errorf("injected CREATE INDEX error")
|
||||||
|
}
|
||||||
|
return db.Exec(query, args...)
|
||||||
|
})
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err == nil {
|
||||||
|
t.Error("expected error from CREATE INDEX")
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddEntryDefaultsPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m, err := Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(types.Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:def"})
|
||||||
|
if _, err := m.DB().Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sloaded := m.Entries()["x1"]
|
||||||
|
if got := sloaded.Path; got != "file.jpg" {
|
||||||
|
t.Fatalf("sqlite path = %q", got)
|
||||||
|
}
|
||||||
|
if sloaded.Checksum != "sha256:def" {
|
||||||
|
t.Fatalf("sqlite checksum = %q", sloaded.Checksum)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogWriter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "test.db")
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
lw, err := NewLogWriter(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lw.Log(types.LogEntry{
|
||||||
|
Timestamp: 1700000000,
|
||||||
|
Level: "info",
|
||||||
|
Event: "export_done",
|
||||||
|
AssetID: "asset-1",
|
||||||
|
Album: "Favorites",
|
||||||
|
Filename: "photo.jpg",
|
||||||
|
Size: 1024,
|
||||||
|
Cloud: "local",
|
||||||
|
DurationMs: 500,
|
||||||
|
})
|
||||||
|
lw.Log(types.LogEntry{
|
||||||
|
Timestamp: 1700000001,
|
||||||
|
Level: "error",
|
||||||
|
Event: "export_fail",
|
||||||
|
AssetID: "asset-2",
|
||||||
|
Filename: "bad.jpg",
|
||||||
|
Message: "timeout",
|
||||||
|
})
|
||||||
|
lw.Close()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("expected 2 log entries, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var event, level, assetID string
|
||||||
|
err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if event != "export_done" || level != "info" || assetID != "asset-1" {
|
||||||
|
t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterNilDB(t *testing.T) {
|
||||||
|
w := &LogWriter{db: nil}
|
||||||
|
w.Log(types.LogEntry{Event: "test"})
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogWriterError(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := NewLogWriter(db); err == nil {
|
||||||
|
t.Error("expected error for closed db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogWriterCloseConcrete(t *testing.T) {
|
||||||
|
(&LogWriter{}).Close()
|
||||||
|
}
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
package manifest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSetJSONLSaveHook(t *testing.T) {
|
|
||||||
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
|
|
||||||
if old != nil {
|
|
||||||
t.Error("expected nil old hook")
|
|
||||||
}
|
|
||||||
restore := SetJSONLSaveHook(old)
|
|
||||||
if restore == nil {
|
|
||||||
t.Error("expected non-nil restore function")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONLSaveHookError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
|
|
||||||
defer SetJSONLSaveHook(old)
|
|
||||||
if err := m.Save(); err == nil {
|
|
||||||
t.Error("expected hook error from Save")
|
|
||||||
}
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONLSyncFuncError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.syncFunc = func() error { return fmt.Errorf("sync func error") }
|
|
||||||
if err := m.Save(); err == nil {
|
|
||||||
t.Error("expected syncFunc error from Save")
|
|
||||||
}
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendSQLError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
return nil, fmt.Errorf("simulated open error")
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error from sql.Open failure")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendCreateTableError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
realOpen := m.open
|
|
||||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
db.Close()
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error from closed DB CREATE TABLE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteHasAfterClose(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.Close()
|
|
||||||
if m.Has("x1") {
|
|
||||||
t.Error("Has should return false after Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteEntriesAfterClose(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.Close()
|
|
||||||
entries := m.Entries()
|
|
||||||
if entries != nil {
|
|
||||||
t.Errorf("Entries should return nil after Close, got %d entries", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendMkdirAllError(t *testing.T) {
|
|
||||||
m, err := LoadSQLite("/proc/cannot-write")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error creating dir under /proc")
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendNilOpener(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.open = nil
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err)
|
|
||||||
}
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteOpenAppendCreateIndexError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
realOpen := m.open
|
|
||||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
db.Close()
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error from closed DB")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteHasQueryError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
closedDB := m.db
|
|
||||||
closedDB.Close()
|
|
||||||
result := m.Has("x1")
|
|
||||||
if result {
|
|
||||||
t.Error("Has should return false with broken DB connection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteEntriesQueryError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
closedDB := m.db
|
|
||||||
closedDB.Close()
|
|
||||||
entries := m.Entries()
|
|
||||||
if entries == nil {
|
|
||||||
t.Error("Entries should return non-nil map with broken DB connection")
|
|
||||||
}
|
|
||||||
if len(entries) != 0 {
|
|
||||||
t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteHasQueryErrorWithOpenDB(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
closedDB := m.db
|
|
||||||
closedDB.Close()
|
|
||||||
if m.Has("x1") {
|
|
||||||
t.Error("Has should return false with closed DB")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteEntriesQueryErrorWithOpenDB(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
closedDB := m.db
|
|
||||||
closedDB.Close()
|
|
||||||
entries := m.Entries()
|
|
||||||
if entries != nil && len(entries) != 0 {
|
|
||||||
t.Errorf("Entries should be empty with closed DB, got %d", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSQLiteCreateIndexError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
realOpen := m.open
|
|
||||||
callCount := 0
|
|
||||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
m.execFunc = func(query string, args ...any) (sql.Result, error) {
|
|
||||||
if strings.Contains(query, "CREATE INDEX") {
|
|
||||||
return nil, fmt.Errorf("injected CREATE INDEX error")
|
|
||||||
}
|
|
||||||
callCount++
|
|
||||||
return db.Exec(query, args...)
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
if err := m.OpenAppend(); err == nil {
|
|
||||||
t.Error("expected error from CREATE INDEX")
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromJSONLOpenAppendSQLError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.Close()
|
|
||||||
oldOpen := sqliteOpenFunc
|
|
||||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
return nil, fmt.Errorf("simulated sqlite open error")
|
|
||||||
}
|
|
||||||
defer func() { sqliteOpenFunc = oldOpen }()
|
|
||||||
_, err := ConvertFromJSONL(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromJSONLDstOpenAppendError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.Close()
|
|
||||||
realOpen := defaultSQLOpener()
|
|
||||||
oldOpen := sqliteOpenFunc
|
|
||||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
db.Close()
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
defer func() { sqliteOpenFunc = oldOpen }()
|
|
||||||
_, err := ConvertFromJSONL(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromSQLiteSrcOpenAppendError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
src, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
src.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
src.Close()
|
|
||||||
realOpen := defaultSQLOpener()
|
|
||||||
callCount := 0
|
|
||||||
oldOpen := sqliteOpenFunc
|
|
||||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
|
||||||
callCount++
|
|
||||||
db, err := realOpen(driverName, dataSourceName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if callCount > 0 {
|
|
||||||
db.Close()
|
|
||||||
}
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
defer func() { sqliteOpenFunc = oldOpen }()
|
|
||||||
_, err = ConvertFromSQLite(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from src.OpenAppend during ConvertFromSQLite")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromSQLiteDstOpenAppendError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
src, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
src.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
src.Close()
|
|
||||||
jsonlPath := JSONLPath(dir)
|
|
||||||
f, err := os.Create(jsonlPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
os.Chmod(jsonlPath, 0444)
|
|
||||||
defer os.Chmod(jsonlPath, 0644)
|
|
||||||
_, err = ConvertFromSQLite(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from dst.OpenAppend during ConvertFromSQLite")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJSONLSaveError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
m := LoadJSONL(dir)
|
|
||||||
if err := m.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
m.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
m.file.Close()
|
|
||||||
if err := m.Save(); err == nil {
|
|
||||||
t.Error("expected Sync error on closed file")
|
|
||||||
}
|
|
||||||
m.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFromSQLiteSaveError(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
src, err := LoadSQLite(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := src.OpenAppend(); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
src.Add("x1", "photo.jpg", 1024, "local")
|
|
||||||
src.Close()
|
|
||||||
oldHook := jsonlSaveHook
|
|
||||||
jsonlSaveHook = func() error { return fmt.Errorf("simulated sync error") }
|
|
||||||
defer func() { jsonlSaveHook = oldHook }()
|
|
||||||
_, err = ConvertFromSQLite(dir)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error from dst.Save during ConvertFromSQLite")
|
|
||||||
}
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "save jsonl") {
|
|
||||||
t.Errorf("expected save jsonl error, got: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package manifest
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Adapter interface {
|
||||||
|
Format() Format
|
||||||
|
Aliases() []string
|
||||||
|
Path(dir string) string
|
||||||
|
Exists(dir string) bool
|
||||||
|
Open(dir string) (Manifest, error)
|
||||||
|
OpenLogWriter(m Manifest, dir string) (LogWriter, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
adapters []Adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry(adapters ...Adapter) Registry {
|
||||||
|
return Registry{adapters: adapters}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) ParseFormat(s string) (Format, error) {
|
||||||
|
want := strings.ToLower(s)
|
||||||
|
for _, adapter := range r.adapters {
|
||||||
|
if want == string(adapter.Format()) {
|
||||||
|
return adapter.Format(), nil
|
||||||
|
}
|
||||||
|
for _, alias := range adapter.Aliases() {
|
||||||
|
if want == strings.ToLower(alias) {
|
||||||
|
return adapter.Format(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) Open(dir string, format Format) (Manifest, error) {
|
||||||
|
dst, err := r.adapter(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if dst.Exists(dir) {
|
||||||
|
return dst.Open(dir)
|
||||||
|
}
|
||||||
|
for _, src := range r.adapters {
|
||||||
|
if src.Format() != dst.Format() && src.Exists(dir) {
|
||||||
|
return ConvertManifest(dir, src, dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst.Open(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) OpenLogWriter(m Manifest, dir string, format Format) (LogWriter, error) {
|
||||||
|
adapter, err := r.adapter(format)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return adapter.OpenLogWriter(m, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) adapter(format Format) (Adapter, error) {
|
||||||
|
for _, adapter := range r.adapters {
|
||||||
|
if adapter.Format() == format {
|
||||||
|
return adapter, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", format)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertManifest(dir string, src Adapter, dst Adapter) (Manifest, error) {
|
||||||
|
source, err := src.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s for read: %w", src.Format(), err)
|
||||||
|
}
|
||||||
|
if err := source.OpenAppend(); err != nil {
|
||||||
|
source.Close()
|
||||||
|
return nil, fmt.Errorf("open %s for read: %w", src.Format(), err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
target, err := dst.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open %s for write: %w", dst.Format(), err)
|
||||||
|
}
|
||||||
|
if err := target.OpenAppend(); err != nil {
|
||||||
|
target.Close()
|
||||||
|
return nil, fmt.Errorf("open %s for write: %w", dst.Format(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id, entry := range source.Entries() {
|
||||||
|
entry.ID = id
|
||||||
|
target.AddEntry(entry)
|
||||||
|
}
|
||||||
|
if err := target.Save(); err != nil {
|
||||||
|
target.Close()
|
||||||
|
return nil, fmt.Errorf("save %s: %w", dst.Format(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := removeFile(src.Path(dir)); err != nil {
|
||||||
|
target.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeFile func(string) error
|
||||||
|
|
||||||
|
func SetRemoveFunc(fn func(string) error) {
|
||||||
|
removeFile = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveFunc() func(string) error {
|
||||||
|
return removeFile
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRegistry(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, err := r.ParseFormat("jsonl"); err == nil {
|
||||||
|
t.Fatal("expected error from empty registry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryParseFormatAliases(t *testing.T) {
|
||||||
|
r := NewRegistry(testAdapter{format: FormatJSONL, aliases: []string{"json"}}, testAdapter{format: FormatSQLite, aliases: []string{"db", "sqlite3"}})
|
||||||
|
cases := map[string]Format{
|
||||||
|
"jsonl": FormatJSONL,
|
||||||
|
"json": FormatJSONL,
|
||||||
|
"sqlite": FormatSQLite,
|
||||||
|
"db": FormatSQLite,
|
||||||
|
"sqlite3": FormatSQLite,
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
got, err := r.ParseFormat(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFormat(%q): %v", input, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ParseFormat(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryParseFormatUnknown(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, err := r.ParseFormat("bad"); err == nil {
|
||||||
|
t.Fatal("expected unknown format error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenDefaultsToOpen(t *testing.T) {
|
||||||
|
r := NewRegistry(testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false})
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenUnknownFormat(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, err := r.Open(t.TempDir(), FormatSQLite); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenNoConversionFallback(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
testAdapter{format: FormatSQLite, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
)
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenSameFormatAdapterInLoop(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
)
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenOtherFormatNotExists(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
testAdapter{format: FormatSQLite, store: &testStore{entries: map[string]Entry{}}, exists: false},
|
||||||
|
)
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenExistingManifest(t *testing.T) {
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: true},
|
||||||
|
)
|
||||||
|
m, err := r.Open(t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenConverts(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := &testStore{entries: map[string]Entry{"x1": {ID: "x1", Filename: "photo.jpg", Path: "photo.jpg"}}}
|
||||||
|
dst := &testStore{entries: map[string]Entry{}}
|
||||||
|
r := NewRegistry(
|
||||||
|
testAdapter{format: FormatJSONL, store: src, exists: true},
|
||||||
|
testAdapter{format: FormatSQLite, store: dst, exists: false},
|
||||||
|
)
|
||||||
|
SetRemoveFunc(func(string) error { return nil })
|
||||||
|
m, err := r.Open(dir, FormatSQLite)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
if _, ok := dst.entries["x1"]; !ok {
|
||||||
|
t.Fatal("expected converted entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenLogWriter(t *testing.T) {
|
||||||
|
r := NewRegistry(testAdapter{format: FormatJSONL, logWriter: NoopLogWriter})
|
||||||
|
w, err := r.OpenLogWriter(nil, t.TempDir(), FormatJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryOpenLogWriterUnknown(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
if _, err := r.OpenLogWriter(nil, t.TempDir(), FormatSQLite); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
src := &testStore{entries: map[string]Entry{"x1": {ID: "x1", Filename: "photo.jpg", Path: "photo.jpg"}}}
|
||||||
|
dst := &testStore{entries: map[string]Entry{}}
|
||||||
|
SetRemoveFunc(func(string) error { return nil })
|
||||||
|
m, err := ConvertManifest(dir, testAdapter{format: FormatJSONL, store: src, exists: true}, testAdapter{format: FormatSQLite, store: dst, exists: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Close()
|
||||||
|
if _, ok := dst.entries["x1"]; !ok {
|
||||||
|
t.Fatal("expected converted entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestSourceOpenError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, openErr: fmt.Errorf("boom")}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestSourceOpenAppendError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{openAppendErr: fmt.Errorf("boom")}}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestDstOpenError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, openErr: fmt.Errorf("boom")}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestDstOpenAppendError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{openAppendErr: fmt.Errorf("boom")}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestDstSaveError(t *testing.T) {
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{saveErr: fmt.Errorf("boom")}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertManifestRemoveError(t *testing.T) {
|
||||||
|
SetRemoveFunc(func(string) error { return fmt.Errorf("remove failed") })
|
||||||
|
defer SetRemoveFunc(nil)
|
||||||
|
if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRemoveFunc(t *testing.T) {
|
||||||
|
SetRemoveFunc(func(string) error { return nil })
|
||||||
|
if RemoveFunc() == nil {
|
||||||
|
t.Fatal("expected non-nil remove func")
|
||||||
|
}
|
||||||
|
SetRemoveFunc(nil)
|
||||||
|
if RemoveFunc() != nil {
|
||||||
|
t.Fatal("expected nil remove func")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoopLogWriterMethods(t *testing.T) {
|
||||||
|
NoopLogWriter.Log(LogEntry{})
|
||||||
|
NoopLogWriter.Close()
|
||||||
|
var n noopLogWriter
|
||||||
|
n.Log(LogEntry{})
|
||||||
|
n.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type testAdapter struct {
|
||||||
|
format Format
|
||||||
|
aliases []string
|
||||||
|
store Manifest
|
||||||
|
exists bool
|
||||||
|
openErr error
|
||||||
|
logWriter LogWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a testAdapter) Format() Format { return a.format }
|
||||||
|
func (a testAdapter) Aliases() []string { return a.aliases }
|
||||||
|
func (a testAdapter) Path(string) string { return "test" }
|
||||||
|
func (a testAdapter) Exists(string) bool { return a.exists }
|
||||||
|
func (a testAdapter) Open(string) (Manifest, error) {
|
||||||
|
if a.openErr != nil {
|
||||||
|
return nil, a.openErr
|
||||||
|
}
|
||||||
|
return a.store, nil
|
||||||
|
}
|
||||||
|
func (a testAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) {
|
||||||
|
if a.logWriter != nil {
|
||||||
|
return a.logWriter, nil
|
||||||
|
}
|
||||||
|
return NoopLogWriter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type testStore struct {
|
||||||
|
entries map[string]Entry
|
||||||
|
openAppendErr error
|
||||||
|
saveErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStore) Has(id string) bool {
|
||||||
|
_, ok := s.entries[id]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
func (s *testStore) Add(id string, filename string, size int64, cloud string) {
|
||||||
|
s.entries[id] = NewEntry(id, filename, filename, size, cloud)
|
||||||
|
}
|
||||||
|
func (s *testStore) AddEntry(e Entry) {
|
||||||
|
if e.Path == "" {
|
||||||
|
e.Path = e.Filename
|
||||||
|
}
|
||||||
|
s.entries[e.ID] = e
|
||||||
|
}
|
||||||
|
func (s *testStore) Save() error { return s.saveErr }
|
||||||
|
func (s *testStore) Close() {}
|
||||||
|
func (s *testStore) OpenAppend() error { return s.openAppendErr }
|
||||||
|
func (s *testStore) Entries() map[string]Entry {
|
||||||
|
out := make(map[string]Entry, len(s.entries))
|
||||||
|
for k, v := range s.entries {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
ID string
|
||||||
|
Filename string
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
Cloud string
|
||||||
|
Checksum string
|
||||||
|
Exported int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type Manifest interface {
|
||||||
|
Has(id string) bool
|
||||||
|
Add(id string, filename string, size int64, cloud string)
|
||||||
|
AddEntry(entry Entry)
|
||||||
|
Save() error
|
||||||
|
Close()
|
||||||
|
OpenAppend() error
|
||||||
|
Entries() map[string]Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntryReader = Manifest
|
||||||
|
|
||||||
|
type Format string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FormatJSONL Format = "jsonl"
|
||||||
|
FormatSQLite Format = "sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp int64 `json:"ts"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
AssetID string `json:"asset_id,omitempty"`
|
||||||
|
Album string `json:"album,omitempty"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Cloud string `json:"cloud,omitempty"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogWriter interface {
|
||||||
|
Log(entry LogEntry)
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type noopLogWriter struct{}
|
||||||
|
|
||||||
|
func (noopLogWriter) Log(LogEntry) { _ = struct{}{} }
|
||||||
|
func (noopLogWriter) Close() { _ = struct{}{} }
|
||||||
|
|
||||||
|
var NoopLogWriter LogWriter = noopLogWriter{}
|
||||||
|
|
||||||
|
type FormatReporter interface {
|
||||||
|
ManifestFormat() Format
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogPath(dir string) string {
|
||||||
|
return dir + "/export.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEntry(id, filename, path string, size int64, cloud string) Entry {
|
||||||
|
e := Entry{
|
||||||
|
ID: id,
|
||||||
|
Filename: filename,
|
||||||
|
Path: filename,
|
||||||
|
Size: size,
|
||||||
|
Cloud: cloud,
|
||||||
|
Exported: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if path != "" {
|
||||||
|
e.Path = path
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry {
|
||||||
|
e := NewEntry(id, filename, path, size, cloud)
|
||||||
|
e.Checksum = checksum
|
||||||
|
return e
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewEntryPath(t *testing.T) {
|
||||||
|
e := NewEntry("id1", "file.jpg", "", 123, "local")
|
||||||
|
if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 {
|
||||||
|
t.Fatalf("unexpected entry: %+v", e)
|
||||||
|
}
|
||||||
|
e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "cloud")
|
||||||
|
if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" {
|
||||||
|
t.Fatalf("unexpected entry with path: %+v", e)
|
||||||
|
}
|
||||||
|
e = NewEntryWithChecksum("id3", "file3.jpg", "Album/file3.jpg", 789, "local", "sha256:abc")
|
||||||
|
if e.Checksum != "sha256:abc" || e.Path != "Album/file3.jpg" {
|
||||||
|
t.Fatalf("unexpected checksum entry: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoopLogWriter(t *testing.T) {
|
||||||
|
NoopLogWriter.Log(LogEntry{Event: "test"})
|
||||||
|
NoopLogWriter.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFileLogWriter(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := LogPath(dir)
|
||||||
|
w, err := NewFileLogWriter(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Log(LogEntry{Timestamp: 1700000000, Level: "info", Event: "export_done", Filename: "photo.jpg", Size: 2048})
|
||||||
|
w.Close()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
t.Error("expected log data")
|
||||||
|
}
|
||||||
|
if data[len(data)-1] != '\n' {
|
||||||
|
t.Error("expected trailing newline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileLogWriterClosed(t *testing.T) {
|
||||||
|
w, err := NewFileLogWriter(LogPath(t.TempDir()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
w.Log(LogEntry{Event: "after-close"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewFileLogWriterError(t *testing.T) {
|
||||||
|
if _, err := NewFileLogWriter("/nonexistent/dir/export.log"); err == nil {
|
||||||
|
t.Error("expected error for bad path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileLogWriterDoubleClose(t *testing.T) {
|
||||||
|
w, err := NewFileLogWriter(LogPath(t.TempDir()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
w.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogPath(t *testing.T) {
|
||||||
|
if got := LogPath("/tmp/out"); got != "/tmp/out/export.log" {
|
||||||
|
t.Fatalf("LogPath = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user