diff --git a/internal/storage/push.go b/internal/storage/push.go index 49b0efa..ed5c338 100644 --- a/internal/storage/push.go +++ b/internal/storage/push.go @@ -96,7 +96,7 @@ func NewClassifiedSince(sinceUnix int64, limit int) ([]Story, error) { rows, err := Get().Query( `SELECT id, headline, source, channel, seen_at FROM stories - WHERE classified = 1 AND channel <> '_discarded' AND seen_at > ? + WHERE classified = 1 AND channel NOT IN ('_discarded', '_duplicate') AND seen_at > ? ORDER BY seen_at DESC LIMIT ?`, sinceUnix, limit) if err != nil { diff --git a/internal/storage/userstate.go b/internal/storage/userstate.go index 72e0379..a360888 100644 --- a/internal/storage/userstate.go +++ b/internal/storage/userstate.go @@ -104,6 +104,7 @@ func ListBookmarks(sub string, limit, offset int) ([]Story, error) { WHERE u.user_sub = ? AND u.bookmarked_at IS NOT NULL AND s.classified = 1 + AND s.channel NOT IN ('_discarded', '_duplicate') ORDER BY u.bookmarked_at DESC, u.story_id DESC LIMIT ? OFFSET ?`, sub, limit, offset) if err != nil { @@ -129,7 +130,8 @@ func CountBookmarks(sub string) (int, error) { err := Get().QueryRow( `SELECT COUNT(*) 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`, + WHERE u.user_sub = ? AND u.bookmarked_at IS NOT NULL AND s.classified = 1 + AND s.channel NOT IN ('_discarded', '_duplicate')`, sub).Scan(&n) return n, err } diff --git a/internal/web/feed.go b/internal/web/feed.go index 2bd03b3..85030bf 100644 --- a/internal/web/feed.go +++ b/internal/web/feed.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "encoding/xml" + "html" "log/slog" "net/http" "strings" @@ -284,20 +285,8 @@ func contentToHTML(text string) string { continue } b.WriteString("

") - b.WriteString(strings.ReplaceAll(escapeHTMLText(para), "\n", "
")) + b.WriteString(strings.ReplaceAll(html.EscapeString(para), "\n", "
")) b.WriteString("

") } return b.String() } - -// escapeHTMLText escapes the five characters that matter inside HTML text so the -// article body can't inject markup into content:encoded. -func escapeHTMLText(s string) string { - return strings.NewReplacer( - "&", "&", - "<", "<", - ">", ">", - `"`, """, - "'", "'", - ).Replace(s) -} diff --git a/internal/web/push_sender.go b/internal/web/push_sender.go index c587913..7921d54 100644 --- a/internal/web/push_sender.go +++ b/internal/web/push_sender.go @@ -56,7 +56,6 @@ func (s *Server) sendDigests() { if len(subs) == 0 { return } - now := time.Now().Unix() // Disabled-source sets are per user; cache within a pass so a user with // several devices only parses prefs once. disabledByUser := make(map[string]map[string]bool) @@ -116,7 +115,11 @@ func (s *Server) sendDigests() { slog.Warn("push: send failed", "sub", sub.UserSub, "err", err) continue } - if derr := storage.TouchPushSubscription(sub.Endpoint, now); derr != nil { + // Advance to the newest story this digest actually accounted for, not a + // pass-start "now": the loop can run for minutes (one slow endpoint per + // send), so stories arriving mid-pass would otherwise be re-counted next + // pass. stories[0] is the newest in the scanned window (seen_at DESC). + if derr := storage.TouchPushSubscription(sub.Endpoint, stories[0].SeenAt); derr != nil { slog.Error("push: advance watermark failed", "err", derr) } sent++ @@ -133,7 +136,7 @@ func buildDigestPayload(count int, top string) []byte { body = "1 new story" } if top != "" { - body += " — " + top + body += ": " + top } b, _ := json.Marshal(map[string]string{ "title": "Pete has fresh news", diff --git a/internal/web/static/js/pwa.js b/internal/web/static/js/pwa.js index c3f18d1..320a09b 100644 --- a/internal/web/static/js/pwa.js +++ b/internal/web/static/js/pwa.js @@ -101,7 +101,7 @@ if (sub) return unsubscribe().then(function () { paint(false, "Notifications off."); }); return Notification.requestPermission().then(function (perm) { if (perm !== "granted") { paint(false, "Permission denied."); return; } - return subscribe().then(function () { paint(true, "You're all set — new stories will nudge you."); }); + return subscribe().then(function () { paint(true, "You're all set. New stories will nudge you."); }); }); }).catch(function () { paint(false, "Something went wrong. Try again."); diff --git a/internal/web/static/js/reader.js b/internal/web/static/js/reader.js index dfb538a..be0dd22 100644 --- a/internal/web/static/js/reader.js +++ b/internal/web/static/js/reader.js @@ -242,7 +242,7 @@ : ""; var href = safeURL(it.url); var note = '

' + - (it.paywalled ? "This source is paywalled — the text above may be partial. " : "") + + (it.paywalled ? "This source is paywalled, so the text above may be partial. " : "") + (href ? 'Read it at the source: ' + escapeHTML(it.source || "original article") + " ↗." : "") + "

"; diff --git a/internal/web/static/sw.js b/internal/web/static/sw.js index 74964f3..f3e8776 100644 --- a/internal/web/static/sw.js +++ b/internal/web/static/sw.js @@ -4,7 +4,7 @@ // // Bump CACHE_VERSION whenever the precached shell assets change; activate() // drops every cache that doesn't match the current version. -var CACHE_VERSION = "v2"; +var CACHE_VERSION = "v3"; var SHELL_CACHE = "pete-shell-" + CACHE_VERSION; var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION; @@ -77,7 +77,7 @@ function offlineFallback() { "
" + "
🦆
" + "

You're offline

" + - "

Pete can't reach the news right now. Articles you've already opened are still readable — head back and try one of those.

" + + "

Pete can't reach the news right now. Articles you've already opened are still readable, so head back and try one of those.

" + "
", { headers: { "Content-Type": "text/html; charset=utf-8" }, status: 503 } );