diff --git a/backend/store.go b/backend/store.go index dc8a09a..7588509 100644 --- a/backend/store.go +++ b/backend/store.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "errors" "fmt" "strings" @@ -28,6 +29,22 @@ type Bookmark struct { UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert } +// 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 +} + const schema = ` CREATE TABLE IF NOT EXISTS bookmarks ( key TEXT PRIMARY KEY, @@ -182,6 +199,21 @@ func (s *Store) List() ([]Bookmark, error) { 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. // diff --git a/backend/store_test.go b/backend/store_test.go index 8c8b081..cc1bd99 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -34,6 +34,16 @@ func newTestServer(t *testing.T) http.Handler { return newRouter(store, testConfig()) } +func newTestStore(t *testing.T) *Store { + t.Helper() + store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + func auth(req *http.Request) *http.Request { req.Header.Set("Authorization", "Bearer "+testToken) return req @@ -433,3 +443,75 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) { } store2.Close() } + +func TestStoreGet(t *testing.T) { + store := newTestStore(t) + if _, err := store.Upsert(Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1000, + }); err != nil { + t.Fatalf("Upsert: %v", err) + } + + got, ok, err := store.Get("asura:solo") + if err != nil { + t.Fatalf("Get: %v", err) + } + if !ok { + t.Fatal("Get ok = false, want true") + } + if got.Title != "Solo Leveling" || got.LastChapterNum != 45 { + t.Fatalf("Get = %+v, want title/chapter preserved", got) + } +} + +func TestStoreGetMissing(t *testing.T) { + store := newTestStore(t) + _, ok, err := store.Get("asura:nope") + if err != nil { + t.Fatalf("Get missing returned error %v, want nil", err) + } + if ok { + t.Fatal("Get ok = true for missing key, want false") + } +} + +func TestBookmarkHasNewChapter(t *testing.T) { + num := func(f float64) *float64 { return &f } + cases := []struct { + name string + b Bookmark + want bool + }{ + {"latest ahead", Bookmark{LastChapterNum: 45, LatestChapterNum: num(47)}, true}, + {"latest equal", Bookmark{LastChapterNum: 45, LatestChapterNum: num(45)}, false}, + {"latest behind", Bookmark{LastChapterNum: 45, LatestChapterNum: num(44)}, false}, + {"latest unknown", Bookmark{LastChapterNum: 45}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.b.HasNewChapter(); got != tc.want { + t.Fatalf("HasNewChapter() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBookmarkContinueURL(t *testing.T) { + cases := []struct { + name string + b Bookmark + want string + }{ + {"chapter url present", Bookmark{LastChapterURL: "/ch/45", SeriesURL: "/series"}, "/ch/45"}, + {"falls back to series", Bookmark{SeriesURL: "/series"}, "/series"}, + {"both empty", Bookmark{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.b.ContinueURL(); got != tc.want { + t.Fatalf("ContinueURL() = %q, want %q", got, tc.want) + } + }) + } +}