package main import ( "database/sql" "fmt" "strings" _ "modernc.org/sqlite" ) // Bookmark is one tracked series, keyed ":" across both sites. type Bookmark struct { Key string `json:"key"` Site string `json:"site"` SeriesID string `json:"series_id"` Title string `json:"title"` SeriesURL string `json:"series_url"` Cover string `json:"cover"` LastChapter string `json:"last_chapter"` LastChapterNum float64 `json:"last_chapter_num"` LastChapterURL string `json:"last_chapter_url"` UpdatedAt int64 `json:"updated_at"` // unix ms, server-assigned } const schema = ` CREATE TABLE IF NOT EXISTS bookmarks ( key TEXT PRIMARY KEY, site TEXT NOT NULL, series_id TEXT NOT NULL, title TEXT, series_url TEXT, cover TEXT, last_chapter TEXT, last_chapter_num REAL, last_chapter_url TEXT, updated_at INTEGER NOT NULL );` // Store is the SQLite-backed bookmark store. type Store struct { db *sql.DB } // OpenStore opens (or creates) the SQLite database at path and applies the schema. func OpenStore(path string) (*Store, error) { // busy_timeout guards against SQLITE_BUSY under the reverse proxy's // concurrent requests; a single writer connection keeps writes serialized. dsn := path if !strings.Contains(dsn, "?") { dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)" } db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite %q: %w", path, err) } db.SetMaxOpenConns(1) if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("apply schema: %w", err) } return &Store{db: db}, nil } // Close releases the underlying database handle. func (s *Store) Close() error { return s.db.Close() } // List returns every bookmark, newest activity first. func (s *Store) List() ([]Bookmark, error) { rows, err := s.db.Query(` SELECT key, site, series_id, title, series_url, cover, last_chapter, last_chapter_num, last_chapter_url, updated_at FROM bookmarks ORDER BY updated_at DESC`) if err != nil { return nil, fmt.Errorf("query bookmarks: %w", err) } defer rows.Close() out := []Bookmark{} for rows.Next() { var b Bookmark if err := rows.Scan( &b.Key, &b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &b.Cover, &b.LastChapter, &b.LastChapterNum, &b.LastChapterURL, &b.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan bookmark: %w", err) } out = append(out, b) } return out, rows.Err() } // Upsert inserts or replaces a bookmark by key (last-write-wins). func (s *Store) Upsert(b Bookmark) error { _, err := s.db.Exec(` INSERT INTO bookmarks (key, site, series_id, title, series_url, cover, last_chapter, last_chapter_num, last_chapter_url, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET site=excluded.site, series_id=excluded.series_id, title=excluded.title, series_url=excluded.series_url, cover=excluded.cover, last_chapter=excluded.last_chapter, last_chapter_num=excluded.last_chapter_num, last_chapter_url=excluded.last_chapter_url, updated_at=excluded.updated_at`, b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.LastChapter, b.LastChapterNum, b.LastChapterURL, b.UpdatedAt) if err != nil { return fmt.Errorf("upsert %q: %w", b.Key, err) } return nil } // Delete removes a bookmark by key. Deleting a missing key is not an error. func (s *Store) Delete(key string) error { if _, err := s.db.Exec(`DELETE FROM bookmarks WHERE key = ?`, key); err != nil { return fmt.Errorf("delete %q: %w", key, err) } return nil }