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 // Status is the lifecycle bucket: reading, archived, or finished. // Archived series stay polled for new chapters; finished ones do not. // Empty on the way in means "no opinion" — see Upsert. Status string `json:"status"` } // 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 } // Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal. const ( statusReading = "reading" statusArchived = "archived" statusFinished = "finished" ) 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, latest_checked_at INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'reading', 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`}, // When the server last looked at this series, unix ms; 0 means never, and // sorts first so a new bookmark is picked up on the next tick with no // special case. Deliberately NOT in bookmarkColumns — see MarkLatestChecked. {"latest_checked_at", `ALTER TABLE bookmarks ADD COLUMN latest_checked_at INTEGER NOT NULL DEFAULT 0`}, // Lifecycle bucket. The DEFAULT backfills every pre-existing row as // 'reading', so there is no separate migration step. {"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`}, } 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, status` // 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 status 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, &status, ); 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 } // A NULL, empty, or unrecognised bucket (e.g. a hand-edited row) would // leave the row in no list at all, so anything outside the three known // buckets reads as the default rather than being passed through. b.Status = status.String if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished { b.Status = statusReading } 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. // // The status column resolves on the VALUES side, not in the conflict // clause: excluded.* is the row *after* these expressions are evaluated, // so a default applied there would look identical to a real 'reading' and // would overwrite an archived row on every PUT from a client that knows // nothing about the column. Resolved once here, an empty incoming status // means "keep what is stored", and only a brand-new row falls through to // the literal default. The subquery runs inside this transaction, so it // sees the row this statement is about to conflict with. if _, err := tx.Exec(` INSERT INTO bookmarks (`+bookmarkColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')) 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, status=excluded.status, 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, b.Status, b.Key); 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 } // DueForLatestCheck returns bookmarks whose server-side latest-chapter check has // aged past cutoffMs, least-recently-checked first, at most limit of them. // // Oldest-first is what keeps the poller fair when the backlog outgrows its // throughput: the most neglected series is always next, so a large collection // refreshes uniformly slower rather than leaving a tail that never refreshes at // all. The userscript sorts its own queue the same way (L453). // // Bookmarks with no series_url are skipped — there is nothing to fetch, which // is the same filter the userscript applies at L452. // // Finished series are excluded: nothing more is coming, so fetching them only // burns requests. Archived ones are deliberately still polled — knowing what a // shelved series is up to is the whole reason for archiving instead of deleting. func (s *Store) DueForLatestCheck(cutoffMs int64, limit int) ([]Bookmark, error) { rows, err := s.db.Query(`SELECT `+bookmarkColumns+` FROM bookmarks WHERE series_url IS NOT NULL AND series_url <> '' AND status IS NOT 'finished' AND latest_checked_at <= ? ORDER BY latest_checked_at ASC LIMIT ?`, cutoffMs, limit) if err != nil { return nil, fmt.Errorf("query due bookmarks: %w", err) } defer rows.Close() out := []Bookmark{} for rows.Next() { b, err := scanBookmark(rows.Scan) if err != nil { return nil, fmt.Errorf("scan due bookmark: %w", err) } out = append(out, b) } return out, rows.Err() } // MarkLatestChecked records that the server looked at key at ts, whatever the // look turned up. Marking a missing key is not an error: the row may have been // deleted while a fetch was in flight. // // This is the one write that does not go through Upsert, and the column is kept // out of bookmarkColumns on purpose. PUT /bookmarks/{key} decodes a whole // Bookmark from the client and Upsert writes every column it knows about, so a // userscript PUT — which has no idea this field exists — would write a zero and // reset the cooldown, making the poller re-fetch that series every tick for as // long as the user kept reading it. func (s *Store) MarkLatestChecked(key string, ts int64) error { if _, err := s.db.Exec( `UPDATE bookmarks SET latest_checked_at = ? WHERE key = ?`, ts, key); err != nil { return fmt.Errorf("mark checked %q: %w", key, err) } return nil }