Add feed/reader mode with full-article capture at ingest
Reader mode presents the stories on a page one at a time in a focused overlay, marking each read as it's shown. Left/right arrows (or the header book button / `f`) page through them; read stories dim on the grid. Read state is device-local in localStorage. Backing this required actually capturing article bodies, which Pete wasn't doing — it kept only the RSS <description> lede and discarded content:encoded: - stories.content column (idempotent migration; old rows fall back to lede) - parser keeps content:encoded as paragraph-preserving text - article fetch already done for paywall detection now also returns its body, so ingest stores the richer of feed-content vs scraped body with no extra request (prefers the archive snapshot body for paywalled stories) - GET /api/article?id= serves the stored text; card queries now select id and expose it as data-id for the reader Tests cover content extraction, the storage round-trip, and the article endpoint + card rendering end to end.
This commit is contained in:
@@ -61,6 +61,67 @@ func TestInsertStoryAndGUIDSeen(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStoryReaderText(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
s := &Story{
|
||||
GUID: "reader-guid",
|
||||
Headline: "Reader Headline",
|
||||
Lede: "Short lede.",
|
||||
Content: "First paragraph.\n\nSecond paragraph.",
|
||||
ArticleURL: "https://example.com/reader",
|
||||
Source: "Src",
|
||||
SeenAt: 1700000000,
|
||||
}
|
||||
if err := InsertStory(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var id int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, s.GUID).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, lede, found, err := GetStoryReaderText(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected story to be found")
|
||||
}
|
||||
if content != s.Content {
|
||||
t.Errorf("content = %q, want %q", content, s.Content)
|
||||
}
|
||||
if lede != s.Lede {
|
||||
t.Errorf("lede = %q, want %q", lede, s.Lede)
|
||||
}
|
||||
|
||||
// A story ingested before content capture (no content) is still found, with
|
||||
// an empty content string so the reader falls back to the lede.
|
||||
bare := &Story{GUID: "bare-guid", Headline: "H", Lede: "Only a lede.", ArticleURL: "https://a.com", Source: "S", SeenAt: 1}
|
||||
if err := InsertStory(bare); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var bareID int64
|
||||
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, bare.GUID).Scan(&bareID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, lede, found, err = GetStoryReaderText(bareID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("bare story: found=%v err=%v", found, err)
|
||||
}
|
||||
if content != "" {
|
||||
t.Errorf("bare content = %q, want empty", content)
|
||||
}
|
||||
if lede != "Only a lede." {
|
||||
t.Errorf("bare lede = %q", lede)
|
||||
}
|
||||
|
||||
// Unknown id reports not-found rather than erroring.
|
||||
if _, _, found, err = GetStoryReaderText(999999); err != nil || found {
|
||||
t.Errorf("unknown id: found=%v err=%v, want found=false err=nil", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertStoryDuplicateGUID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user