diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8148e3..08dd3a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,23 @@
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.
diff --git a/Makefile b/Makefile
index 92cb673..08fd75b 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli
-VERSION := 0.9.4
+VERSION := 0.10.0
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 696ae36..7abb7d8 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,18 +1,25 @@
-# v0.9.4
+# v0.10.0
-This release adds `doctor` diagnostics for Photos access and backups.
+Ports and adapters refactor. No user-visible changes.
## Highlights
-- Add `doctor` to check Photos access.
-- Add `doctor --out
` to report backup directory, manifest entries, and failure count.
-- Add `doctor --json` for scriptable diagnostics.
-- `doctor` is read-only and does not modify backup data.
+- Extract manifest types, registry, and conversion logic into `internal/manifest/types`.
+- Extract SQLite adapter into `internal/manifest/sqlite` with its own store, adapter, and log writer.
+- Extract JSONL adapter into `internal/manifest/jsonl` with its own store and adapter.
+- 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
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
-- `photoscli-0.9.4-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.
Intel Macs are not currently a supported release target.
diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go
index 90e3454..164941b 100644
--- a/cmd/photoscli/main.go
+++ b/cmd/photoscli/main.go
@@ -34,6 +34,7 @@ var (
renameFunc = os.Rename
openFileFunc = os.OpenFile
removeFunc = os.Remove
+ reg = manifest.Default
)
type exportOptions struct {
@@ -500,7 +501,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
return exitErr
}
- mf, mfErr := manifest.ParseFormat(manifestFmt)
+ mf, mfErr := reg.ParseFormat(manifestFmt)
if mfErr != nil {
fmt.Fprintf(stderr, "error: %v\n", mfErr)
return exitErr
@@ -603,7 +604,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
}
var exported, failed int
if opts.metadataOnly {
- m, _ := manifest.Open(outDir, mf)
+ m, _ := reg.Open(outDir, mf)
defer m.Close()
entries := manifestEntries(m)
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
@@ -673,7 +674,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
return exitErr
}
- mf, mfErr := manifest.ParseFormat(manifestFmt)
+ mf, mfErr := reg.ParseFormat(manifestFmt)
if mfErr != nil {
fmt.Fprintf(stderr, "error: %v\n", mfErr)
return exitErr
@@ -747,7 +748,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
}
var totalAssets, failed int
if opts.metadataOnly {
- m, _ := manifest.Open(outDir, mf)
+ m, _ := reg.Open(outDir, mf)
entries := manifestEntries(m)
m.Close()
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
@@ -1299,10 +1300,7 @@ func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals b
}
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
- if r, ok := m.(manifest.EntryReader); ok {
- return r.Entries()
- }
- return nil
+ return m.Entries()
}
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
@@ -1428,7 +1426,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
var m manifest.Manifest
if !noManifest {
var err error
- m, err = manifest.Open(outDir, mf)
+ m, err = reg.Open(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err)
} else {
@@ -1441,7 +1439,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
var lw manifest.LogWriter = manifest.NoopLogWriter
if enableLog {
var err error
- lw, err = manifest.OpenLogWriter(m, outDir)
+ lw, err = reg.OpenLogWriter(m, outDir, mf)
if err != nil {
fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err)
lw = manifest.NoopLogWriter
@@ -1717,7 +1715,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con
var m manifest.Manifest
if !noManifest {
var err error
- m, err = manifest.Open(outDir, mf)
+ m, err = reg.Open(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err)
} else {
@@ -1730,7 +1728,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con
var lw manifest.LogWriter = manifest.NoopLogWriter
if enableLog {
var err error
- lw, err = manifest.OpenLogWriter(m, outDir)
+ lw, err = reg.OpenLogWriter(m, outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err)
lw = manifest.NoopLogWriter
@@ -2230,7 +2228,7 @@ func cmdReport(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
- mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
+ mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
@@ -2259,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")
return exitErr
}
- mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
+ mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
@@ -2304,7 +2302,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
- mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
+ mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
@@ -2417,7 +2415,7 @@ func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
problems++
} else {
result["backup_dir"] = "ok"
- mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
+ mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
@@ -2466,7 +2464,7 @@ func cmdCleanup(args []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
- mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
+ mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
@@ -2544,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)
return exitErr
}
- mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
+ mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
@@ -2755,7 +2753,7 @@ func cmdStatus(args []string, stdout, stderr io.Writer) int {
return exitErr
}
manifestFmt := flagValWithDefault(args, "--manifest", "jsonl")
- mf, err := manifest.ParseFormat(manifestFmt)
+ mf, err := reg.ParseFormat(manifestFmt)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go
index ce442ee..c76f8ae 100644
--- a/cmd/photoscli/main_test.go
+++ b/cmd/photoscli/main_test.go
@@ -50,12 +50,13 @@ func (t testFileInfo) Sys() any { return nil }
type noEntryManifest struct{}
-func (noEntryManifest) Has(string) bool { return false }
-func (noEntryManifest) Add(string, string, int64, string) {}
-func (noEntryManifest) AddEntry(manifest.Entry) {}
-func (noEntryManifest) Save() error { return nil }
-func (noEntryManifest) Close() {}
-func (noEntryManifest) OpenAppend() error { return nil }
+func (noEntryManifest) Has(string) bool { return false }
+func (noEntryManifest) Add(string, string, int64, string) {}
+func (noEntryManifest) AddEntry(manifest.Entry) {}
+func (noEntryManifest) Save() error { return nil }
+func (noEntryManifest) Close() {}
+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) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
@@ -5455,7 +5456,8 @@ func (m *mockManifest) Has(string) bool { return false }
func (m *mockManifest) Add(id string, filename string, size int64, cloud string) {
m.last = manifest.NewEntry(id, filename, filename, size, cloud)
}
-func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
-func (m *mockManifest) Save() error { return nil }
-func (m *mockManifest) Close() {}
-func (m *mockManifest) OpenAppend() error { return nil }
+func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e }
+func (m *mockManifest) Save() error { return nil }
+func (m *mockManifest) Close() {}
+func (m *mockManifest) OpenAppend() error { return nil }
+func (m *mockManifest) Entries() map[string]manifest.Entry { return nil }
diff --git a/internal/manifest/entry_test.go b/internal/manifest/entry_test.go
deleted file mode 100644
index 507d4f6..0000000
--- a/internal/manifest/entry_test.go
+++ /dev/null
@@ -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()
-}
diff --git a/internal/manifest/jsonl/adapter.go b/internal/manifest/jsonl/adapter.go
new file mode 100644
index 0000000..69c6bbb
--- /dev/null
+++ b/internal/manifest/jsonl/adapter.go
@@ -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))
+}
diff --git a/internal/manifest/jsonl/adapter_test.go b/internal/manifest/jsonl/adapter_test.go
new file mode 100644
index 0000000..ae5e8ac
--- /dev/null
+++ b/internal/manifest/jsonl/adapter_test.go
@@ -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
diff --git a/internal/manifest/jsonl.go b/internal/manifest/jsonl/store.go
similarity index 66%
rename from internal/manifest/jsonl.go
rename to internal/manifest/jsonl/store.go
index 13125ea..d7a23ac 100644
--- a/internal/manifest/jsonl.go
+++ b/internal/manifest/jsonl/store.go
@@ -1,4 +1,4 @@
-package manifest
+package jsonl
import (
"encoding/json"
@@ -6,32 +6,34 @@ import (
"path/filepath"
"strings"
"sync"
+
+ "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
-type jsonlManifest struct {
+type Store struct {
mu sync.Mutex
- entries map[string]Entry
+ entries map[string]types.Entry
path string
file *os.File
- syncFunc func() error
+ SyncFunc func() error
}
-var jsonlSaveHook func() error
+var saveHook func() error
-func SetJSONLSaveHook(fn func() error) func() error {
- old := jsonlSaveHook
- jsonlSaveHook = fn
+func SetSaveHook(fn func() error) func() error {
+ old := saveHook
+ saveHook = fn
return old
}
-func JSONLPath(dir string) string {
+func Path(dir string) string {
return filepath.Join(dir, "downloads.jsonl")
}
-func LoadJSONL(dir string) *jsonlManifest {
- m := &jsonlManifest{
- entries: make(map[string]Entry),
- path: JSONLPath(dir),
+func Load(dir string) *Store {
+ m := &Store{
+ entries: make(map[string]types.Entry),
+ path: Path(dir),
}
data, err := os.ReadFile(m.path)
if err != nil {
@@ -56,7 +58,7 @@ func LoadJSONL(dir string) *jsonlManifest {
if raw.Path == "" {
raw.Path = raw.Filename
}
- m.entries[raw.ID] = Entry{
+ m.entries[raw.ID] = types.Entry{
ID: raw.ID,
Filename: raw.Filename,
Path: raw.Path,
@@ -70,18 +72,22 @@ func LoadJSONL(dir string) *jsonlManifest {
return m
}
-func (m *jsonlManifest) Has(id string) bool {
+func (m *Store) Has(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.entries[id]
return ok
}
-func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) {
- m.AddEntry(newEntry(id, filename, size, cloud))
+func (m *Store) ManifestFormat() types.Format {
+ 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()
defer m.mu.Unlock()
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()
defer m.mu.Unlock()
- if m.syncFunc != nil {
- return m.syncFunc()
+ if m.SyncFunc != nil {
+ return m.SyncFunc()
}
- if jsonlSaveHook != nil {
- return jsonlSaveHook()
+ if saveHook != nil {
+ return saveHook()
}
if m.file != nil {
return m.file.Sync()
@@ -118,7 +124,7 @@ func (m *jsonlManifest) Save() error {
return nil
}
-func (m *jsonlManifest) Close() {
+func (m *Store) Close() {
m.mu.Lock()
defer m.mu.Unlock()
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()
defer m.mu.Unlock()
if m.file != nil {
@@ -144,12 +150,20 @@ func (m *jsonlManifest) OpenAppend() error {
return nil
}
-func (m *jsonlManifest) Entries() map[string]Entry {
+func (m *Store) Entries() map[string]types.Entry {
m.mu.Lock()
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 {
out[k] = v
}
return out
}
+
+func FileExists(path string) bool {
+ info, err := os.Stat(path)
+ if err != nil {
+ return false
+ }
+ return !info.IsDir()
+}
diff --git a/internal/manifest/jsonl/store_test.go b/internal/manifest/jsonl/store_test.go
new file mode 100644
index 0000000..7d7e985
--- /dev/null
+++ b/internal/manifest/jsonl/store_test.go
@@ -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)
+ }
+}
diff --git a/internal/manifest/log.go b/internal/manifest/log.go
deleted file mode 100644
index 6e2c1fb..0000000
--- a/internal/manifest/log.go
+++ /dev/null
@@ -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"
-}
diff --git a/internal/manifest/log_test.go b/internal/manifest/log_test.go
deleted file mode 100644
index 52eb73c..0000000
--- a/internal/manifest/log_test.go
+++ /dev/null
@@ -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")
- }
-}
diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go
index 067ad4e..9c296dc 100644
--- a/internal/manifest/manifest.go
+++ b/internal/manifest/manifest.go
@@ -1,51 +1,37 @@
package manifest
-import "time"
+import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
-type Entry struct {
- ID string
- Filename string
- Path string
- Size int64
- Cloud string
- Checksum string
- Exported int64
-}
+type Entry = types.Entry
-type Manifest interface {
- Has(id string) bool
- Add(id string, filename string, size int64, cloud string)
- AddEntry(entry Entry)
- Save() error
- Close()
- OpenAppend() error
-}
+type Manifest = types.Manifest
-type EntryReader interface {
- Entries() map[string]Entry
-}
+type EntryReader = types.EntryReader
-func newEntry(id, filename string, size int64, cloud string) Entry {
- return Entry{
- ID: id,
- Filename: filename,
- Path: filename,
- Size: size,
- Cloud: cloud,
- Exported: time.Now().Unix(),
- }
-}
+type Format = types.Format
-func NewEntry(id, filename, path string, size int64, cloud string) Entry {
- e := newEntry(id, filename, size, cloud)
- if path != "" {
- e.Path = path
- }
- return e
-}
+const (
+ FormatJSONL = types.FormatJSONL
+ FormatSQLite = types.FormatSQLite
+)
-func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry {
- e := NewEntry(id, filename, path, size, cloud)
- e.Checksum = checksum
- return e
-}
+type LogEntry = types.LogEntry
+
+type LogWriter = types.LogWriter
+
+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
+)
diff --git a/internal/manifest/memory.go b/internal/manifest/memory.go
new file mode 100644
index 0000000..337a717
--- /dev/null
+++ b/internal/manifest/memory.go
@@ -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
+}
diff --git a/internal/manifest/open.go b/internal/manifest/open.go
index ecde369..52b82a4 100644
--- a/internal/manifest/open.go
+++ b/internal/manifest/open.go
@@ -1,84 +1,21 @@
package manifest
import (
- "fmt"
"os"
- "strings"
-)
-type Format string
-
-const (
- FormatJSONL Format = "jsonl"
- FormatSQLite Format = "sqlite"
+ "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
func Open(dir string, format Format) (Manifest, error) {
- jsonlPath := JSONLPath(dir)
- sqlitePath := SQLitePath(dir)
- jsonlExists := FileExists(jsonlPath)
- sqliteExists := FileExists(sqlitePath)
-
- switch {
- case format == FormatJSONL && jsonlExists:
- return LoadJSONL(dir), nil
- case format == FormatSQLite && sqliteExists:
- return LoadSQLite(dir)
- case format == FormatJSONL && sqliteExists:
- return ConvertFromSQLite(dir)
- case format == FormatSQLite && jsonlExists:
- return ConvertFromJSONL(dir)
- default:
- if format == FormatJSONL {
- return LoadJSONL(dir), nil
- }
- return LoadSQLite(dir)
- }
+ return Default.Open(dir, format)
}
func ConvertFromJSONL(dir string) (Manifest, error) {
- src := LoadJSONL(dir)
- if err := src.OpenAppend(); err != nil {
- return nil, fmt.Errorf("open jsonl for read: %w", err)
- }
- defer src.Close()
-
- dst, _ := LoadSQLite(dir)
- if err := dst.OpenAppend(); err != nil {
- return nil, fmt.Errorf("open sqlite for write: %w", err)
- }
-
- for id, e := range src.Entries() {
- e.ID = id
- dst.AddEntry(e)
- }
-
- os.Remove(JSONLPath(dir))
- return dst, nil
+ return types.ConvertManifest(dir, JSONLAdapter, SQLiteAdapter)
}
func ConvertFromSQLite(dir string) (Manifest, error) {
- src, _ := LoadSQLite(dir)
- if err := src.OpenAppend(); err != nil {
- return nil, fmt.Errorf("open sqlite for read: %w", err)
- }
- defer src.Close()
-
- dst := LoadJSONL(dir)
- if err := dst.OpenAppend(); err != nil {
- return nil, fmt.Errorf("open jsonl for write: %w", err)
- }
-
- for id, e := range src.Entries() {
- 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
+ return types.ConvertManifest(dir, SQLiteAdapter, JSONLAdapter)
}
func FileExists(path string) bool {
@@ -90,19 +27,21 @@ func FileExists(path string) bool {
}
func ParseFormat(s string) (Format, error) {
- switch strings.ToLower(s) {
- case "jsonl", "json":
- return FormatJSONL, nil
- case "sqlite", "db", "sqlite3":
- return FormatSQLite, nil
- default:
- return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
- }
+ return Default.ParseFormat(s)
}
func OpenLogWriter(m Manifest, dir string) (LogWriter, error) {
- if sm, ok := m.(*sqliteManifest); ok && sm.DB() != nil {
- return NewSQLiteLogWriter(sm.DB())
+ format := FormatJSONL
+ 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)
}
diff --git a/internal/manifest/registry.go b/internal/manifest/registry.go
new file mode 100644
index 0000000..f6bd866
--- /dev/null
+++ b/internal/manifest/registry.go
@@ -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)
+}
diff --git a/internal/manifest/registry_test.go b/internal/manifest/registry_test.go
new file mode 100644
index 0000000..9a962b3
--- /dev/null
+++ b/internal/manifest/registry_test.go
@@ -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") }
diff --git a/internal/manifest/sqlite/adapter.go b/internal/manifest/sqlite/adapter.go
new file mode 100644
index 0000000..09348cc
--- /dev/null
+++ b/internal/manifest/sqlite/adapter.go
@@ -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))
+}
diff --git a/internal/manifest/sqlite/adapter_test.go b/internal/manifest/sqlite/adapter_test.go
new file mode 100644
index 0000000..d0ee122
--- /dev/null
+++ b/internal/manifest/sqlite/adapter_test.go
@@ -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()
+}
diff --git a/internal/manifest/sqlite_log.go b/internal/manifest/sqlite/log_writer.go
similarity index 74%
rename from internal/manifest/sqlite_log.go
rename to internal/manifest/sqlite/log_writer.go
index 30e8146..0e988c0 100644
--- a/internal/manifest/sqlite_log.go
+++ b/internal/manifest/sqlite/log_writer.go
@@ -1,14 +1,16 @@
-package manifest
+package sqlite
import (
"database/sql"
+
+ "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
-type sqliteLogWriter struct {
+type LogWriter struct {
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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_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 {
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)
}
-func (w *sqliteLogWriter) Close() { _ = w }
+func (w *LogWriter) Close() { _ = w }
diff --git a/internal/manifest/sqlite.go b/internal/manifest/sqlite/store.go
similarity index 62%
rename from internal/manifest/sqlite.go
rename to internal/manifest/sqlite/store.go
index 8957f19..9e2d7a4 100644
--- a/internal/manifest/sqlite.go
+++ b/internal/manifest/sqlite/store.go
@@ -1,4 +1,4 @@
-package manifest
+package sqlite
import (
"database/sql"
@@ -6,40 +6,46 @@ import (
"os"
"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
db *sql.DB
- open sqlOpenerFunc
+ open OpenerFunc
execFunc func(query string, args ...any) (sql.Result, error)
}
-type sqlOpenerFunc func(driverName, dataSourceName string) (*sql.DB, error)
-
-var sqliteOpenFunc sqlOpenerFunc
-
-func defaultSQLOpener() sqlOpenerFunc {
- if sqliteOpenFunc != nil {
- return sqliteOpenFunc
- }
- return sql.Open
-}
-
-func SQLitePath(dir string) string {
+func Path(dir string) string {
return filepath.Join(dir, "downloads.db")
}
-func LoadSQLite(dir string) (*sqliteManifest, error) {
- m := &sqliteManifest{
- path: SQLitePath(dir),
- open: defaultSQLOpener(),
+func Load(dir string) (*Store, error) {
+ m := &Store{
+ path: Path(dir),
+ open: opener(),
}
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 {
return nil
}
@@ -82,7 +88,13 @@ func (m *sqliteManifest) OpenAppend() error {
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 {
return false
}
@@ -94,11 +106,15 @@ func (m *sqliteManifest) Has(id string) bool {
return count > 0
}
-func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) {
- m.AddEntry(newEntry(id, filename, size, cloud))
+func (m *Store) ManifestFormat() types.Format {
+ 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 {
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)
}
-func (m *sqliteManifest) Save() error {
- return nil
-}
+func (m *Store) Save() error { return nil }
-func (m *sqliteManifest) Close() {
+func (m *Store) Close() {
if m.db != nil {
m.db.Close()
m.db = nil
}
}
-func (m *sqliteManifest) DB() *sql.DB {
- return m.db
-}
-
-func (m *sqliteManifest) Entries() map[string]Entry {
+func (m *Store) Entries() map[string]types.Entry {
if m.db == 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`)
if err != nil {
return out
}
defer rows.Close()
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 e.Path == "" {
e.Path = e.Filename
@@ -145,3 +155,11 @@ func (m *sqliteManifest) Entries() map[string]Entry {
}
return out
}
+
+func FileExists(path string) bool {
+ info, err := os.Stat(path)
+ if err != nil {
+ return false
+ }
+ return !info.IsDir()
+}
diff --git a/internal/manifest/sqlite/store_test.go b/internal/manifest/sqlite/store_test.go
new file mode 100644
index 0000000..f4eb720
--- /dev/null
+++ b/internal/manifest/sqlite/store_test.go
@@ -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()
+}
diff --git a/internal/manifest/sqlite_internal_test.go b/internal/manifest/sqlite_internal_test.go
deleted file mode 100644
index 970c4fa..0000000
--- a/internal/manifest/sqlite_internal_test.go
+++ /dev/null
@@ -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)
- }
-}
diff --git a/internal/manifest/file_log.go b/internal/manifest/types/file_log.go
similarity index 97%
rename from internal/manifest/file_log.go
rename to internal/manifest/types/file_log.go
index b2cdb0d..fa517d8 100644
--- a/internal/manifest/file_log.go
+++ b/internal/manifest/types/file_log.go
@@ -1,4 +1,4 @@
-package manifest
+package types
import (
"encoding/json"
diff --git a/internal/manifest/types/registry.go b/internal/manifest/types/registry.go
new file mode 100644
index 0000000..6da8725
--- /dev/null
+++ b/internal/manifest/types/registry.go
@@ -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
+}
diff --git a/internal/manifest/types/registry_test.go b/internal/manifest/types/registry_test.go
new file mode 100644
index 0000000..411b310
--- /dev/null
+++ b/internal/manifest/types/registry_test.go
@@ -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
+}
diff --git a/internal/manifest/types/types.go b/internal/manifest/types/types.go
new file mode 100644
index 0000000..a6d3178
--- /dev/null
+++ b/internal/manifest/types/types.go
@@ -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
+}
diff --git a/internal/manifest/types/types_test.go b/internal/manifest/types/types_test.go
new file mode 100644
index 0000000..ddd9919
--- /dev/null
+++ b/internal/manifest/types/types_test.go
@@ -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)
+ }
+}