package store import ( "database/sql" "embed" "errors" "fmt" "io/fs" "path" "regexp" "slices" "strconv" "strings" _ "github.com/jackc/pgx/v5/stdlib" ) // 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"` // Kind is the library bucket: manga or novel. Empty on the way in means // "no opinion" — see Upsert. Kind string `json:"kind"` } // 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 } // chapterLeadIn matches the prefix the userscript and the poller both write // ("Chapter 250"), so the UI can add exactly one "Ch " of its own instead of // doubling it. A manual edit through the web UI stores a bare "250", which is // the same string minus the lead-in. var chapterLeadIn = regexp.MustCompile(`(?i)^\s*(?:chapter|ch\.?)\s*`) func displayChapter(raw string, num float64) string { rest := strings.TrimSpace(chapterLeadIn.ReplaceAllString(raw, "")) if rest == "" { rest = strconv.FormatFloat(num, 'f', -1, 64) } // "Ch " only makes sense in front of a number; anything else is a label the // site gave us, so pass it through as written. if rest[0] < '0' || rest[0] > '9' { return rest } return "Ch " + rest } // DisplayChapter is the read-progress line: one canonical "Ch N" whatever // format the write came in as. func (b Bookmark) DisplayChapter() string { return displayChapter(b.LastChapter, b.LastChapterNum) } // DisplayLatest is the same for the newest published chapter, which arrives // with the same "Chapter N" lead-in from both the userscript and the poller. func (b Bookmark) DisplayLatest() string { var num float64 if b.LatestChapterNum != nil { num = *b.LatestChapterNum } return displayChapter(b.LatestChapter, num) } // 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 } // Initial is the monogram the web UI shows in place of a cover when the // source site never gave us an og:image. First rune, uppercased; "?" when even // the title is missing, so the slot is never empty. func (b Bookmark) Initial() string { for _, r := range b.Title { return strings.ToUpper(string(r)) } return "?" } // Library buckets. A bookmark is in exactly one. This cannot be derived from // Site: asurascans serves manga and novels from the same /comics/ path, so the // userscript that recorded the page is the only party that knows which. const ( KindManga = "manga" KindNovel = "novel" ) // Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal. const ( StatusReading = "reading" StatusArchived = "archived" StatusFinished = "finished" ) //go:embed migrations/*.sql var migrations embed.FS // bookmarkColumns is the only value ever concatenated into query text. It is a // compile-time constant; every request value is bound as a parameter. 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, kind` // Store is the Postgres-backed bookmark store. type Store struct { db *sql.DB } // Open connects to Postgres at url — a libpq connection URL such as // "postgres://user:pass@host:5432/bookmarks?sslmode=disable" — and brings its // schema up to date. func Open(url string) (*Store, error) { db, err := sql.Open("pgx", url) if err != nil { return nil, fmt.Errorf("open postgres: %w", err) } if err := migrate(db); err != nil { db.Close() return nil, fmt.Errorf("migrate: %w", err) } return &Store{db: db}, nil } // migrate applies every embedded migration this database has not recorded, in // filename order, each in its own transaction. Files are named // "_.sql" and are append-only: editing an applied file changes // nothing, because schema_migrations is how a database remembers what it ran. // Runs on every start and is a no-op once current. func migrate(db *sql.DB) error { if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( version bigint PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { return fmt.Errorf("create version table: %w", err) } names, err := fs.Glob(migrations, "migrations/*.sql") if err != nil { return err } slices.Sort(names) for _, name := range names { version, err := strconv.ParseInt(strings.SplitN(path.Base(name), "_", 2)[0], 10, 64) if err != nil { return fmt.Errorf("migration %q: filename must start with a version number", name) } body, err := migrations.ReadFile(name) if err != nil { return err } if err := applyMigration(db, version, string(body)); err != nil { return fmt.Errorf("migration %q: %w", name, err) } } return nil } // applyMigration runs one migration and records its version in the same // transaction, so an interrupted start leaves neither half behind. func applyMigration(db *sql.DB, version int64, body string) error { tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() var applied bool if err := tx.QueryRow( `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil { return err } if applied { return nil } // No parameters, so this goes over the simple protocol and a migration may // hold more than one statement. if _, err := tx.Exec(body); err != nil { return err } if _, err := tx.Exec(`INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { return err } return tx.Commit() } // scanBookmark reads one row in bookmarkColumns order. Every column is NOT // NULL except latest_chapter_num, where NULL means "never captured" — a // distinct state from chapter zero, and the reason for the pointer. func scanBookmark(scan func(...any) error) (Bookmark, error) { var ( b Bookmark latestChapterNum sql.NullFloat64 ) if err := scan( &b.Key, &b.Site, &b.SeriesID, &b.Title, &b.SeriesURL, &b.Cover, &b.LastChapter, &b.LastChapterNum, &b.LastChapterURL, &b.Favorite, &b.LatestChapter, &latestChapterNum, &b.UpdatedAt, &b.Status, &b.Kind, ); err != nil { return Bookmark{}, err } if latestChapterNum.Valid { b.LatestChapterNum = &latestChapterNum.Float64 } // An unrecognised bucket (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. 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 = $1`, 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 DISTINCT FROM is Postgres's null-safe comparison, and it is what // implements the ordering rule. 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 and kind columns resolve 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' / 'manga' and would overwrite an archived or novel row on // every PUT from a client that knows nothing about the column. Resolved // once here, an empty incoming status or kind 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. // // The ::text casts are load-bearing: inside COALESCE/NULLIF there is no // target column to infer the parameter type from, and Postgres rejects the // statement rather than guessing. if _, err := tx.Exec(` INSERT INTO bookmarks (`+bookmarkColumns+`) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, COALESCE(NULLIF($14::text, ''), (SELECT status FROM bookmarks WHERE key = $1), 'reading'), COALESCE(NULLIF($15::text, ''), (SELECT kind FROM bookmarks WHERE key = $1), 'manga')) 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, kind=excluded.kind, updated_at=CASE WHEN bookmarks.last_chapter_num IS DISTINCT FROM 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.Kind); err != nil { return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err) } stored, err := scanBookmark(tx.QueryRow( `SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = $1`, 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 = $1`, 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 <> '' AND status IS DISTINCT FROM 'finished' AND latest_checked_at <= $1 ORDER BY latest_checked_at ASC LIMIT $2`, 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 = $1 WHERE key = $2`, ts, key); err != nil { return fmt.Errorf("mark checked %q: %w", key, err) } return nil } // LatestCheckedAt reads the column MarkLatestChecked writes. It exists for // tests outside this package (the poller's own tests assert on cooldown // bookkeeping) — see MarkLatestChecked for why the field itself stays off // Bookmark. func (s *Store) LatestCheckedAt(key string) (int64, error) { var ts int64 if err := s.db.QueryRow( `SELECT latest_checked_at FROM bookmarks WHERE key = $1`, key).Scan(&ts); err != nil { return 0, fmt.Errorf("latest checked at %q: %w", key, err) } return ts, nil }