v0.5.0: manifests, filters, logging, docs
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type fileLogWriter struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
}
|
||||
|
||||
func NewFileLogWriter(path string) (LogWriter, error) {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileLogWriter{f: f}, nil
|
||||
}
|
||||
|
||||
func (w *fileLogWriter) Log(e LogEntry) {
|
||||
data, _ := json.Marshal(e)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.f != nil {
|
||||
w.f.Write(data)
|
||||
w.f.Write([]byte("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func (w *fileLogWriter) Close() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.f != nil {
|
||||
w.f.Close()
|
||||
w.f = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type jsonlManifest struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]Entry
|
||||
path string
|
||||
file *os.File
|
||||
syncFunc func() error
|
||||
}
|
||||
|
||||
var jsonlSaveHook func() error
|
||||
|
||||
func SetJSONLSaveHook(fn func() error) func() error {
|
||||
old := jsonlSaveHook
|
||||
jsonlSaveHook = fn
|
||||
return old
|
||||
}
|
||||
|
||||
func JSONLPath(dir string) string {
|
||||
return filepath.Join(dir, "downloads.jsonl")
|
||||
}
|
||||
|
||||
func LoadJSONL(dir string) *jsonlManifest {
|
||||
m := &jsonlManifest{
|
||||
entries: make(map[string]Entry),
|
||||
path: JSONLPath(dir),
|
||||
}
|
||||
data, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
type entryWithID struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
Cloud string `json:"cloud"`
|
||||
Exported int64 `json:"exported"`
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var raw entryWithID
|
||||
if json.Unmarshal([]byte(line), &raw) == nil && raw.ID != "" {
|
||||
m.entries[raw.ID] = Entry{
|
||||
Filename: raw.Filename,
|
||||
Size: raw.Size,
|
||||
Cloud: raw.Cloud,
|
||||
Exported: raw.Exported,
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *jsonlManifest) Has(id string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ok := m.entries[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
entry := newEntry(id, filename, size, cloud)
|
||||
m.entries[id] = entry
|
||||
if m.file != nil {
|
||||
data, _ := json.Marshal(struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
Cloud string `json:"cloud"`
|
||||
Exported int64 `json:"exported"`
|
||||
}{ID: id, Filename: entry.Filename, Size: entry.Size, Cloud: entry.Cloud, Exported: entry.Exported})
|
||||
m.file.Write(data)
|
||||
m.file.Write([]byte("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *jsonlManifest) Save() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.syncFunc != nil {
|
||||
return m.syncFunc()
|
||||
}
|
||||
if jsonlSaveHook != nil {
|
||||
return jsonlSaveHook()
|
||||
}
|
||||
if m.file != nil {
|
||||
return m.file.Sync()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *jsonlManifest) Close() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.file != nil {
|
||||
m.file.Close()
|
||||
m.file = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *jsonlManifest) OpenAppend() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.file != nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(m.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.file = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *jsonlManifest) Entries() map[string]Entry {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make(map[string]Entry, len(m.entries))
|
||||
for k, v := range m.entries {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package manifest
|
||||
|
||||
type LogEntry struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Level string `json:"level"`
|
||||
Event string `json:"event"`
|
||||
AssetID string `json:"asset_id,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Cloud string `json:"cloud,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type LogWriter interface {
|
||||
Log(entry LogEntry)
|
||||
Close()
|
||||
}
|
||||
|
||||
type noopLogWriter struct{}
|
||||
|
||||
func (noopLogWriter) Log(LogEntry) { _ = struct{}{} }
|
||||
func (noopLogWriter) Close() { _ = struct{}{} }
|
||||
|
||||
var NoopLogWriter LogWriter = noopLogWriter{}
|
||||
|
||||
func LogPath(dir string) string {
|
||||
return dir + "/export.log"
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestNoopLogWriter(t *testing.T) {
|
||||
lw := NoopLogWriter
|
||||
lw.Log(LogEntry{Event: "test"})
|
||||
lw.Close()
|
||||
noopLogWriter{}.Log(LogEntry{Event: "test"})
|
||||
noopLogWriter{}.Close()
|
||||
}
|
||||
|
||||
func TestNewSQLiteLogWriter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
lw, err := NewSQLiteLogWriter(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lw.Log(LogEntry{
|
||||
Timestamp: 1700000000,
|
||||
Level: "info",
|
||||
Event: "export_done",
|
||||
AssetID: "asset-1",
|
||||
Album: "Favorites",
|
||||
Filename: "photo.jpg",
|
||||
Size: 1024,
|
||||
Cloud: "local",
|
||||
DurationMs: 500,
|
||||
Message: "",
|
||||
})
|
||||
|
||||
lw.Log(LogEntry{
|
||||
Timestamp: 1700000001,
|
||||
Level: "error",
|
||||
Event: "export_fail",
|
||||
AssetID: "asset-2",
|
||||
Filename: "bad.jpg",
|
||||
Message: "timeout",
|
||||
})
|
||||
|
||||
lw.Close()
|
||||
|
||||
var count int
|
||||
err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2 log entries, got %d", count)
|
||||
}
|
||||
|
||||
var event, level, assetID string
|
||||
err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if event != "export_done" || level != "info" || assetID != "asset-1" {
|
||||
t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteLogWriterNilDB(t *testing.T) {
|
||||
w := &sqliteLogWriter{db: nil}
|
||||
w.Log(LogEntry{Event: "test"})
|
||||
w.Close()
|
||||
}
|
||||
|
||||
func TestNewSQLiteLogWriterError(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewSQLiteLogWriter(db); err == nil {
|
||||
t.Error("expected error for closed db")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteLogWriterCloseConcrete(t *testing.T) {
|
||||
(&sqliteLogWriter{}).Close()
|
||||
}
|
||||
|
||||
func TestNewFileLogWriter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "export.log")
|
||||
lw, err := NewFileLogWriter(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lw.Log(LogEntry{
|
||||
Timestamp: 1700000000,
|
||||
Level: "info",
|
||||
Event: "export_done",
|
||||
Filename: "photo.jpg",
|
||||
Size: 2048,
|
||||
})
|
||||
|
||||
lw.Close()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("expected log data")
|
||||
}
|
||||
if data[len(data)-1] != '\n' {
|
||||
t.Error("expected trailing newline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileLogWriterClosed(t *testing.T) {
|
||||
w := &fileLogWriter{f: nil}
|
||||
w.Log(LogEntry{Event: "test"})
|
||||
w.Close()
|
||||
}
|
||||
|
||||
func TestNewFileLogWriterError(t *testing.T) {
|
||||
_, err := NewFileLogWriter("/nonexistent/dir/export.log")
|
||||
if err == nil {
|
||||
t.Error("expected error for bad path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileLogWriterDoubleClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "export.log")
|
||||
lw, err := NewFileLogWriter(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lw.Close()
|
||||
lw.Close()
|
||||
}
|
||||
|
||||
func TestLogPath(t *testing.T) {
|
||||
p := LogPath("/tmp/out")
|
||||
if p != "/tmp/out/export.log" {
|
||||
t.Errorf("expected /tmp/out/export.log, got %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLogWriterSQLite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
lw, err := OpenLogWriter(m, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
||||
lw.Close()
|
||||
}
|
||||
|
||||
func TestOpenLogWriterJSONL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := LoadJSONL(dir)
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
lw, err := OpenLogWriter(m, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
||||
lw.Close()
|
||||
|
||||
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
|
||||
t.Error("expected export.log to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLogWriterNilManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
lw, err := OpenLogWriter(nil, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lw.Log(LogEntry{Event: "test", Level: "info"})
|
||||
lw.Close()
|
||||
if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) {
|
||||
t.Error("expected export.log to exist for nil manifest")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package manifest
|
||||
|
||||
import "time"
|
||||
|
||||
type Entry struct {
|
||||
ID string
|
||||
Filename string
|
||||
Size int64
|
||||
Cloud string
|
||||
Exported int64
|
||||
}
|
||||
|
||||
type Manifest interface {
|
||||
Has(id string) bool
|
||||
Add(id string, filename string, size int64, cloud string)
|
||||
Save() error
|
||||
Close()
|
||||
OpenAppend() error
|
||||
}
|
||||
|
||||
type EntryReader interface {
|
||||
Entries() map[string]Entry
|
||||
}
|
||||
|
||||
func newEntry(id, filename string, size int64, cloud string) Entry {
|
||||
return Entry{
|
||||
ID: id,
|
||||
Filename: filename,
|
||||
Size: size,
|
||||
Cloud: cloud,
|
||||
Exported: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Format string
|
||||
|
||||
const (
|
||||
FormatJSONL Format = "jsonl"
|
||||
FormatSQLite Format = "sqlite"
|
||||
)
|
||||
|
||||
func Open(dir string, format Format) (Manifest, error) {
|
||||
jsonlPath := JSONLPath(dir)
|
||||
sqlitePath := SQLitePath(dir)
|
||||
jsonlExists := FileExists(jsonlPath)
|
||||
sqliteExists := FileExists(sqlitePath)
|
||||
|
||||
switch {
|
||||
case format == FormatJSONL && jsonlExists:
|
||||
return LoadJSONL(dir), nil
|
||||
case format == FormatSQLite && sqliteExists:
|
||||
return LoadSQLite(dir)
|
||||
case format == FormatJSONL && sqliteExists:
|
||||
return ConvertFromSQLite(dir)
|
||||
case format == FormatSQLite && jsonlExists:
|
||||
return ConvertFromJSONL(dir)
|
||||
default:
|
||||
if format == FormatJSONL {
|
||||
return LoadJSONL(dir), nil
|
||||
}
|
||||
return LoadSQLite(dir)
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertFromJSONL(dir string) (Manifest, error) {
|
||||
src := LoadJSONL(dir)
|
||||
if err := src.OpenAppend(); err != nil {
|
||||
return nil, fmt.Errorf("open jsonl for read: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst, _ := LoadSQLite(dir)
|
||||
if err := dst.OpenAppend(); err != nil {
|
||||
return nil, fmt.Errorf("open sqlite for write: %w", err)
|
||||
}
|
||||
|
||||
for id, e := range src.Entries() {
|
||||
dst.Add(id, e.Filename, e.Size, e.Cloud)
|
||||
}
|
||||
|
||||
os.Remove(JSONLPath(dir))
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func ConvertFromSQLite(dir string) (Manifest, error) {
|
||||
src, _ := LoadSQLite(dir)
|
||||
if err := src.OpenAppend(); err != nil {
|
||||
return nil, fmt.Errorf("open sqlite for read: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
dst := LoadJSONL(dir)
|
||||
if err := dst.OpenAppend(); err != nil {
|
||||
return nil, fmt.Errorf("open jsonl for write: %w", err)
|
||||
}
|
||||
|
||||
for id, e := range src.Entries() {
|
||||
dst.Add(id, e.Filename, e.Size, e.Cloud)
|
||||
}
|
||||
if err := dst.Save(); err != nil {
|
||||
return nil, fmt.Errorf("save jsonl: %w", err)
|
||||
}
|
||||
|
||||
os.Remove(SQLitePath(dir))
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func FileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
func ParseFormat(s string) (Format, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "jsonl", "json":
|
||||
return FormatJSONL, nil
|
||||
case "sqlite", "db", "sqlite3":
|
||||
return FormatSQLite, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s)
|
||||
}
|
||||
}
|
||||
|
||||
func OpenLogWriter(m Manifest, dir string) (LogWriter, error) {
|
||||
if sm, ok := m.(*sqliteManifest); ok && sm.DB() != nil {
|
||||
return NewSQLiteLogWriter(sm.DB())
|
||||
}
|
||||
return NewFileLogWriter(LogPath(dir))
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type sqliteManifest struct {
|
||||
path string
|
||||
db *sql.DB
|
||||
open sqlOpenerFunc
|
||||
execFunc func(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
type sqlOpenerFunc func(driverName, dataSourceName string) (*sql.DB, error)
|
||||
|
||||
var sqliteOpenFunc sqlOpenerFunc
|
||||
|
||||
func defaultSQLOpener() sqlOpenerFunc {
|
||||
if sqliteOpenFunc != nil {
|
||||
return sqliteOpenFunc
|
||||
}
|
||||
return sql.Open
|
||||
}
|
||||
|
||||
func SQLitePath(dir string) string {
|
||||
return filepath.Join(dir, "downloads.db")
|
||||
}
|
||||
|
||||
func LoadSQLite(dir string) (*sqliteManifest, error) {
|
||||
m := &sqliteManifest{
|
||||
path: SQLitePath(dir),
|
||||
open: defaultSQLOpener(),
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) OpenAppend() error {
|
||||
if m.db != nil {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
opener := m.open
|
||||
if opener == nil {
|
||||
opener = sql.Open
|
||||
}
|
||||
db, err := opener("sqlite", m.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
execFn := m.execFunc
|
||||
if execFn == nil {
|
||||
execFn = db.Exec
|
||||
}
|
||||
_, err = execFn(`CREATE TABLE IF NOT EXISTS downloads (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
cloud TEXT NOT NULL DEFAULT '',
|
||||
exported INTEGER NOT NULL DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("create table: %w", err)
|
||||
}
|
||||
_, err = execFn(`CREATE INDEX IF NOT EXISTS idx_downloads_id ON downloads(id)`)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("create index: %w", err)
|
||||
}
|
||||
m.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) Has(id string) bool {
|
||||
if m.db == nil {
|
||||
return false
|
||||
}
|
||||
var count int
|
||||
err := m.db.QueryRow(`SELECT COUNT(*) FROM downloads WHERE id = ?`, id).Scan(&count)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) {
|
||||
if m.db == nil {
|
||||
return
|
||||
}
|
||||
entry := newEntry(id, filename, size, cloud)
|
||||
m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, size, cloud, exported) VALUES (?, ?, ?, ?, ?)`,
|
||||
id, entry.Filename, entry.Size, entry.Cloud, entry.Exported)
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) Save() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) Close() {
|
||||
if m.db != nil {
|
||||
m.db.Close()
|
||||
m.db = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) DB() *sql.DB {
|
||||
return m.db
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) Entries() map[string]Entry {
|
||||
if m.db == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]Entry)
|
||||
rows, err := m.db.Query(`SELECT id, filename, size, cloud, exported FROM downloads`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.Filename, &e.Size, &e.Cloud, &e.Exported); err == nil {
|
||||
out[e.ID] = e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestSetJSONLSaveHook(t *testing.T) {
|
||||
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
|
||||
if old != nil {
|
||||
t.Error("expected nil old hook")
|
||||
}
|
||||
restore := SetJSONLSaveHook(old)
|
||||
if restore == nil {
|
||||
t.Error("expected non-nil restore function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLSaveHookError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := LoadJSONL(dir)
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") })
|
||||
defer SetJSONLSaveHook(old)
|
||||
if err := m.Save(); err == nil {
|
||||
t.Error("expected hook error from Save")
|
||||
}
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func TestJSONLSyncFuncError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := LoadJSONL(dir)
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
m.syncFunc = func() error { return fmt.Errorf("sync func error") }
|
||||
if err := m.Save(); err == nil {
|
||||
t.Error("expected syncFunc error from Save")
|
||||
}
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func TestSQLiteOpenAppendSQLError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("simulated open error")
|
||||
}
|
||||
if err := m.OpenAppend(); err == nil {
|
||||
t.Error("expected error from sql.Open failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteOpenAppendCreateTableError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realOpen := m.open
|
||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||
db, err := realOpen(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.Close()
|
||||
return db, nil
|
||||
}
|
||||
if err := m.OpenAppend(); err == nil {
|
||||
t.Error("expected error from closed DB CREATE TABLE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteHasAfterClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
m.Close()
|
||||
if m.Has("x1") {
|
||||
t.Error("Has should return false after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteEntriesAfterClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
m.Close()
|
||||
entries := m.Entries()
|
||||
if entries != nil {
|
||||
t.Errorf("Entries should return nil after Close, got %d entries", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteOpenAppendMkdirAllError(t *testing.T) {
|
||||
m, err := LoadSQLite("/proc/cannot-write")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.OpenAppend(); err == nil {
|
||||
t.Error("expected error creating dir under /proc")
|
||||
m.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteOpenAppendNilOpener(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.open = nil
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err)
|
||||
}
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func TestSQLiteOpenAppendCreateIndexError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realOpen := m.open
|
||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||
db, err := realOpen(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.Close()
|
||||
return db, nil
|
||||
}
|
||||
if err := m.OpenAppend(); err == nil {
|
||||
t.Error("expected error from closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteHasQueryError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
closedDB := m.db
|
||||
closedDB.Close()
|
||||
result := m.Has("x1")
|
||||
if result {
|
||||
t.Error("Has should return false with broken DB connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteEntriesQueryError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
closedDB := m.db
|
||||
closedDB.Close()
|
||||
entries := m.Entries()
|
||||
if entries == nil {
|
||||
t.Error("Entries should return non-nil map with broken DB connection")
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteHasQueryErrorWithOpenDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
closedDB := m.db
|
||||
closedDB.Close()
|
||||
if m.Has("x1") {
|
||||
t.Error("Has should return false with closed DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteEntriesQueryErrorWithOpenDB(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
closedDB := m.db
|
||||
closedDB.Close()
|
||||
entries := m.Entries()
|
||||
if entries != nil && len(entries) != 0 {
|
||||
t.Errorf("Entries should be empty with closed DB, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteCreateIndexError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realOpen := m.open
|
||||
callCount := 0
|
||||
m.open = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||
db, err := realOpen(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.execFunc = func(query string, args ...any) (sql.Result, error) {
|
||||
callCount++
|
||||
if callCount == 2 {
|
||||
return nil, fmt.Errorf("injected CREATE INDEX error")
|
||||
}
|
||||
return db.Exec(query, args...)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
if err := m.OpenAppend(); err == nil {
|
||||
t.Error("expected error from CREATE INDEX")
|
||||
m.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromJSONLOpenAppendSQLError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := LoadJSONL(dir)
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
m.Close()
|
||||
oldOpen := sqliteOpenFunc
|
||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("simulated sqlite open error")
|
||||
}
|
||||
defer func() { sqliteOpenFunc = oldOpen }()
|
||||
_, err := ConvertFromJSONL(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromJSONLDstOpenAppendError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := LoadJSONL(dir)
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
m.Close()
|
||||
realOpen := defaultSQLOpener()
|
||||
oldOpen := sqliteOpenFunc
|
||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||
db, err := realOpen(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.Close()
|
||||
return db, nil
|
||||
}
|
||||
defer func() { sqliteOpenFunc = oldOpen }()
|
||||
_, err := ConvertFromJSONL(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error from dst.OpenAppend during ConvertFromJSONL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSQLiteSrcOpenAppendError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src.Add("x1", "photo.jpg", 1024, "local")
|
||||
src.Close()
|
||||
realOpen := defaultSQLOpener()
|
||||
callCount := 0
|
||||
oldOpen := sqliteOpenFunc
|
||||
sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) {
|
||||
callCount++
|
||||
db, err := realOpen(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if callCount > 0 {
|
||||
db.Close()
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
defer func() { sqliteOpenFunc = oldOpen }()
|
||||
_, err = ConvertFromSQLite(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error from src.OpenAppend during ConvertFromSQLite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertFromSQLiteDstOpenAppendError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src.Add("x1", "photo.jpg", 1024, "local")
|
||||
src.Close()
|
||||
jsonlPath := JSONLPath(dir)
|
||||
f, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
os.Chmod(jsonlPath, 0444)
|
||||
defer os.Chmod(jsonlPath, 0644)
|
||||
_, err = ConvertFromSQLite(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error from dst.OpenAppend during ConvertFromSQLite")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLSaveError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := LoadJSONL(dir)
|
||||
if err := m.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Add("x1", "photo.jpg", 1024, "local")
|
||||
m.file.Close()
|
||||
if err := m.Save(); err == nil {
|
||||
t.Error("expected Sync error on closed file")
|
||||
}
|
||||
m.Close()
|
||||
}
|
||||
|
||||
func TestConvertFromSQLiteSaveError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src, err := LoadSQLite(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := src.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src.Add("x1", "photo.jpg", 1024, "local")
|
||||
src.Close()
|
||||
oldHook := jsonlSaveHook
|
||||
jsonlSaveHook = func() error { return fmt.Errorf("simulated sync error") }
|
||||
defer func() { jsonlSaveHook = oldHook }()
|
||||
_, err = ConvertFromSQLite(dir)
|
||||
if err == nil {
|
||||
t.Error("expected error from dst.Save during ConvertFromSQLite")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "save jsonl") {
|
||||
t.Errorf("expected save jsonl error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type sqliteLogWriter struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) {
|
||||
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
asset_id TEXT,
|
||||
album TEXT,
|
||||
filename TEXT,
|
||||
size INTEGER,
|
||||
cloud TEXT,
|
||||
duration_ms INTEGER,
|
||||
message TEXT
|
||||
)`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`)
|
||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`)
|
||||
return &sqliteLogWriter{db: db}, nil
|
||||
}
|
||||
|
||||
func (w *sqliteLogWriter) Log(e LogEntry) {
|
||||
if w.db == nil {
|
||||
return
|
||||
}
|
||||
w.db.Exec(`INSERT INTO logs (ts, level, event, asset_id, album, filename, size, cloud, duration_ms, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message)
|
||||
}
|
||||
|
||||
func (w *sqliteLogWriter) Close() { _ = w }
|
||||
Reference in New Issue
Block a user