package main import ( "database/sql" "errors" "fmt" "strings" _ "modernc.org/sqlite" ) // Bookmark is one tracked series, keyed ":" across both sites. // // LastChapter* is the user's read progress; LatestChapter* is the newest // chapter the site has published, captured opportunistically by the userscript. 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"` Favorite bool `json:"favorite"` LatestChapter string `json:"latest_chapter"` LatestChapterNum *float64 `json:"latest_chapter_num"` // nil until first captured UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert } // HasNewChapter reports whether the site has published past the read point. // A nil LatestChapterNum means nothing has been captured yet, which is not the // same as "nothing new". func (b Bookmark) HasNewChapter() bool { return b.LatestChapterNum != nil && *b.LatestChapterNum > b.LastChapterNum } // ContinueURL is where the Continue button points: the chapter last read, or // the series page when no chapter URL was ever captured. func (b Bookmark) ContinueURL() string { if b.LastChapterURL != "" { return b.LastChapterURL } return b.SeriesURL } 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, favorite INTEGER NOT NULL DEFAULT 0, latest_chapter TEXT NOT NULL DEFAULT '', latest_chapter_num REAL, updated_at INTEGER NOT NULL );` // The columns above that databases created before them will be missing. // SQLite has no ADD COLUMN IF NOT EXISTS, so each is added only when absent. var addedColumns = []struct{ name, ddl string }{ {"favorite", `ALTER TABLE bookmarks ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0`}, {"latest_chapter", `ALTER TABLE bookmarks ADD COLUMN latest_chapter TEXT NOT NULL DEFAULT ''`}, {"latest_chapter_num", `ALTER TABLE bookmarks ADD COLUMN latest_chapter_num REAL`}, } const bookmarkColumns = `key, site, series_id, title, series_url, cover, last_chapter, last_chapter_num, last_chapter_url, favorite, latest_chapter, latest_chapter_num, updated_at` // 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) } if err := migrateColumns(db); err != nil { db.Close() return nil, fmt.Errorf("migrate schema: %w", err) } return &Store{db: db}, nil } // migrateColumns brings a pre-existing bookmarks table up to the current // schema. Safe to run on every start: columns already present are skipped. func migrateColumns(db *sql.DB) error { have, err := existingColumns(db, "bookmarks") if err != nil { return err } for _, c := range addedColumns { if _, ok := have[c.name]; ok { continue } if _, err := db.Exec(c.ddl); err != nil { return fmt.Errorf("add column %q: %w", c.name, err) } } return nil } func existingColumns(db *sql.DB, table string) (map[string]struct{}, error) { rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table) if err != nil { return nil, fmt.Errorf("read %s columns: %w", table, err) } defer rows.Close() out := map[string]struct{}{} for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, fmt.Errorf("scan column name: %w", err) } out[name] = struct{}{} } return out, rows.Err() } // scanBookmark reads one row in bookmarkColumns order, translating SQLite's // integer bool and nullable latest_chapter_num into Go types. // // The optional columns are read through Null* types because rows predating // this code (or written by hand) may hold NULL where the app only ever writes // zero values. Only latest_chapter_num distinguishes the two: everywhere else // NULL and the zero value mean the same thing to clients. func scanBookmark(scan func(...any) error) (Bookmark, error) { var ( b Bookmark title, seriesURL, cover sql.NullString lastChapter, lastChapterURL, latestChapter sql.NullString lastChapterNum, latestChapterNum sql.NullFloat64 favorite sql.NullInt64 ) if err := scan( &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &lastChapter, &lastChapterNum, &lastChapterURL, &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, ); err != nil { return Bookmark{}, err } b.Title = title.String b.SeriesURL = seriesURL.String b.Cover = cover.String b.LastChapter = lastChapter.String b.LastChapterNum = lastChapterNum.Float64 b.LastChapterURL = lastChapterURL.String b.Favorite = favorite.Int64 != 0 b.LatestChapter = latestChapter.String if latestChapterNum.Valid { b.LatestChapterNum = &latestChapterNum.Float64 } return b, 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 ` + bookmarkColumns + ` 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() { b, err := scanBookmark(rows.Scan) if err != nil { return nil, fmt.Errorf("scan bookmark: %w", err) } out = append(out, b) } return out, rows.Err() } // Get returns one bookmark by key. A missing key is not an error: ok is false // and err is nil. UI mutations read-modify-write through this so they preserve // the fields they do not touch. func (s *Store) Get(key string) (Bookmark, bool, error) { b, err := scanBookmark(s.db.QueryRow( `SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = ?`, key).Scan) if errors.Is(err, sql.ErrNoRows) { return Bookmark{}, false, nil } if err != nil { return Bookmark{}, false, fmt.Errorf("get %q: %w", key, err) } return b, true, nil } // Upsert inserts or replaces a bookmark by key (last-write-wins) and returns // the row as actually stored. // // b.UpdatedAt is only a candidate: it is applied when the row is new or when // last_chapter_num changes, and otherwise the stored value is kept. Clients // order their list by updated_at, so favoriting a series or recording a newly // published chapter must not disturb that order — only real reading progress // does. Callers must therefore use the returned bookmark, not the argument. func (s *Store) Upsert(b Bookmark) (Bookmark, error) { tx, err := s.db.Begin() if err != nil { return Bookmark{}, fmt.Errorf("begin %q: %w", b.Key, err) } defer tx.Rollback() var latestNum any if b.LatestChapterNum != nil { latestNum = *b.LatestChapterNum } // IS NOT is SQLite's null-safe comparison. Within DO UPDATE, a bare column // is the stored row and excluded.* is the incoming one; a brand-new key // never reaches this clause, so it keeps the fresh timestamp from VALUES. if _, err := tx.Exec(` INSERT INTO bookmarks (`+bookmarkColumns+`) 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, favorite=excluded.favorite, latest_chapter=excluded.latest_chapter, latest_chapter_num=excluded.latest_chapter_num, updated_at=CASE WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num THEN excluded.updated_at ELSE bookmarks.updated_at END`, b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.LastChapter, b.LastChapterNum, b.LastChapterURL, b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt); err != nil { return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err) } stored, err := scanBookmark(tx.QueryRow( `SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = ?`, b.Key).Scan) if err != nil { return Bookmark{}, fmt.Errorf("read back %q: %w", b.Key, err) } if err := tx.Commit(); err != nil { return Bookmark{}, fmt.Errorf("commit %q: %w", b.Key, err) } return stored, 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 }