diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 0658d2b..903e66a 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -33,6 +33,9 @@ type Bookmark struct { // 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. @@ -96,6 +99,14 @@ func (b Bookmark) Initial() string { 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" @@ -119,6 +130,7 @@ CREATE TABLE IF NOT EXISTS bookmarks ( latest_chapter_num REAL, latest_checked_at INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'reading', + kind TEXT NOT NULL DEFAULT 'manga', 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 // 'reading', so there is no separate migration step. {"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, 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. type Store struct { @@ -296,7 +311,7 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) { if err := scan( &b.Key, &b.Site, &b.SeriesID, &title, &seriesURL, &cover, &lastChapter, &lastChapterNum, &lastChapterURL, - &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, + &favorite, &latestChapter, &latestChapterNum, &b.UpdatedAt, &status, &b.Kind, ); err != nil { 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 // 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. + // 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. if _, err := tx.Exec(` INSERT INTO bookmarks (`+bookmarkColumns+`) 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 site=excluded.site, series_id=excluded.series_id, title=excluded.title, 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_num=excluded.latest_chapter_num, status=excluded.status, + kind=excluded.kind, updated_at=CASE WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num 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.LastChapter, b.LastChapterNum, b.LastChapterURL, 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) } diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 0653b01..7a29188 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -660,3 +660,104 @@ func TestDisplayChapter(t *testing.T) { 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) + } +}