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 }
|
||||
@@ -10,9 +10,9 @@ type Bridge interface {
|
||||
ListAlbums() ([]Album, error)
|
||||
ListAssets(albumID string) ([]Asset, int, error)
|
||||
ListTree() ([]CollectionNode, error)
|
||||
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
|
||||
ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error)
|
||||
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
|
||||
ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error)
|
||||
ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error)
|
||||
ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error)
|
||||
Cancel()
|
||||
IsCancelled() bool
|
||||
|
||||
@@ -62,29 +62,29 @@ func (*CgoBridge) IsCancelled() bool {
|
||||
return C.photos_request_is_cancelled() != 0
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
|
||||
return exportPreviewWithSlot(assetID, outputDir, targetSize, index, -1)
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) {
|
||||
return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, -1)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
|
||||
return exportOriginalWithSlot(assetID, outputDir, index, -1)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
|
||||
return exportPreviewWithSlot(assetID, outputDir, targetSize, index, slotIndex)
|
||||
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
|
||||
return exportPreviewWithSlot(assetID, outputDir, targetSize, quality, index, slotIndex)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) {
|
||||
return exportOriginalWithSlot(assetID, outputDir, index, slotIndex)
|
||||
}
|
||||
|
||||
func exportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
|
||||
func exportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
|
||||
cid := C.CString(assetID)
|
||||
defer C.free(unsafe.Pointer(cid))
|
||||
cdir := C.CString(outputDir)
|
||||
defer C.free(unsafe.Pointer(cdir))
|
||||
|
||||
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex))
|
||||
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(quality), C.int(index), C.int(slotIndex))
|
||||
if cs == nil {
|
||||
return ExportResult{}, errBridgeNil
|
||||
}
|
||||
@@ -117,8 +117,8 @@ func GetProgressSlots() []ExportProgressSlot {
|
||||
ptr := (*C.export_progress_t)(unsafe.Pointer(uintptr(unsafe.Pointer(slots)) + uintptr(i)*unsafe.Sizeof(C.export_progress_t{})))
|
||||
result[i] = ExportProgressSlot{
|
||||
Active: ptr.active != 0,
|
||||
Progress: float64(ptr.progress),
|
||||
BytesDone: int64(ptr.bytes_done),
|
||||
Progress: float64(ptr.progress),
|
||||
BytesDone: int64(ptr.bytes_done),
|
||||
BytesTotal: int64(ptr.bytes_total),
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,10 @@ func ResetProgressSlots() {
|
||||
C.photos_reset_progress_slots()
|
||||
}
|
||||
|
||||
func GetProgressSlotCount() int {
|
||||
return int(C.photos_get_progress_slot_count())
|
||||
}
|
||||
|
||||
type ExportProgressSlot struct {
|
||||
Active bool
|
||||
Progress float64
|
||||
|
||||
@@ -17,7 +17,11 @@ void photos_test_set_assets_null(void);
|
||||
void photos_test_set_tree_null(void);
|
||||
void photos_request_cancel(void);
|
||||
void photos_test_set_export_preview_json(const char *json);
|
||||
void photos_test_set_export_preview_json_null(void);
|
||||
void photos_test_set_export_original_json(const char *json);
|
||||
void photos_test_set_export_original_json_null(void);
|
||||
void photos_test_set_progress_slot_count(int count);
|
||||
void photos_test_set_progress_slots_null(int val);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
@@ -27,15 +31,19 @@ type CgoBridge struct{}
|
||||
|
||||
var DefaultBridge Bridge = &CgoBridge{}
|
||||
|
||||
func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) }
|
||||
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
|
||||
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
|
||||
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
|
||||
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
|
||||
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
|
||||
func SetTestTreeNull() { C.photos_test_set_tree_null() }
|
||||
func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) }
|
||||
func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) }
|
||||
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
|
||||
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
|
||||
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
|
||||
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
|
||||
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
|
||||
func SetTestTreeNull() { C.photos_test_set_tree_null() }
|
||||
func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) }
|
||||
func SetTestExportPreviewJSONNull() { C.photos_test_set_export_preview_json_null() }
|
||||
func SetTestExportOriginalJSON(json string) { C.photos_test_set_export_original_json(C.CString(json)) }
|
||||
func SetTestExportOriginalJSONNull() { C.photos_test_set_export_original_json_null() }
|
||||
func SetTestProgressSlotCount(count int) { C.photos_test_set_progress_slot_count(C.int(count)) }
|
||||
func SetTestProgressSlotsNull(val int) { C.photos_test_set_progress_slots_null(C.int(val)) }
|
||||
|
||||
func (*CgoBridge) RequestAccess() error {
|
||||
rc := C.photos_request_access()
|
||||
@@ -82,28 +90,28 @@ func (*CgoBridge) IsCancelled() bool {
|
||||
return C.photos_request_is_cancelled() != 0
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
|
||||
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, -1)
|
||||
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error) {
|
||||
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, -1)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
|
||||
return exportOriginalWithSlotTest(assetID, outputDir, index, -1)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
|
||||
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, index, slotIndex)
|
||||
func (*CgoBridge) ExportPreviewWithSlot(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
|
||||
return exportPreviewWithSlotTest(assetID, outputDir, targetSize, quality, index, slotIndex)
|
||||
}
|
||||
|
||||
func (*CgoBridge) ExportOriginalWithSlot(assetID, outputDir string, index, slotIndex int) (ExportResult, error) {
|
||||
return exportOriginalWithSlotTest(assetID, outputDir, index, slotIndex)
|
||||
}
|
||||
|
||||
func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, index, slotIndex int) (ExportResult, error) {
|
||||
func exportPreviewWithSlotTest(assetID, outputDir string, targetSize, quality, index, slotIndex int) (ExportResult, error) {
|
||||
cid := C.CString(assetID)
|
||||
defer C.free(unsafe.Pointer(cid))
|
||||
cdir := C.CString(outputDir)
|
||||
defer C.free(unsafe.Pointer(cdir))
|
||||
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index), C.int(slotIndex))
|
||||
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(quality), C.int(index), C.int(slotIndex))
|
||||
if cs == nil {
|
||||
return ExportResult{}, errBridgeNil
|
||||
}
|
||||
@@ -132,8 +140,31 @@ type ExportProgressSlot struct {
|
||||
}
|
||||
|
||||
func GetProgressSlots() []ExportProgressSlot {
|
||||
return nil
|
||||
count := int(C.photos_get_progress_slot_count())
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
cSlots := C.photos_get_progress_slots()
|
||||
if cSlots == nil {
|
||||
return nil
|
||||
}
|
||||
slots := make([]ExportProgressSlot, count)
|
||||
for i := 0; i < count; i++ {
|
||||
s := C.photos_get_progress_slot(cSlots, C.int(i))
|
||||
slots[i] = ExportProgressSlot{
|
||||
Active: s.active != 0,
|
||||
Progress: float64(s.progress),
|
||||
BytesDone: int64(s.bytes_done),
|
||||
BytesTotal: int64(s.bytes_total),
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
func ResetProgressSlots() {
|
||||
C.photos_reset_progress_slots()
|
||||
}
|
||||
|
||||
func GetProgressSlotCount() int {
|
||||
return int(C.photos_get_progress_slot_count())
|
||||
}
|
||||
|
||||
@@ -5,4 +5,4 @@ package photos
|
||||
import "fmt"
|
||||
|
||||
var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security")
|
||||
var errBridgeNil = fmt.Errorf("bridge returned nil")
|
||||
var errBridgeNil = fmt.Errorf("bridge returned nil")
|
||||
|
||||
+266
-25
@@ -35,9 +35,9 @@ func TestParseAlbumsJSON(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing albums key",
|
||||
json: `{}`,
|
||||
want: []Album{},
|
||||
name: "missing albums key",
|
||||
json: `{}`,
|
||||
want: []Album{},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -66,23 +66,23 @@ func TestParseAlbumsJSON(t *testing.T) {
|
||||
|
||||
func TestParseAssetsJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want []Asset
|
||||
name string
|
||||
json string
|
||||
want []Asset
|
||||
wantTotal int
|
||||
wantErr bool
|
||||
errMsg string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "empty assets",
|
||||
json: `{"assets":[],"total":0}`,
|
||||
want: []Asset{},
|
||||
name: "empty assets",
|
||||
json: `{"assets":[],"total":0}`,
|
||||
want: []Asset{},
|
||||
wantTotal: 0,
|
||||
},
|
||||
{
|
||||
name: "single asset",
|
||||
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
|
||||
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
|
||||
name: "single asset",
|
||||
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
|
||||
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
|
||||
wantTotal: 1,
|
||||
},
|
||||
{
|
||||
@@ -106,9 +106,9 @@ func TestParseAssetsJSON(t *testing.T) {
|
||||
wantTotal: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple assets",
|
||||
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`,
|
||||
want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
|
||||
name: "multiple assets",
|
||||
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`,
|
||||
want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
|
||||
wantTotal: 3,
|
||||
},
|
||||
{
|
||||
@@ -132,9 +132,9 @@ func TestParseAssetsJSON(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty error is not an error",
|
||||
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
|
||||
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
|
||||
name: "empty error is not an error",
|
||||
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
|
||||
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
|
||||
wantTotal: 1,
|
||||
},
|
||||
}
|
||||
@@ -407,11 +407,11 @@ func TestErrBridgeNilMessage(t *testing.T) {
|
||||
|
||||
func TestParseExportResultJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want ExportResult
|
||||
wantErr bool
|
||||
errMsg string
|
||||
name string
|
||||
json string
|
||||
want ExportResult
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
@@ -488,3 +488,244 @@ func equalAsset(a, b Asset) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewViaStub(t *testing.T) {
|
||||
SetTestExportPreviewJSON(`{"filename":"0001_img.jpg","size":2048,"cloud":"local"}`)
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filename != "0001_img.jpg" {
|
||||
t.Errorf("got filename %q, want %q", result.Filename, "0001_img.jpg")
|
||||
}
|
||||
if result.Size != 2048 {
|
||||
t.Errorf("got size %d, want %d", result.Size, 2048)
|
||||
}
|
||||
if result.Cloud != "local" {
|
||||
t.Errorf("got cloud %q, want %q", result.Cloud, "local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewWithSlotViaStub(t *testing.T) {
|
||||
SetTestExportPreviewJSON(`{"filename":"slot_img.jpg","size":4096,"cloud":"cloud"}`)
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportPreviewWithSlot("asset-1", "/tmp", 2048, 85, 0, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filename != "slot_img.jpg" {
|
||||
t.Errorf("got filename %q", result.Filename)
|
||||
}
|
||||
if result.Cloud != "cloud" {
|
||||
t.Errorf("got cloud %q, want %q", result.Cloud, "cloud")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalViaStub(t *testing.T) {
|
||||
SetTestExportOriginalJSON(`{"filename":"original.jpg","size":8192,"cloud":"local"}`)
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filename != "original.jpg" {
|
||||
t.Errorf("got filename %q, want %q", result.Filename, "original.jpg")
|
||||
}
|
||||
if result.Size != 8192 {
|
||||
t.Errorf("got size %d, want %d", result.Size, 8192)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalWithSlotViaStub(t *testing.T) {
|
||||
SetTestExportOriginalJSON(`{"filename":"slot_orig.heic","size":16384,"cloud":"local"}`)
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportOriginalWithSlot("asset-1", "/tmp", 0, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Filename != "slot_orig.heic" {
|
||||
t.Errorf("got filename %q", result.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewErrorViaStub(t *testing.T) {
|
||||
SetTestExportPreviewJSON(`{"error":"disk full","cloud":"local"}`)
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err.Error() != "disk full" {
|
||||
t.Errorf("got error %q, want %q", err.Error(), "disk full")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalErrorViaStub(t *testing.T) {
|
||||
SetTestExportOriginalJSON(`{"error":"write failed","cloud":"local"}`)
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err.Error() != "write failed" {
|
||||
t.Errorf("got error %q, want %q", err.Error(), "write failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportSkippedResult(t *testing.T) {
|
||||
SetTestExportPreviewJSON(`{"filename":"skip.jpg","size":0,"cloud":"local","skipped":true}`)
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
result, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Skipped {
|
||||
t.Error("expected Skipped to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeCancelAndIsCancelled(t *testing.T) {
|
||||
bridge := &CgoBridge{}
|
||||
if bridge.IsCancelled() {
|
||||
t.Error("should not be cancelled initially")
|
||||
}
|
||||
bridge.Cancel()
|
||||
if !bridge.IsCancelled() {
|
||||
t.Error("should be cancelled after Cancel()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressSlotsReturnsSlots(t *testing.T) {
|
||||
slots := GetProgressSlots()
|
||||
if slots == nil {
|
||||
t.Errorf("GetProgressSlots should return slots in test build")
|
||||
}
|
||||
if len(slots) != 16 {
|
||||
t.Errorf("GetProgressSlots should return 16 slots, got %d", len(slots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetProgressSlotsNoPanic(t *testing.T) {
|
||||
ResetProgressSlots()
|
||||
}
|
||||
|
||||
func TestGetProgressSlotCount(t *testing.T) {
|
||||
count := GetProgressSlotCount()
|
||||
if count != 16 {
|
||||
t.Errorf("expected 16, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewNilStub(t *testing.T) {
|
||||
SetTestExportPreviewJSONNull()
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportPreview("asset-1", "/tmp", 1024, 85, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected errBridgeNil")
|
||||
}
|
||||
if err != errBridgeNil {
|
||||
t.Errorf("got %v, want %v", err, errBridgeNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalNilStub(t *testing.T) {
|
||||
SetTestExportOriginalJSONNull()
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportOriginal("asset-1", "/tmp", 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected errBridgeNil")
|
||||
}
|
||||
if err != errBridgeNil {
|
||||
t.Errorf("got %v, want %v", err, errBridgeNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportPreviewWithSlotNilStub(t *testing.T) {
|
||||
SetTestExportPreviewJSONNull()
|
||||
defer SetTestExportPreviewJSON(`{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportPreviewWithSlot("asset-1", "/tmp", 1024, 85, 0, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected errBridgeNil")
|
||||
}
|
||||
if err != errBridgeNil {
|
||||
t.Errorf("got %v, want %v", err, errBridgeNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgoBridgeExportOriginalWithSlotNilStub(t *testing.T) {
|
||||
SetTestExportOriginalJSONNull()
|
||||
defer SetTestExportOriginalJSON(`{"filename":"test.jpg","size":2048,"cloud":"cloud"}`)
|
||||
bridge := &CgoBridge{}
|
||||
_, err := bridge.ExportOriginalWithSlot("asset-1", "/tmp", 0, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected errBridgeNil")
|
||||
}
|
||||
if err != errBridgeNil {
|
||||
t.Errorf("got %v, want %v", err, errBridgeNil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressSlotsWithActiveSlot(t *testing.T) {
|
||||
ResetProgressSlots()
|
||||
slots := GetProgressSlots()
|
||||
if len(slots) != 16 {
|
||||
t.Errorf("expected 16 slots, got %d", len(slots))
|
||||
}
|
||||
for i, s := range slots {
|
||||
if s.Active {
|
||||
t.Errorf("slot %d should not be active after reset", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetProgressSlotsClearsState(t *testing.T) {
|
||||
ResetProgressSlots()
|
||||
slots := GetProgressSlots()
|
||||
if len(slots) > 0 && slots[0].Active {
|
||||
t.Errorf("slot should be inactive after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressSlotsZeroCount(t *testing.T) {
|
||||
SetTestProgressSlotCount(0)
|
||||
defer SetTestProgressSlotCount(3)
|
||||
slots := GetProgressSlots()
|
||||
if slots != nil {
|
||||
t.Errorf("expected nil with zero count, got %v", slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressSlotsNullPointer(t *testing.T) {
|
||||
SetTestProgressSlotsNull(1)
|
||||
defer SetTestProgressSlotsNull(0)
|
||||
slots := GetProgressSlots()
|
||||
if slots != nil {
|
||||
t.Errorf("expected nil with null pointer, got %v", slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportProgressSlotType(t *testing.T) {
|
||||
slot := ExportProgressSlot{
|
||||
Active: true,
|
||||
Progress: 0.75,
|
||||
BytesDone: 512,
|
||||
BytesTotal: 1024,
|
||||
}
|
||||
if !slot.Active {
|
||||
t.Error("expected Active to be true")
|
||||
}
|
||||
if slot.Progress != 0.75 {
|
||||
t.Errorf("expected Progress 0.75, got %f", slot.Progress)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -6,17 +6,17 @@ type Album struct {
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Cloud string `json:"cloud"`
|
||||
MediaType string `json:"mediaType"`
|
||||
PixelWidth int `json:"pixelWidth"`
|
||||
PixelHeight int `json:"pixelHeight"`
|
||||
CreationDate *string `json:"creationDate,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsFavorite bool `json:"isFavorite,omitempty"`
|
||||
HasAdjustments bool `json:"hasAdjustments,omitempty"`
|
||||
Resources []AssetResource `json:"resources,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Cloud string `json:"cloud"`
|
||||
MediaType string `json:"mediaType"`
|
||||
PixelWidth int `json:"pixelWidth"`
|
||||
PixelHeight int `json:"pixelHeight"`
|
||||
CreationDate *string `json:"creationDate,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsFavorite bool `json:"isFavorite,omitempty"`
|
||||
HasAdjustments bool `json:"hasAdjustments,omitempty"`
|
||||
Resources []AssetResource `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
type AssetResource struct {
|
||||
|
||||
Reference in New Issue
Block a user