A multi-session build turning Pete's read-only web UI into something people return to. Five phases, signed-in features keyed off the OIDC subject; anonymous visitors keep the reverse-chron feed and localStorage-only state. Phase 1 — per-user read + bookmark state: user_story_state table + storage/userstate.go; auth-gated /api/read, /api/bookmark, /api/state and a /bookmarks page; reader.js syncs state server-side for signed-in users. Also hides the Matrix-posting UI when posting.enabled=false (web-only mode). Phase 2 — outbound feeds: storage.ListForFeed + web/feed.go hand-build RSS 2.0 (content:encoded) and JSON Feed 1.1 (no new dep); /feed.xml, /feed.json and per-channel variants; <link rel=alternate> discovery tags. Phase 3 — "For you" + related: storage/rank.go scores recent unread candidates by channel/source affinity + recency decay; RelatedStories via FTS5. ForYou rail + /for-you page; public /api/related feeds the reader's "You might also like". Phase 4 — source-health dashboard: source_health table + storage/sourcehealth.go (RecordPollResult, ListSourceHealth, SourceContentStats), written by the poller; admin-gated /status page behind web.admin_subs. Phase 5 — PWA + offline reader + Web Push: root-scoped manifest.webmanifest and sw.js (app-shell precache, /api/article runtime cache for offline reading, offline fallback, push/notificationclick handlers); PNG icons from pete.avif; pwa.js registers the SW and drives a notifications toggle. Web Push adds webpush-go, a [web.push] config block (pete -genvapid mints VAPID keys), a push_subscriptions table, auth-gated subscribe/unsubscribe endpoints, and a digest sender that pings each subscriber "N new stories" past their watermark, honoring disabled-sources and pruning gone endpoints. Tests added beside each new storage/web file; go test ./... and go vet clean.
140 lines
4.2 KiB
Go
140 lines
4.2 KiB
Go
package storage
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// SetRead marks (read=true) or clears (read=false) the read flag for one story
|
|
// and one signed-in user (keyed by OIDC subject). Read and bookmark state share
|
|
// a row; clearing the last remaining flag removes the row.
|
|
func SetRead(sub string, storyID int64, read bool) error {
|
|
var ts any
|
|
if read {
|
|
ts = nowUnix()
|
|
}
|
|
_, err := Get().Exec(`
|
|
INSERT INTO user_story_state (user_sub, story_id, read_at, bookmarked_at)
|
|
VALUES (?, ?, ?, NULL)
|
|
ON CONFLICT(user_sub, story_id) DO UPDATE SET read_at = excluded.read_at`,
|
|
sub, storyID, ts)
|
|
if err != nil {
|
|
return fmt.Errorf("set read: %w", err)
|
|
}
|
|
return pruneEmptyState(sub, storyID)
|
|
}
|
|
|
|
// SetBookmark adds (on=true) or removes (on=false) a bookmark for one story and
|
|
// one signed-in user. See SetRead for the shared-row semantics.
|
|
func SetBookmark(sub string, storyID int64, on bool) error {
|
|
var ts any
|
|
if on {
|
|
ts = nowUnix()
|
|
}
|
|
_, err := Get().Exec(`
|
|
INSERT INTO user_story_state (user_sub, story_id, read_at, bookmarked_at)
|
|
VALUES (?, ?, NULL, ?)
|
|
ON CONFLICT(user_sub, story_id) DO UPDATE SET bookmarked_at = excluded.bookmarked_at`,
|
|
sub, storyID, ts)
|
|
if err != nil {
|
|
return fmt.Errorf("set bookmark: %w", err)
|
|
}
|
|
return pruneEmptyState(sub, storyID)
|
|
}
|
|
|
|
// pruneEmptyState drops a row once neither flag is set, keeping the table to
|
|
// only meaningful state.
|
|
func pruneEmptyState(sub string, storyID int64) error {
|
|
_, err := Get().Exec(
|
|
`DELETE FROM user_story_state
|
|
WHERE user_sub = ? AND story_id = ? AND read_at IS NULL AND bookmarked_at IS NULL`,
|
|
sub, storyID)
|
|
if err != nil {
|
|
return fmt.Errorf("prune user state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UserStoryState reports, for the given story ids, which are read and which are
|
|
// bookmarked by this user. Both maps contain only ids whose flag is set, so a
|
|
// missing key means false. An empty sub or id list returns empty maps.
|
|
func UserStoryState(sub string, ids []int64) (read, bookmarked map[int64]bool, err error) {
|
|
read = make(map[int64]bool)
|
|
bookmarked = make(map[int64]bool)
|
|
if sub == "" || len(ids) == 0 {
|
|
return read, bookmarked, nil
|
|
}
|
|
q := `SELECT story_id, read_at, bookmarked_at FROM user_story_state
|
|
WHERE user_sub = ? AND story_id IN (` + placeholders(len(ids)) + `)`
|
|
args := make([]any, 0, len(ids)+1)
|
|
args = append(args, sub)
|
|
for _, id := range ids {
|
|
args = append(args, id)
|
|
}
|
|
rows, err := Get().Query(q, args...)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("user story state: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var id int64
|
|
var r, b sql.NullInt64
|
|
if err := rows.Scan(&id, &r, &b); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if r.Valid {
|
|
read[id] = true
|
|
}
|
|
if b.Valid {
|
|
bookmarked[id] = true
|
|
}
|
|
}
|
|
return read, bookmarked, rows.Err()
|
|
}
|
|
|
|
// ListBookmarks returns the user's bookmarked stories, most recently bookmarked
|
|
// first, restricted to still-classified stories.
|
|
func ListBookmarks(sub string, limit, offset int) ([]Story, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT s.id, s.guid, s.headline, s.lede, s.image_url, s.article_url, s.source, s.platforms, s.channel, s.paywalled, s.seen_at,
|
|
EXISTS(SELECT 1 FROM post_log p WHERE p.guid = s.guid) AS posted
|
|
FROM user_story_state u
|
|
JOIN stories s ON s.id = u.story_id
|
|
WHERE u.user_sub = ?
|
|
AND u.bookmarked_at IS NOT NULL
|
|
AND s.classified = 1
|
|
ORDER BY u.bookmarked_at DESC, u.story_id DESC
|
|
LIMIT ? OFFSET ?`, sub, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Story
|
|
for rows.Next() {
|
|
var s Story
|
|
if err := rows.Scan(&s.ID, &s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.Paywalled, &s.SeenAt, &s.Posted); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// CountBookmarks returns how many stories the user has bookmarked.
|
|
func CountBookmarks(sub string) (int, error) {
|
|
var n int
|
|
err := Get().QueryRow(
|
|
`SELECT COUNT(*) FROM user_story_state WHERE user_sub = ? AND bookmarked_at IS NOT NULL`,
|
|
sub).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// placeholders returns "?, ?, …" with n slots for an IN clause.
|
|
func placeholders(n int) string {
|
|
if n <= 0 {
|
|
return ""
|
|
}
|
|
return strings.Repeat("?, ", n-1) + "?"
|
|
}
|