f58d113934
Backend (Go, stdlib net/http + modernc.org/sqlite, CGO-free static binary):
- GET/PUT/DELETE /bookmarks{,/key} + /healthz
- bearer auth (constant-time), CORS origin reflection + 204 preflight
- SQLite store keyed <site>:<series_id>, last-write-wins, server-set updated_at
- httptest + temp-sqlite tests (auth, CORS, round-trip); go vet clean
- multi-stage Dockerfile (distroless static nonroot) + compose (base + prod proxy override)
Userscript (single Bromite-compatible IIFE, no GM_* APIs):
- Asura + Demonic adapters, URL-regex ids + og: title/cover
- Shadow-DOM floating UI, localStorage cache, optimistic sync
- auto-progress (no regress) + manual override; framework-agnostic nav watcher
Live-verified adapters (Playwright, 2026-07-24): asurascans.com /comics/<slug-hash>,
demonicscans.org /manga/<slug> + /title/<slug>/chapter/<n> — corrects the plan's
assumed /series/ paths and asuracomic.net domain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// Bookmark is one tracked series, keyed "<site>:<series_id>" 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
|
|
}
|