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:
prosolis
2026-07-06 22:46:14 -07:00
parent 410f8dda0a
commit 55aa167151
17 changed files with 803 additions and 13 deletions

View File

@@ -1,6 +1,9 @@
package ingestion
import "testing"
import (
"strings"
"testing"
)
func TestExtractLede(t *testing.T) {
tests := []struct {
@@ -69,3 +72,26 @@ func TestExtractLede(t *testing.T) {
})
}
}
func TestExtractContentText(t *testing.T) {
if got := extractContentText(""); got != "" {
t.Errorf("empty input = %q, want empty", got)
}
if got := extractContentText(" \n "); got != "" {
t.Errorf("whitespace-only input = %q, want empty", got)
}
in := `<p>First paragraph with <a href="/x">a link</a> &amp; entity.</p>` +
`<p>Second line one.<br>line two.</p><ul><li>item</li></ul>`
got := extractContentText(in)
want := "First paragraph with a link & entity.\n\nSecond line one.\nline two.\n\nitem"
if got != want {
t.Errorf("extractContentText:\n got %q\n want %q", got, want)
}
// Output is capped so a runaway body can't bloat the row.
long := "<p>" + strings.Repeat("x", maxContentChars+500) + "</p>"
if n := len(extractContentText(long)); n > maxContentChars {
t.Errorf("capped length = %d, want <= %d", n, maxContentChars)
}
}