feat(store): add kind column splitting manga and novel libraries

This commit is contained in:
2026-08-06 03:01:07 +07:00
parent 44a8df43e1
commit dcec12ae72
2 changed files with 132 additions and 12 deletions
+31 -12
View File
@@ -33,6 +33,9 @@ type Bookmark struct {
// Archived series stay polled for new chapters; finished ones do not. // Archived series stay polled for new chapters; finished ones do not.
// Empty on the way in means "no opinion" — see Upsert. // Empty on the way in means "no opinion" — see Upsert.
Status string `json:"status"` 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. // HasNewChapter reports whether the site has published past the read point.
@@ -96,6 +99,14 @@ func (b Bookmark) Initial() string {
return "?" 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. // Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
const ( const (
StatusReading = "reading" StatusReading = "reading"
@@ -119,6 +130,7 @@ CREATE TABLE IF NOT EXISTS bookmarks (
latest_chapter_num REAL, latest_chapter_num REAL,
latest_checked_at INTEGER NOT NULL DEFAULT 0, latest_checked_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'reading', status TEXT NOT NULL DEFAULT 'reading',
kind TEXT NOT NULL DEFAULT 'manga',
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
);` );`
@@ -135,11 +147,14 @@ var addedColumns = []struct{ name, ddl string }{
// Lifecycle bucket. The DEFAULT backfills every pre-existing row as // Lifecycle bucket. The DEFAULT backfills every pre-existing row as
// 'reading', so there is no separate migration step. // 'reading', so there is no separate migration step.
{"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`}, {"status", `ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'`},
// Library bucket. The DEFAULT backfills every pre-existing row as 'manga',
// which is what every row written before novels existed actually is.
{"kind", `ALTER TABLE bookmarks ADD COLUMN kind TEXT NOT NULL DEFAULT 'manga'`},
} }
const bookmarkColumns = `key, site, series_id, title, series_url, cover, const bookmarkColumns = `key, site, series_id, title, series_url, cover,
last_chapter, last_chapter_num, last_chapter_url, last_chapter, last_chapter_num, last_chapter_url,
favorite, latest_chapter, latest_chapter_num, updated_at, status` favorite, latest_chapter, latest_chapter_num, updated_at, status, kind`
// Store is the SQLite-backed bookmark store. // Store is the SQLite-backed bookmark store.
type Store struct { type Store struct {
@@ -296,7 +311,7 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
if err := scan( if err := scan(
&b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover,
&lastChapter, &lastChapterNum, &lastChapterURL, &lastChapter, &lastChapterNum, &lastChapterURL,
&favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, &b.Kind,
); err != nil { ); err != nil {
return Bookmark{}, err return Bookmark{}, err
} }
@@ -384,18 +399,20 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
// is the stored row and excluded.* is the incoming one; a brand-new key // 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. // never reaches this clause, so it keeps the fresh timestamp from VALUES.
// //
// The status column resolves on the VALUES side, not in the conflict // The status and kind columns resolve on the VALUES side, not in the
// clause: excluded.* is the row *after* these expressions are evaluated, // conflict clause: excluded.* is the row *after* these expressions are
// so a default applied there would look identical to a real 'reading' and // evaluated, so a default applied there would look identical to a real
// would overwrite an archived row on every PUT from a client that knows // 'reading' / 'manga' and would overwrite an archived or novel row on
// nothing about the column. Resolved once here, an empty incoming status // every PUT from a client that knows nothing about the column. Resolved
// means "keep what is stored", and only a brand-new row falls through to // once here, an empty incoming status or kind means "keep what is
// the literal default. The subquery runs inside this transaction, so it // stored", and only a brand-new row falls through to the literal
// sees the row this statement is about to conflict with. // default. The subquery runs inside this transaction, so it sees the
// row this statement is about to conflict with.
if _, err := tx.Exec(` if _, err := tx.Exec(`
INSERT INTO bookmarks (`+bookmarkColumns+`) INSERT INTO bookmarks (`+bookmarkColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')) COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading'),
COALESCE(NULLIF(?, ''), (SELECT kind FROM bookmarks WHERE key = ?), 'manga'))
ON CONFLICT(key) DO UPDATE SET ON CONFLICT(key) DO UPDATE SET
site=excluded.site, series_id=excluded.series_id, title=excluded.title, site=excluded.site, series_id=excluded.series_id, title=excluded.title,
series_url=excluded.series_url, cover=excluded.cover, series_url=excluded.series_url, cover=excluded.cover,
@@ -405,6 +422,7 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
latest_chapter=excluded.latest_chapter, latest_chapter=excluded.latest_chapter,
latest_chapter_num=excluded.latest_chapter_num, latest_chapter_num=excluded.latest_chapter_num,
status=excluded.status, status=excluded.status,
kind=excluded.kind,
updated_at=CASE updated_at=CASE
WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num
THEN excluded.updated_at THEN excluded.updated_at
@@ -413,7 +431,8 @@ func (s *Store) Upsert(b Bookmark) (Bookmark, error) {
b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover, b.Key, b.Site, b.SeriesID, b.Title, b.SeriesURL, b.Cover,
b.LastChapter, b.LastChapterNum, b.LastChapterURL, b.LastChapter, b.LastChapterNum, b.LastChapterURL,
b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt, b.Favorite, b.LatestChapter, latestNum, b.UpdatedAt,
b.Status, b.Key); err != nil { b.Status, b.Key,
b.Kind, b.Key); err != nil {
return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err) return Bookmark{}, fmt.Errorf("upsert %q: %w", b.Key, err)
} }
+101
View File
@@ -660,3 +660,104 @@ func TestDisplayChapter(t *testing.T) {
t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11") t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11")
} }
} }
func TestUpsertKindDefaultsToManga(t *testing.T) {
store := newTestStore(t)
got, err := store.Upsert(Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if got.Kind != KindManga {
t.Fatalf("Kind = %q, want %q", got.Kind, KindManga)
}
}
func TestUpsertKindRoundTrips(t *testing.T) {
store := newTestStore(t)
got, err := store.Upsert(Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Kind: KindNovel, UpdatedAt: 1000,
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if got.Kind != KindNovel {
t.Fatalf("Kind = %q, want %q", got.Kind, KindNovel)
}
}
// The real hazard: a client that predates the column sends no kind at all. That
// must keep the stored library, not silently demote a novel to manga.
func TestUpsertEmptyKindKeepsStoredValue(t *testing.T) {
store := newTestStore(t)
if _, err := store.Upsert(Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Kind: KindNovel, LastChapterNum: 10, UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed: %v", err)
}
got, err := store.Upsert(Bookmark{
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
SeriesID: "a-will-eternal", Kind: "", LastChapterNum: 11, UpdatedAt: 2000,
})
if err != nil {
t.Fatalf("Upsert: %v", err)
}
if got.Kind != KindNovel {
t.Fatalf("Kind = %q, want %q — an empty kind must not reset the library", got.Kind, KindNovel)
}
if got.LastChapterNum != 11 {
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
}
}
// A database created before this column exists must gain it, backfilled as
// manga, without losing anything.
func TestLegacyDatabaseGainsKindAsManga(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy.db")
legacy, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
if _, err := legacy.Exec(`
CREATE TABLE 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
)`); err != nil {
t.Fatalf("create legacy schema: %v", err)
}
if _, err := legacy.Exec(`
INSERT INTO bookmarks (key, site, series_id, title, updated_at)
VALUES ('asura:legacy', 'asura', 'legacy', 'Legacy Series', 123)`); err != nil {
t.Fatalf("seed legacy row: %v", err)
}
if err := legacy.Close(); err != nil {
t.Fatalf("close legacy db: %v", err)
}
store, err := Open(dbPath)
if err != nil {
t.Fatalf("Open on legacy db: %v", err)
}
t.Cleanup(func() { store.Close() })
list, err := store.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 || list[0].Kind != KindManga {
t.Fatalf("legacy row should backfill as manga, got %+v", list)
}
}