11 Commits

Author SHA1 Message Date
prosolis
8e0d6aff3e Adventure news: fix posting gates, digest counts, and section gating
- Don't post adventure beats when posting.enabled=false (PostNow ignores the flag)
- Keep the adventure channel out of the round-robin rotation so it can't steal bulletins from the digest
- no_push now retires a fact against the digest, not just the live post
- Default a missing occurred_at to now instead of the Unix epoch
- Keep protocol-relative image URLs on the guarded thumbnailer
- Make digest_hour=0 (midnight UTC) settable
- Quote the true window total in the digest, not the truncated slice
- Hide the Adventure section entirely when it's disabled
2026-07-11 08:07:40 -07:00
prosolis
9bf56cbb4e Adventure news: add zone_clear event type
gogobee now splits the realm's first-ever clear (zone_first, priority)
from a later repeat (zone_clear, bulletin) so a repeat is never filed as
a first-ever. Handle both:

- renderAdventure: zone_first keeps the "cleared for the very first
  time" headline; zone_clear gets the personal "{subject} clears {zone}"
  headline, sharing the lede. A tier fallback still handles a legacy
  zone_first that predates the split.
- advEventMeta: zone_clear -> "Zone cleared" (distinct from zone_first's
  "First clear"), so a repeat's permalink page and emblem aren't stamped
  "First clear".

The GUID contract is otherwise unchanged: prefix == event_type, so the
permalink label derives correctly. Test: TestRenderZoneTaxonomy.

Deploy this before the matching gogobee change — a zone_clear fact hits
an un-updated Pete as an unknown event_type (400).
2026-07-11 07:44:15 -07:00
prosolis
4c671fb410 Adventure section: ingest, permalink, digest, emblems, noindex
Pete's side of the Adventure news feed. Receives structured game-event
facts from gogobee, templates them in Pete's warm-reporter voice, and
publishes to a new /adventure section + live Matrix posts.

- adventure.go: bearer ingest + fact-guard + 13 event templates;
  /adventure/{guid} permalink (story.html); per-event SVG emblems at
  /adventure/art/{type}.svg (card image + og:image); NoPush suppresses
  the live Matrix post (cold-start backfill).
- adventure_digest.go: daily BULLETIN roundup at DigestHour (UTC);
  unposted-in-48h = bulletins; marks them digested; per-day ?digest= URL
  avoids canonical dedup.
- config AdventureConfig (enabled/ingest_token/channel/digest_hour);
  web.New takes the seam + a priority poster; started in main.
- adventure theme colors; thumbURL passes through local emblem paths;
  adventure pages are noindex (player-named; gap #5).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-11 00:53:59 -07:00
prosolis
0a723418ff Redo moon and clouds as procedural shaders in the GL engine
The baked atlas sprites (blurred-circle clouds, gradient moon with flat
maria blobs) read as cheap. Clouds are now built per-instance in the
particle fragment shader from fbm density with a flattened base and lit
tops; the moon renders in the sky shader with sphere shading, emboss
relief, maria and a halo. Stars skip the moon disc since the sky pass
draws first.
2026-07-08 00:19:55 -07:00
prosolis
9b20040b49 Rebuild weather effects on WebGL2 with new variants and a live demo page
The background weather is now GPU-rendered: one instanced-quad draw call
over a baked sprite atlas plus a fullscreen sky shader (fog, Saharan
haze, aurora, sun rays, storm gloom and lightning flash). The old
Canvas2D renderer stays as weather-2d.js and kicks in automatically
when WebGL2 is missing; weather.js is now a thin controller that owns
the toggle, prefs and the PeteWeather API.

Effect upgrades: shared wind with gust pulses leans the whole scene
together, rain gets depth, splash pops and velocity-aligned streaks,
storms grow procedural branched lightning bolts, snow mixes soft motes
with spinning six-arm crystals, clouds drift in two parallax layers,
clear nights get a moon with maria, twinkling stars and the occasional
shooting star, blossoms and leaves tumble with a faked 3D flip.

New variants: haze (Saharan calima), wind (autumn gusts with streak
lines), hail (bouncing stones with drizzle) and aurora. The /weather
demo page switches variant, intensity and phase in place without a
reload and shows the active renderer plus an FPS meter.
2026-07-07 23:53:45 -07:00
prosolis
e91b423b1a Focus reader scroll region on open so keys scroll the story
The reader overlay opened without moving focus, so Arrow-Up/Down and
PageUp/Down scrolled the page behind it until the user clicked into the
text. Make the scroll container focusable (tabindex=-1) and focus it on
open, and drop its focus outline.
2026-07-07 23:14:05 -07:00
prosolis
dbcb459908 Add server-side Piper read-aloud with voice picker
Reader read-aloud now streams neural WAV audio from a new POST /api/tts
endpoint that shells out to Piper, instead of the browser's Web Speech
voice. Each paragraph is synthesized on demand with the next one
prefetched during playback, keeping the existing highlight/scroll sync.

Voices are configured under [web.tts] (piper binary + voices_dir + a
labelled voice list) and exposed to the client as window.PETE_TTS; the
reader gets a Voice selector in the Aa menu, persisted per-device. Still
a signed-in-only perk and gated on auth.
2026-07-07 23:00:27 -07:00
prosolis
fceeb12ad5 Fix reader prefs popover ignoring the hidden attribute
The .pete-reader-typemenu class sets display:flex, which outranks the
UA [hidden]{display:none} rule, so toggling typeMenu.hidden never hid
the popover. Add an explicit [hidden] guard.
2026-07-07 22:48:06 -07:00
prosolis
74aa578a2d Precompute content_chars to drop per-render body scans
The N-min-read chip derived reading time via LENGTH(content) over the
full article-body TEXT column on every listing render. LENGTH can't use
an index, so SQLite read each row's whole body per request on the hottest
path. Cache the character count in a content_chars column filled at insert
time (backfilled for existing rows), and point StoryContentLengths at it.
2026-07-07 22:41:41 -07:00
prosolis
8f9fcc45f3 Fix reader read-aloud races and add rows.Err checks
- reader.js: guard read-aloud against the loading placeholder (bodyReady)
  and invalidate stale speechSynthesis callbacks with a generation token
- storage: check rows.Err() after iterating story-view/content-length reads
- metrics: reuse placeholders() instead of duplicating the IN-clause builder
2026-07-07 22:35:09 -07:00
prosolis
616055a704 Add per-story views, trending, and reader reading-experience upgrades
Surface read counts and sharpen the reader:
- story_views table + RecordStoryView on /api/article (background,
  filter-guarded); "Popular this week" home rail via TrendingStories;
  read-count badge and reading-time chip decorated onto every listing
- reader: signed-in-only read-aloud (TTS), native share/copy, and an
  Aa typography popover (size/serif/sepia) persisted per device
- real alt text on card/reader/related/search images; time-of-day Pete
  greeting on the home hero
- harden exec() to skip (not panic) on a nil DB so background writes
  can't crash on a closed handle

Tests: story_views_test.go, trending_test.go. Suite green, CSS rebuilt.
2026-07-07 22:17:23 -07:00
32 changed files with 3902 additions and 526 deletions

View File

@@ -77,6 +77,25 @@ interval_minutes = 360
# item doesn't ping everyone.
min_stories = 3
# Server-side neural read-aloud (Piper, https://github.com/rhasspy/piper).
# When enabled, the reader's "Listen" button streams real Piper voices instead
# of the browser's robotic Web Speech voice. Signed-in only, so it needs
# [web.auth] on too. Install the piper binary and one or more voice models
# (<id>.onnx + <id>.onnx.json) into voices_dir first.
[web.tts]
enabled = false
piper_bin = "/opt/piper/piper" # path to the piper executable
voices_dir = "/opt/piper/voices" # dir holding <id>.onnx (+ .onnx.json) models
default = "en_US-amy-medium" # voice id selected until the reader picks another
# List the voices to offer, in menu order. Omit the whole [[web.tts.voices]]
# list to auto-discover every *.onnx in voices_dir (labelled by filename).
[[web.tts.voices]]
id = "en_US-amy-medium"
label = "Amy (US, female)"
[[web.tts.voices]]
id = "en_US-ryan-high"
label = "Ryan (US, male, HQ)"
# Every enabled source MUST set direct_route to a key from [matrix.channels] above.
# There is no automatic classification — Pete posts each story to its configured channel.
# Optional: language = "en" drops feed items whose per-item <language> tag is

View File

@@ -13,13 +13,44 @@ import (
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
type Config struct {
Matrix MatrixConfig `toml:"matrix"`
Posting PostingConfig `toml:"posting"`
Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"`
Sources []SourceConfig `toml:"sources"`
Matrix MatrixConfig `toml:"matrix"`
Posting PostingConfig `toml:"posting"`
Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"`
Adventure AdventureConfig `toml:"adventure"`
Sources []SourceConfig `toml:"sources"`
}
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
// default — the endpoint 404s and no adventure channel appears until enabled.
type AdventureConfig struct {
Enabled bool `toml:"enabled"`
// IngestToken is the shared bearer secret gogobee presents on
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
IngestToken string `toml:"ingest_token"`
// Channel is the Matrix channel name (a key in [matrix.channels]) that
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
// channel, adventure runs website-only (stories still appear on /adventure).
Channel string `toml:"channel"`
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
// "unset" and doesn't get silently rewritten to the default.
DigestHour *int `toml:"digest_hour"`
}
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
// unset case. Safe on a zero-value AdventureConfig.
func (a AdventureConfig) DigestHourOrDefault() int {
if a.DigestHour == nil {
return defaultDigestHour
}
return *a.DigestHour
}
const defaultDigestHour = 17
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
type WebConfig struct {
Enabled bool `toml:"enabled"`
@@ -28,6 +59,7 @@ type WebConfig struct {
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
// AdminSubs is the allowlist of OIDC subjects allowed to view the
// owner-facing source-health dashboard at /status. Empty means the page is
// inaccessible to everyone (returns 404). Requires auth to be enabled.
@@ -53,6 +85,29 @@ type PushConfig struct {
MinStories int `toml:"min_stories"`
}
// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
// signed-in users get the reader's "Listen" button backed by real Piper voices
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
// perk, so this does nothing unless auth is also enabled.
type TTSConfig struct {
Enabled bool `toml:"enabled"`
PiperBin string `toml:"piper_bin"` // path to the piper executable
VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
// Voices lists the voices to offer, in menu order. Each id is a model
// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
// Leave empty to auto-discover every *.onnx in voices_dir.
Voices []VoiceConfig `toml:"voices"`
// Default is the voice id selected until the reader picks another. Empty
// falls back to the first available voice.
Default string `toml:"default"`
}
// VoiceConfig is one selectable Piper voice.
type VoiceConfig struct {
ID string `toml:"id"` // model filename stem, e.g. "en_US-ryan-high"
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
}
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
// When enabled, signed-in users get their preferences stored server-side keyed
// by the OIDC subject; anonymous visitors keep using browser localStorage.
@@ -204,6 +259,21 @@ func (c *Config) validate() error {
}
}
if c.Adventure.Enabled {
if c.Adventure.IngestToken == "" {
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
}
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
return fmt.Errorf("adventure.digest_hour must be 0-23")
}
if c.Adventure.Channel != "" {
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
"channel", c.Adventure.Channel)
}
}
}
for i, s := range c.Sources {
if s.Name == "" {
return fmt.Errorf("sources[%d].name is required", i)

View File

@@ -85,6 +85,11 @@ func runMigrations(d *sql.DB) error {
// else the body scraped during paywall detection) for reader mode. Stories
// ingested before this column existed simply have NULL and fall back to lede.
addColumnIfMissing(d, "stories", "content", "TEXT")
// content_chars caches the character count of content so the "N min read"
// chip never has to LENGTH() the full body on the hot listing path. Filled at
// insert time; the backfill below populates rows that predate the column.
addColumnIfMissing(d, "stories", "content_chars", "INTEGER NOT NULL DEFAULT 0")
backfillContentChars(d)
addColumnIfMissing(d, "stories", "published_at", "INTEGER")
addColumnIfMissing(d, "post_log", "url_canonical", "TEXT")
addColumnIfMissing(d, "post_log", "forced", "INTEGER NOT NULL DEFAULT 0")
@@ -125,6 +130,10 @@ func RunMaintenance() {
exec("prune orphan user_story_state",
`DELETE FROM user_story_state WHERE story_id NOT IN (SELECT id FROM stories)`)
// Same for per-story view counts once their story has aged out.
exec("prune orphan story_views",
`DELETE FROM story_views WHERE story_id NOT IN (SELECT id FROM stories)`)
// Daily unique tokens are only useful for the recent window; their salts are
// long gone. page_views is kept forever (tiny aggregate, all-time totals).
exec("prune old daily_visitors",
@@ -134,13 +143,36 @@ func RunMaintenance() {
exec("optimize", "PRAGMA optimize")
}
// exec is a fire-and-forget helper that logs errors.
// exec is a fire-and-forget helper that logs errors. Several callers run it from
// background goroutines (metrics, view counts), which can outlive a Close() — so
// unlike Get() it must not panic on a nil handle: it simply skips the write.
func exec(label, query string, args ...any) {
if _, err := Get().Exec(query, args...); err != nil {
mu.RLock()
db := globalDB
mu.RUnlock()
if db == nil {
slog.Warn("db exec skipped: no database", "op", label)
return
}
if _, err := db.Exec(query, args...); err != nil {
slog.Error("db exec failed", "op", label, "err", err)
}
}
// backfillContentChars populates content_chars for rows carrying a body but a
// zero count — i.e. stories ingested before the column existed. LENGTH() counts
// characters (code points) for TEXT, matching the utf8.RuneCountInString done at
// insert. After the first run this matches no rows (bodied stories are set,
// bodyless ones stay 0 and are filtered by content IS NOT NULL), so it's a cheap
// startup no-op thereafter.
func backfillContentChars(d *sql.DB) {
if _, err := d.Exec(
`UPDATE stories SET content_chars = LENGTH(content)
WHERE content_chars = 0 AND content IS NOT NULL AND content <> ''`); err != nil {
slog.Error("backfill content_chars failed", "err", err)
}
}
func addColumnIfMissing(d *sql.DB, table, column, columnType string) {
q := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, columnType)
if _, err := d.Exec(q); err != nil {

View File

@@ -10,6 +10,10 @@ const secondsPerDay = 86400
// unixDay returns the current UTC day number (floor(unix / 86400)).
func unixDay() int64 { return nowUnix() / secondsPerDay }
// UnixDay is the exported current UTC day number, for callers building
// day-windowed queries (e.g. the trending rail's 7-day cutoff).
func UnixDay() int64 { return unixDay() }
// RecordPageView increments the all-time and per-day view counter for a coarse
// path label ("home", a channel slug, …). Fire-and-forget: a failure here must
// never affect serving a page, so errors are logged and swallowed.
@@ -28,6 +32,59 @@ func RecordVisitor(token string) {
unixDay(), token)
}
// RecordStoryView bumps the per-day read counter for a single story. Called
// when a visitor opens the story in reader mode. Fire-and-forget like the other
// metrics writes: a failure here must never affect serving the article.
func RecordStoryView(id int64) {
exec("record story view",
`INSERT INTO story_views (story_id, day, views) VALUES (?, ?, 1)
ON CONFLICT(story_id, day) DO UPDATE SET views = views + 1`,
id, unixDay())
}
// StoryViewTotals returns all-time read counts for the given story ids, as a
// map keyed by id. Ids with no recorded views are simply absent from the map
// (callers treat missing as zero). Best-effort: on error it returns whatever it
// managed to read, so a metrics hiccup never blanks a page.
func StoryViewTotals(ids []int64) map[int64]int {
out := make(map[int64]int, len(ids))
if len(ids) == 0 {
return out
}
ph, args := intInClause(ids)
rows, err := Get().Query(
`SELECT story_id, SUM(views) FROM story_views
WHERE story_id IN (`+ph+`) GROUP BY story_id`, args...)
if err != nil {
slog.Error("metrics: story view totals query failed", "err", err)
return out
}
defer rows.Close()
for rows.Next() {
var id int64
var n int
if err := rows.Scan(&id, &n); err != nil {
slog.Error("metrics: scan story view total failed", "err", err)
continue
}
out[id] = n
}
if err := rows.Err(); err != nil {
slog.Error("metrics: story view totals iteration failed", "err", err)
}
return out
}
// intInClause builds a "?, ?, …" placeholder string and matching args slice for
// a SQL IN (…) over int64 ids.
func intInClause(ids []int64) (string, []any) {
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
return placeholders(len(ids)), args
}
// PathStat is one row of the per-page usage breakdown.
type PathStat struct {
Path string

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"strings"
"time"
"unicode/utf8"
)
func nowUnix() int64 {
@@ -38,9 +39,9 @@ func InsertStory(s *Story) error {
publishedAt = s.PublishedAt
}
_, err := Get().Exec(
`INSERT INTO stories (guid, headline, lede, content, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
`INSERT INTO stories (guid, headline, lede, content, content_chars, image_url, article_url, url_canonical, headline_norm, source, platforms, channel, classified, paywalled, seen_at, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.GUID, s.Headline, s.Lede, nullIfEmpty(s.Content), utf8.RuneCountInString(s.Content), s.ImageURL, s.ArticleURL, nullIfEmpty(s.URLCanonical), nullIfEmpty(s.HeadlineNorm), s.Source, s.Platforms, s.Channel, classified, paywalled, s.SeenAt, publishedAt,
)
return err
}
@@ -131,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
func GetStoryByGUID(guid string) (*Story, error) {
row := Get().QueryRow(
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
FROM stories WHERE guid = ?`, guid)
var s Story
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
return &s, nil
@@ -251,6 +252,53 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
return &s, nil
}
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
// daily digest collects. Oldest first so the digest reads chronologically.
//
// At most `limit` rows come back, but total is how many match in all — the digest
// quotes it to readers, so it must count the window rather than the returned page.
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
const where = `WHERE classified = 1
AND channel = 'adventure'
AND seen_at >= ?
AND guid NOT IN (SELECT guid FROM post_log)`
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := Get().Query(
`SELECT guid, headline, lede, article_url, seen_at
FROM stories `+where+`
ORDER BY seen_at ASC
LIMIT ?`, since, limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
return nil, 0, err
}
out = append(out, s)
}
return out, total, rows.Err()
}
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
func MarkAdventureDigested(guids []string, eventID string) {
for _, g := range guids {
InsertPostLog(g, "adventure", eventID, "", false)
}
}
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
// real channel, newest first, with optional offset for pagination. Sentinel
// channels (_discarded, _duplicate) are excluded.
@@ -380,6 +428,72 @@ func ListRecentlyPosted(limit int) ([]Story, error) {
return out, rows.Err()
}
// TrendingStories returns the most-read classified stories (real channels only)
// counting views recorded on or after sinceDay (a unix day number), newest-ish
// as a tiebreak. Used for the home page's "popular this week" rail. Stories with
// no views in the window don't appear, so the rail is empty until reads exist.
func TrendingStories(limit int, sinceDay int64) ([]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 stories s
JOIN story_views v ON v.story_id = s.id
WHERE s.classified = 1
AND s.channel IS NOT NULL
AND s.channel NOT IN ('_discarded', '_duplicate')
AND v.day >= ?
GROUP BY s.id
ORDER BY SUM(v.views) DESC, COALESCE(s.published_at, s.seen_at) DESC
LIMIT ?`, sinceDay, limit)
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()
}
// StoryContentLengths returns the captured article text length (in characters)
// for the given story ids, as a map keyed by id. Used to estimate a "N min read"
// chip. It reads the precomputed content_chars column rather than LENGTH()-ing
// the full body, so the hot listing path never scans article text. Ids with no
// captured content have content_chars = 0 and are absent from the map (treated
// as zero by callers).
func StoryContentLengths(ids []int64) map[int64]int {
out := make(map[int64]int, len(ids))
if len(ids) == 0 {
return out
}
ph, args := intInClause(ids)
rows, err := Get().Query(
`SELECT id, content_chars FROM stories
WHERE id IN (`+ph+`) AND content_chars > 0`, args...)
if err != nil {
slog.Error("story content lengths query failed", "err", err)
return out
}
defer rows.Close()
for rows.Next() {
var id, n int64
if err := rows.Scan(&id, &n); err != nil {
slog.Error("scan content length failed", "err", err)
continue
}
out[id] = int(n)
}
if err := rows.Err(); err != nil {
slog.Error("story content lengths iteration failed", "err", err)
}
return out
}
// CountClassifiedByChannel returns how many classified stories exist for a channel.
func CountClassifiedByChannel(channel string) (int, error) {
var n int

View File

@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS stories (
headline TEXT NOT NULL,
lede TEXT,
content TEXT,
content_chars INTEGER NOT NULL DEFAULT 0,
image_url TEXT,
article_url TEXT NOT NULL,
url_canonical TEXT,
@@ -115,6 +116,18 @@ CREATE TABLE IF NOT EXISTS daily_visitors (
PRIMARY KEY (day, visitor)
);
-- Per-story read counts, keyed by story id and UTC day. Incremented whenever a
-- visitor opens a story in reader mode (/api/article). The day dimension lets
-- us surface "popular this week" without a separate rollup; summing across all
-- days gives the all-time count shown on cards. Rows age out with their story
-- via the foreign-key-less prune in RunMaintenance.
CREATE TABLE IF NOT EXISTS story_views (
story_id INTEGER NOT NULL,
day INTEGER NOT NULL, -- unix day (floor(unix / 86400)), UTC
views INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (story_id, day)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_post_log_guid_channel ON post_log(guid, channel);
CREATE INDEX IF NOT EXISTS idx_post_log_event_id ON post_log(event_id);
CREATE INDEX IF NOT EXISTS idx_post_log_channel_posted ON post_log(channel, posted_at);
@@ -129,6 +142,7 @@ CREATE INDEX IF NOT EXISTS idx_reactions_post_guid ON reactions(post_guid);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reactions_event_id ON reactions(event_id);
CREATE INDEX IF NOT EXISTS idx_page_views_day ON page_views(day);
CREATE INDEX IF NOT EXISTS idx_daily_visitors_day ON daily_visitors(day);
CREATE INDEX IF NOT EXISTS idx_story_views_day ON story_views(day);
CREATE INDEX IF NOT EXISTS idx_user_state_bookmarks ON user_story_state(user_sub, bookmarked_at) WHERE bookmarked_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_user_state_reads ON user_story_state(user_sub, read_at) WHERE read_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_push_sub_user ON push_subscriptions(user_sub);

View File

@@ -0,0 +1,91 @@
package storage
import "testing"
// insertClassified is a tiny helper to seed a visible story and return its id.
func insertClassified(t *testing.T, guid, headline, content string) int64 {
t.Helper()
s := &Story{
GUID: guid,
Headline: headline,
Content: content,
ArticleURL: "https://example.com/" + guid,
Source: "Example Wire",
Channel: "tech",
Classified: true,
SeenAt: nowUnix(),
}
if err := InsertStory(s); err != nil {
t.Fatalf("insert %s: %v", guid, err)
}
var id int64
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
t.Fatalf("lookup %s: %v", guid, err)
}
return id
}
func TestStoryViews_TotalsAndTrending(t *testing.T) {
setupTestDB(t)
a := insertClassified(t, "s-a", "Story A", "some body text here")
b := insertClassified(t, "s-b", "Story B", "")
c := insertClassified(t, "s-c", "Story C", "another body")
// A read three times, C twice, B never.
RecordStoryView(a)
RecordStoryView(a)
RecordStoryView(a)
RecordStoryView(c)
RecordStoryView(c)
totals := StoryViewTotals([]int64{a, b, c})
if totals[a] != 3 {
t.Errorf("totals[a] = %d, want 3", totals[a])
}
if _, ok := totals[b]; ok {
t.Errorf("totals[b] present = %v, want absent (zero views)", totals[b])
}
if totals[c] != 2 {
t.Errorf("totals[c] = %d, want 2", totals[c])
}
// Trending over the last week: A (3) before C (2); B is absent (no views).
trend, err := TrendingStories(10, unixDay()-6)
if err != nil {
t.Fatalf("trending: %v", err)
}
if len(trend) != 2 {
t.Fatalf("trending len = %d, want 2 (B has no views)", len(trend))
}
if trend[0].ID != a || trend[1].ID != c {
t.Errorf("trending order = [%d, %d], want [%d, %d]", trend[0].ID, trend[1].ID, a, c)
}
// A window that starts after today excludes everything.
future, err := TrendingStories(10, unixDay()+1)
if err != nil {
t.Fatalf("trending future: %v", err)
}
if len(future) != 0 {
t.Errorf("trending (future window) len = %d, want 0", len(future))
}
}
func TestStoryContentLengths(t *testing.T) {
setupTestDB(t)
a := insertClassified(t, "c-a", "Has body", "hello world body")
b := insertClassified(t, "c-b", "No body", "")
lengths := StoryContentLengths([]int64{a, b})
if lengths[a] != len("hello world body") {
t.Errorf("lengths[a] = %d, want %d", lengths[a], len("hello world body"))
}
if _, ok := lengths[b]; ok {
t.Errorf("lengths[b] present, want absent (empty content)")
}
if got := StoryContentLengths(nil); len(got) != 0 {
t.Errorf("StoryContentLengths(nil) = %v, want empty", got)
}
}

402
internal/web/adventure.go Normal file
View File

@@ -0,0 +1,402 @@
package web
import (
"crypto/subtle"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"pete/internal/storage"
)
// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
// handles) and Actors is the allow-list of the only names permitted to appear in
// rendered output.
type AdvFact struct {
GUID string `json:"guid"`
EventType string `json:"event_type"`
Tier string `json:"tier"` // "priority" | "bulletin"
Actors []string `json:"actors"`
Subject string `json:"subject"`
Opponent string `json:"opponent"`
Boss string `json:"boss"`
Zone string `json:"zone"`
Region string `json:"region"`
Level int `json:"level"`
Count int `json:"count"`
Outcome string `json:"outcome"`
Stakes string `json:"stakes"`
ClassRace string `json:"class_race"`
Milestone string `json:"milestone"`
OccurredAt int64 `json:"occurred_at"`
NoPush bool `json:"no_push"`
}
// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
// web-local so the web package needs no dependency on internal/poster.
type AdvPost struct {
GUID string
Headline string
Lede string
ImageURL string
ArticleURL string
Source string
Channel string
}
// PriorityPoster posts a priority adventure item to Matrix immediately. main
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
type PriorityPoster func(AdvPost)
const advSource = "Pete"
// advBackfillEvent is the synthetic post_log event id used to retire a no_push
// (cold-start backfill) dispatch against the daily digest. It never went to
// Matrix; the row exists only so the digest skips it.
const advBackfillEvent = "adv-backfill"
// handleAdventureIngest receives a game-event fact from gogobee, templates it
// into a deterministic story, publishes it to the /adventure section, and posts
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
if !s.bearerOK(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var f AdvFact
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if f.GUID == "" || f.EventType == "" {
http.Error(w, "guid and event_type are required", http.StatusBadRequest)
return
}
// Fact-guard: any player name we render must be in the actors allow-list.
// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
// last line before a name reaches a public page.
if !factGuard(f) {
slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
return
}
headline, lede, ok := renderAdventure(f)
if !ok {
http.Error(w, "unknown event_type", http.StatusBadRequest)
return
}
// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
if storage.IsGUIDSeen(f.GUID) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("duplicate"))
return
}
// A fact with no occurred_at would otherwise be stored at the Unix epoch:
// dated 1970 on the permalink, pinned to the bottom of the section, and
// outside every digest window. Treat "missing" as "now".
occurredAt := f.OccurredAt
if occurredAt <= 0 {
occurredAt = time.Now().Unix()
}
articleURL := s.advPermalink(f.GUID)
imageURL := advArtURL(f.EventType)
if err := storage.InsertStory(&storage.Story{
GUID: f.GUID,
Headline: headline,
Lede: lede,
ImageURL: imageURL,
ArticleURL: articleURL,
Source: advSource,
Channel: "adventure",
Classified: true,
SeenAt: occurredAt,
PublishedAt: occurredAt,
}); err != nil {
slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
http.Error(w, "insert failed", http.StatusInternalServerError)
return
}
slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)
// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
// the live post isn't enough: the digest collects adventure rows that carry no
// post_log entry, so a backfilled bulletin would still be swept into the next
// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
// against the digest up front instead.
if f.NoPush {
storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
return
}
// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
// digest. Website section always gets the row above regardless of tier.
if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
// media), and the link's og:image carries the preview instead.
s.advPost(AdvPost{
GUID: f.GUID,
Headline: headline,
Lede: lede,
ArticleURL: articleURL,
Source: advSource,
Channel: s.adv.Channel,
})
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
// advEventMeta maps an event_type to a short display label and emoji used by the
// permalink page (and, later, OG art selection). Unknown types fall back to a
// neutral dispatch label so a new gogobee event never renders blank.
func advEventMeta(eventType string) (label, emoji string) {
switch eventType {
case "siege_start", "siege_win", "siege_loss":
return "The Siege", "🏰"
case "boss_first", "boss_kill":
return "Boss down", "🐉"
case "zone_first":
return "First clear", "🗺️"
case "zone_clear":
return "Zone cleared", "🗺️"
case "death":
return "In memoriam", "🪦"
case "arrival":
return "New arrival", "👋"
case "standings", "rival_result":
return "The rival board", "⚔️"
case "pete_duel_loss", "pete_duel_win":
return "Pete's duels", "🤝"
case "milestone":
return "Milestone", "🏅"
}
return "Dispatch", "📣"
}
// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
// the external-image thumbnailer.
func advArtURL(eventType string) string {
return "/adventure/art/" + eventType + ".svg"
}
// handleAdventureArt renders the themed emblem for an event type — an adventure
// gradient with the event's emoji and label. Deterministic and dependency-free
// (no external asset), so every dispatch card has visual identity instead of the
// blank placeholder that made the section look broken next to RSS cards.
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
label, emoji := advEventMeta(eventType)
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
}
// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
// card ratio) so the same image works as a link-preview image.
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#7c5ce8"/>
<stop offset="1" stop-color="#5836b8"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#g)"/>
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
</svg>`
// advStoryPage is the per-story permalink view. It reuses the shared layout so a
// dispatch reads like the rest of the site, with an adventure-themed hero.
type advStoryPage struct {
pageData
EventLabel string
Emoji string
Headline string
Body string
Region string
When string
Permalink string
}
// handleAdventureStory serves the server-rendered permalink for one dispatch
// (the article_url every ingested story points at). Public and cacheable; 404s
// when the section is disabled or the guid is unknown. Character names in the
// stored headline/body already passed the ingest fact-guard, so nothing
// player-controlled reaches here unchecked.
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
guid := r.PathValue("guid")
st, err := storage.GetStoryByGUID(guid)
if err != nil || st == nil || st.Channel != "adventure" {
http.NotFound(w, r)
return
}
s.track(r, "adventure")
// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
// back to the neutral dispatch meta when it isn't a known type.
eventType, _, _ := strings.Cut(guid, ":")
label, emoji := advEventMeta(eventType)
body := st.Content
if strings.TrimSpace(body) == "" {
body = st.Lede // template-only dispatches carry the write-up in the lede
}
base := s.base(r)
base.Active = "adventure"
base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
}
s.render(w, "story", advStoryPage{
pageData: base,
EventLabel: label,
Emoji: emoji,
Headline: st.Headline,
Body: body,
Region: "", // reserved: region isn't stored on the row yet
When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
Permalink: s.advPermalink(guid),
})
}
// bearerOK checks the Authorization: Bearer header against the configured ingest
// token in constant time.
func (s *Server) bearerOK(r *http.Request) bool {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
return false
}
got := strings.TrimPrefix(h, prefix)
return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
}
// siteURL makes a root-relative path absolute against BaseURL. Links that go out
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
// configured the relative form is all we have, and it's still fine on-site.
func (s *Server) siteURL(path string) string {
return strings.TrimRight(s.cfg.BaseURL, "/") + path
}
// advPermalink builds the per-story Pete permalink used as article_url (the card
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
func (s *Server) advPermalink(guid string) string {
return s.siteURL("/adventure/" + url.PathEscape(guid))
}
// factGuard verifies every player-name field we might render is present in the
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
// player-controlled, so they are not guarded.
func factGuard(f AdvFact) bool {
allow := make(map[string]bool, len(f.Actors))
for _, a := range f.Actors {
if a != "" {
allow[a] = true
}
}
if f.Subject != "" && !allow[f.Subject] {
return false
}
if f.Opponent != "" && !allow[f.Opponent] {
return false
}
return true
}
// renderAdventure returns the deterministic headline + lede for a fact. Copied
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
atLevel := ""
if f.Level > 0 {
atLevel = fmt.Sprintf(", at level %d", f.Level)
}
switch f.EventType {
case "siege_start":
return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
case "siege_win":
return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
case "siege_loss":
return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
case "boss_first":
return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
case "boss_kill":
return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
case "zone_first", "zone_clear":
// gogobee splits the realm's first-ever clear (zone_first, priority) from a
// later repeat (zone_clear, bulletin); they share a lede but differ in
// headline. Fall back to the tier for a legacy zone_first that predates the
// split.
if f.EventType == "zone_first" || f.Tier == "priority" {
headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
} else {
headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
}
inRegion := ""
if f.Region != "" {
inRegion = " in " + f.Region
}
return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
case "death":
return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
case "arrival":
return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
case "standings":
return "The rival board's been shaken up.",
fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
case "rival_result":
return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
case "pete_duel_loss":
if f.Tier == "priority" {
headline = fmt.Sprintf("You got me, %s.", f.Subject)
} else {
headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
}
return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
case "pete_duel_win":
return fmt.Sprintf("Held my ground against %s today.", f.Subject),
fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
case "milestone":
return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
}
return "", "", false
}

View File

@@ -0,0 +1,155 @@
package web
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"pete/internal/storage"
)
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
// that were seen since the last digest but never posted live — exactly the
// bulletins — and posts a single warm roundup to the adventure channel. The
// website already carries each one as its own card; the digest is the Matrix-only
// nudge so quiet-but-real activity surfaces without one ping per event.
const (
// digestWindow bounds how far back a digest looks. Wider than a day so a
// missed run (process down over a digest hour) still sweeps up the gap;
// re-collection is prevented by MarkAdventureDigested, not by the window.
digestWindow = 48 * time.Hour
// digestCap bounds a single digest. Far past the observed volume (a dormant
// community, per the voice spec); a runaway just truncates with a log line.
digestCap = 40
// digestPreview is how many headlines the roundup lede lists by name before
// collapsing the rest to "…and N more".
digestPreview = 4
)
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
// section is enabled AND there's a live Matrix poster AND a channel to post to —
// i.e. website-only and local modes never post a digest.
func (s *Server) StartAdventureDigest(ctx context.Context) {
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
return
}
go s.runAdventureDigest(ctx)
}
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
// digest at a predictable time of day across restarts.
func (s *Server) runAdventureDigest(ctx context.Context) {
hour := s.adv.DigestHourOrDefault()
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
for {
wait := durUntilNextHour(time.Now().UTC(), hour)
select {
case <-ctx.Done():
return
case <-time.After(wait):
s.postDailyDigest(time.Now().UTC())
}
}
}
// durUntilNextHour returns the duration from now to the next occurrence of the
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
// at the hour doesn't double-fire.
func durUntilNextHour(now time.Time, hour int) time.Duration {
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next.Sub(now)
}
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
// and marks them digested so they don't recur. Silent when there's nothing new —
// a dormant realm should stay quiet, not ship an empty digest.
func (s *Server) postDailyDigest(now time.Time) {
since := now.Add(-digestWindow).Unix()
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
if err != nil {
slog.Error("adventure digest: query failed", "err", err)
return
}
if len(bulletins) == 0 {
return
}
if total > len(bulletins) {
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
}
date := now.Format("2006-01-02")
eventID := "adv-digest:" + date
headline, lede := buildDigest(bulletins, total)
s.advPost(AdvPost{
GUID: eventID,
Headline: headline,
Lede: lede,
ArticleURL: s.digestURL(date),
Source: advSource,
Channel: s.adv.Channel,
})
guids := make([]string, len(bulletins))
for i, b := range bulletins {
guids[i] = b.GUID
}
storage.MarkAdventureDigested(guids, eventID)
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
}
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
// total is how many bulletins the window actually holds, which is larger than
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
// have to describe the realm, not the slice. Headlines come from stored,
// fact-guarded rows, so no player-controlled text is introduced here.
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
if total == 1 {
return "Today in the realm: one dispatch.",
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
}
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
shown := bulletins
if len(shown) > digestPreview {
shown = shown[:digestPreview]
}
parts := make([]string, len(shown))
for i, b := range shown {
parts[i] = trimHeadline(b.Headline)
}
list := strings.Join(parts, " ")
more := ""
if total > len(shown) {
more = fmt.Sprintf(" …and %d more.", total-len(shown))
}
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
}
// trimHeadline ensures a headline ends with sentence punctuation so the joined
// lede reads as prose rather than a run-on.
func trimHeadline(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
h += "."
}
return h
}
// digestURL points the roundup at the section, with a per-day query param so the
// canonical-URL dedup treats each day's digest as distinct (it strips only
// tracking params, keeping ?digest=).
func (s *Server) digestURL(date string) string {
return s.siteURL("/adventure?digest=" + date)
}

View File

@@ -0,0 +1,313 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// newAdvServer builds a web server with the adventure seam enabled and a
// capturing priority poster, backed by a fresh temp DB.
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
t.Helper()
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
var posted []AdvPost
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
// Mirror the production poster's side effect: queue.PostNow records a
// post_log row, which is exactly what marks a beat "posted" and thus
// excludes it from the bulletin digest.
poster := func(p AdvPost) {
posted = append(posted, p)
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
nil, true, adv, poster)
if err != nil {
t.Fatal(err)
}
return s, &posted
}
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(f)
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rw := httptest.NewRecorder()
s.handleAdventureIngest(rw, req)
return rw
}
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
// bearer-accepted, templated, stored as an adventure story, and posted live.
func TestAdventureIngestEndToEnd(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
}
got, err := storage.GetStoryByGUID("death:abc:1000")
if err != nil || got == nil {
t.Fatalf("story not stored: %v", err)
}
if got.Channel != "adventure" || got.Source != advSource {
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
}
if got.Headline != "We lost Brannigan in the Underforge." {
t.Errorf("headline = %q", got.Headline)
}
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
t.Errorf("article_url = %q", got.ArticleURL)
}
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
t.Fatalf("priority post not delivered: %+v", *posted)
}
// Idempotent re-delivery: no error, no second post.
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("dup ingest status = %d", rw.Code)
}
if len(*posted) != 1 {
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
}
}
// TestAdventurePermalink renders the per-story page the article_url points at:
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
// and body, and an unknown guid must 404.
func TestAdventurePermalink(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
req.SetPathValue("guid", "death:abc:1000")
rw := httptest.NewRecorder()
s.handleAdventureStory(rw, req)
if rw.Code != 200 {
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
}
body := rw.Body.String()
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
t.Errorf("permalink missing headline; body=%s", body)
}
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
t.Errorf("permalink missing event label; body=%s", body)
}
// Unknown guid 404s.
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
req2.SetPathValue("guid", "nope:1")
rw2 := httptest.NewRecorder()
s.handleAdventureStory(rw2, req2)
if rw2.Code != 404 {
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
}
}
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
func TestDurUntilNextHour(t *testing.T) {
// 14:30 UTC, targeting 17:00 → 2h30m today.
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
t.Errorf("before hour: got %v", got)
}
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
t.Errorf("at hour: got %v", got)
}
// 20:00, targeting 17:00 → tomorrow, 21h.
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
t.Errorf("after hour: got %v", got)
}
}
// TestAdventureDigest covers the batched path: bulletins (no live post) are
// collected into one roundup, priority beats are excluded (they already posted),
// digested bulletins don't recur, and an empty window stays silent.
func TestAdventureDigest(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
now := time.Now()
// Two bulletins + one priority (which posts live and must be excluded).
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
if len(*posted) != 1 {
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
}
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
}
dg := (*posted)[1]
if !strings.Contains(dg.Headline, "2 dispatches") {
t.Errorf("digest headline = %q", dg.Headline)
}
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
t.Errorf("digest guid = %q", dg.GUID)
}
// Re-running finds nothing new (bulletins marked digested).
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
}
}
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
// returns a themed SVG, ingested cards carry its local path, and the permalink
// page is noindex with an og:image.
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
func TestRenderZoneTaxonomy(t *testing.T) {
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl, _, ok := renderAdventure(first)
if !ok || !strings.Contains(hl, "very first time") {
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
}
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl2, _, ok := renderAdventure(repeat)
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
}
// The permalink label distinguishes the two, so a repeat's page isn't stamped
// "First clear".
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
}
}
func TestAdventureArtAndMeta(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
// Emblem endpoint returns SVG with the event's emoji.
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
areq.SetPathValue("type", "death.svg")
arw := httptest.NewRecorder()
s.handleAdventureArt(arw, areq)
if arw.Code != 200 {
t.Fatalf("art status = %d", arw.Code)
}
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
t.Errorf("art content-type = %q", ct)
}
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
t.Errorf("art body missing emblem: %s", b)
}
// Ingest sets the card image to the local emblem path.
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
got, _ := storage.GetStoryByGUID("death:abc:1000")
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
t.Errorf("story image = %q", got.ImageURL)
}
// Permalink page is noindex with an og:image.
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
preq.SetPathValue("guid", "death:abc:1000")
prw := httptest.NewRecorder()
s.handleAdventureStory(prw, preq)
body := prw.Body.String()
if !strings.Contains(body, `name="robots" content="noindex"`) {
t.Error("permalink not noindex")
}
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
t.Errorf("permalink missing og:image; body=%s", body)
}
}
// TestAdventureIngestBearer rejects missing/wrong tokens.
func TestAdventureIngestBearer(t *testing.T) {
s, _ := newAdvServer(t, "right")
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
if rw := postFact(t, s, "", f); rw.Code != 401 {
t.Errorf("no token: status = %d, want 401", rw.Code)
}
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
t.Errorf("wrong token: status = %d, want 401", rw.Code)
}
}
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
// so a name that slipped the source can't reach a public page.
func TestAdventureFactGuard(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:evil:1", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
Zone: "the Underforge", Level: 3, OccurredAt: 1,
}
if rw := postFact(t, s, token, f); rw.Code != 400 {
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
}
if storage.IsGUIDSeen("death:evil:1") {
t.Error("guarded fact was stored")
}
if len(*posted) != 0 {
t.Error("guarded fact was posted")
}
}
// TestAdventureDisabled 404s when the seam is off.
func TestAdventureDisabled(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
t.Errorf("disabled: status = %d, want 404", rw.Code)
}
}

View File

@@ -53,7 +53,7 @@ func TestFeeds(t *testing.T) {
}
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -27,6 +27,8 @@ type StoryView struct {
Posted bool
Paywalled bool // source is gated and no archive workaround succeeded
Channel string // channel slug; also the theme key
ReadMins int // estimated reading time in minutes; 0 = unknown (no chip)
Views int // all-time reader-mode opens; 0 = none yet (no badge)
}
func toView(s storage.Story) StoryView {
@@ -44,6 +46,49 @@ func toView(s storage.Story) StoryView {
}
}
// decorate fills the ReadMins and Views fields on one or more groups of story
// cards using two cheap batch queries over the whole id set. Called just before
// render so every listing (home rails, channel pages, bookmarks, for-you) shows
// a reading-time chip and a read count without each list query having to carry
// those columns. Best-effort: a metrics hiccup just leaves the badges off.
func decorate(groups ...[]StoryView) {
var ids []int64
seen := make(map[int64]bool)
for _, g := range groups {
for _, v := range g {
if v.ID > 0 && !seen[v.ID] {
seen[v.ID] = true
ids = append(ids, v.ID)
}
}
}
if len(ids) == 0 {
return
}
lengths := storage.StoryContentLengths(ids)
views := storage.StoryViewTotals(ids)
for _, g := range groups {
for i := range g {
g[i].ReadMins = readMinutes(lengths[g[i].ID])
g[i].Views = views[g[i].ID]
}
}
}
// readMinutes turns a character count into a rounded minutes-to-read estimate,
// assuming ~200 wpm and ~6 characters per word (5-letter words plus a space).
// Any non-empty body reads as at least "1 min".
func readMinutes(chars int) int {
if chars <= 0 {
return 0
}
mins := (chars + 600) / 1200 // round to nearest minute (200 wpm * 6 chars)
if mins < 1 {
mins = 1
}
return mins
}
type pageData struct {
SiteTitle string
Channels []Channel
@@ -59,6 +104,9 @@ type pageData struct {
IsAdmin bool // signed-in user is on the admin allowlist (shows /status link)
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
PushPublicKey string // VAPID public key handed to the client to subscribe
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
}
type channelPage struct {
@@ -79,6 +127,7 @@ type indexPage struct {
Stats []channelStat
Latest []StoryView
ForYou []StoryView // personalized rail; empty for anon / no-history users
Trending []StoryView // most-read this week; empty until reads accumulate
}
type channelStat struct {
@@ -111,7 +160,7 @@ func (s *Server) base(r *http.Request) pageData {
}
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Channels: s.channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
@@ -120,6 +169,10 @@ func (s *Server) base(r *http.Request) pageData {
PostingEnabled: s.postingEnabled,
PushEnabled: s.auth != nil && s.cfg.Push.Enabled,
PushPublicKey: s.cfg.Push.VAPIDPublicKey,
TTS: template.JS("null"),
}
if s.tts != nil {
d.TTS = s.tts.clientConfig()
}
if s.auth != nil {
if u := s.auth.userFromRequest(r); u != nil {
@@ -138,6 +191,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
const (
justPostedLimit = 6
latestLimit = 16
trendingLimit = 8
)
postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
@@ -163,8 +217,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(channels))
for _, ch := range channels {
stats := make([]channelStat, 0, len(s.channels))
for _, ch := range s.channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
@@ -179,11 +233,23 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
stats = append(stats, stat)
}
// Popular this week: most-read stories over the trailing 7 UTC days. Empty
// until reads accumulate, so the section simply doesn't render on a fresh DB.
var trending []StoryView
if trendRows, err := storage.TrendingStories(trendingLimit, storage.UnixDay()-6); err != nil {
slog.Error("web: trending query failed", "err", err)
} else {
for _, row := range trendRows {
trending = append(trending, toView(row))
}
}
data := indexPage{
pageData: s.base(r),
JustPosted: justPosted,
Stats: stats,
Latest: latest,
Trending: trending,
}
// For signed-in users with some read/bookmark history, lead with a small
// personalized rail. ForYou returns nothing for anon / no-history users, so
@@ -200,6 +266,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
}
}
decorate(data.Trending, data.ForYou, data.JustPosted, data.Latest)
s.render(w, "index", data)
}
@@ -228,6 +295,7 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
for _, row := range rows {
views = append(views, toView(row))
}
decorate(views)
base := s.base(r)
base.Active = "for-you"
s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
@@ -257,9 +325,13 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
views = append(views, toView(r))
}
total, _ := storage.CountClassifiedByChannel(ch.Slug)
decorate(views)
base := s.base(r)
base.Active = ch.Slug
// The adventure section names player characters; keep it out of search
// indexes to bound the cached-forever exposure (see plan gap #5).
base.NoIndex = ch.Slug == "adventure"
data := channelPage{
pageData: base,
Channel: ch,
@@ -315,6 +387,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
for _, row := range rows {
views = append(views, toView(row))
}
decorate(views)
total, _ := storage.CountBookmarks(u.Sub)
base := s.base(r)
@@ -333,7 +406,7 @@ func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
}
var (
demoVariants = []string{"rain", "petals", "jacaranda", "motes", "leaves", "clear", "clouds", "snow", "fog", "storm"}
demoVariants = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "petals", "jacaranda", "motes", "leaves"}
demoIntensities = []string{"light", "medium", "heavy"}
demoPhases = []string{"day", "dawn", "dusk", "night"}
)
@@ -363,10 +436,12 @@ func seasonForVariant(v string) string {
return "winter"
case "petals", "jacaranda":
return "spring"
case "motes":
case "motes", "haze":
return "summer"
case "leaves":
case "leaves", "wind":
return "autumn"
case "hail":
return "winter"
}
return ""
}
@@ -519,6 +594,11 @@ func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
return
}
// Opening a story in reader mode is our per-story read signal. found==true
// means the id passed the same visibility filter the listings use, so this
// can't be driven to inflate counts for hidden/unclassified stories. Fired
// in the background so serving the body never waits on the write.
go storage.RecordStoryView(id)
_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
}

View File

@@ -42,7 +42,7 @@ func TestReaderCardDataAndArticleAPI(t *testing.T) {
t.Fatal(err)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -31,6 +31,10 @@ type Channel struct {
}
// channels in display order. Add a new entry here to add a section.
//
// This is the full catalogue, used to resolve a story's channel slug to its
// display metadata. It is NOT the list of live sections: an entry can be gated
// off (adventure), so route registration and the nav read s.channels instead.
var channels = []Channel{
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
@@ -42,6 +46,7 @@ var channels = []Channel{
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
}
// SourceInfo is the trimmed view of a configured feed for the settings panel.
@@ -58,8 +63,12 @@ type Server struct {
srv *http.Server
tpls map[string]*template.Template // keyed by page name (e.g. "index", "channel")
auth *Authenticator // nil when sign-in is disabled or unavailable
tts *ttsService // nil when server-side read-aloud is disabled
adminSubs map[string]bool // OIDC subjects allowed to view /status
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
adv config.AdventureConfig // gogobee adventure-news seam
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
// Guarded by metricsMu; never persisted (see metrics.go).
@@ -71,8 +80,8 @@ type Server struct {
// New builds the server. Templates are parsed once at startup. Each page
// gets its own template set sharing layout.html + _card.html, which avoids
// `main` define collisions between pages.
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
tpls := make(map[string]*template.Template, len(pages))
for _, p := range pages {
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
@@ -98,7 +107,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
adminSubs[sub] = true
}
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
// The adventure section only exists when it's enabled: with the seam off,
// there's no nav tab, no /adventure page and no /adventure feed, rather than
// an empty section nobody can fill.
live := make([]Channel, 0, len(channels))
for _, ch := range channels {
if ch.Slug == "adventure" && !adv.Enabled {
continue
}
live = append(live, ch)
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than
@@ -115,6 +135,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
}
}
// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
// only useful alongside auth; newTTS logs and disables itself on any misconfig.
if s.auth != nil {
tts, err := newTTS(cfg.TTS)
if err != nil {
return nil, err
}
s.tts = tts
} else if cfg.TTS.Enabled {
slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
}
mux := http.NewServeMux()
staticSub, err := fs.Sub(staticFS, "static")
@@ -134,7 +166,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/related", s.handleRelated)
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
for _, ch := range channels {
for _, ch := range s.channels {
ch := ch
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
s.handleChannel(w, r, ch)
@@ -152,6 +184,19 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
_, _ = w.Write([]byte("ok"))
})
// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
// Per-dispatch permalink (the article_url every ingested story points at).
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
// channel listing, registered in the channels loop above).
mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)
// Themed per-event emblem (card + og:image). A distinct path depth from
// /adventure/{guid}, so the two patterns never overlap.
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
if s.auth != nil {
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
@@ -167,6 +212,9 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
}
if s.tts != nil {
mux.HandleFunc("POST /api/tts", s.handleTTS)
}
}
s.srv = &http.Server{

View File

@@ -79,6 +79,7 @@ html[data-phase="night"] {
.bg-theme-kids { background-color: #14b8a6; }
.bg-theme-finance { background-color: #10b981; }
.bg-theme-lego { background-color: #d01012; }
.bg-theme-adventure { background-color: #6d4bd8; }
.text-theme-gaming { color: #2d8a5a; }
.text-theme-tech { color: #2f7fb8; }
@@ -90,6 +91,7 @@ html[data-phase="night"] {
.text-theme-kids { color: #0f766e; }
.text-theme-finance { color: #059669; }
.text-theme-lego { color: #b00d0e; }
.text-theme-adventure { color: #5836b8; }
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
@@ -101,6 +103,7 @@ html[data-phase="night"] {
.decoration-theme-kids { text-decoration-color: #14b8a6; }
.decoration-theme-finance { text-decoration-color: #10b981; }
.decoration-theme-lego { text-decoration-color: #d01012; }
.decoration-theme-adventure { text-decoration-color: #6d4bd8; }
.border-theme-gaming { border-color: #4caf7d; }
.border-theme-tech { border-color: #5aa9e6; }
@@ -112,6 +115,7 @@ html[data-phase="night"] {
.border-theme-kids { border-color: #14b8a6; }
.border-theme-finance { border-color: #10b981; }
.border-theme-lego { border-color: #d01012; }
.border-theme-adventure { border-color: #6d4bd8; }
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@@ -124,6 +128,7 @@ html[data-phase="night"] {
.glow-theme-kids { box-shadow: 0 0 0 2px rgba(20,184,166,0.35), 0 0 24px 4px rgba(20,184,166,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-finance { box-shadow: 0 0 0 2px rgba(16,185,129,0.35), 0 0 24px 4px rgba(16,185,129,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-lego { box-shadow: 0 0 0 2px rgba(208,16,18,0.35), 0 0 24px 4px rgba(208,16,18,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-adventure { box-shadow: 0 0 0 2px rgba(109,75,216,0.35), 0 0 24px 4px rgba(109,75,216,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@keyframes pete-glow-pulse {
0%, 100% { filter: brightness(1); }
@@ -232,6 +237,74 @@ html[data-phase="night"] {
.pete-reader-btn:disabled { opacity: 0.35; cursor: default; transform: none; }
.pete-reader-btn-open { background: var(--accent); border-color: transparent; color: #1c1305; text-decoration: none; }
.pete-reader-btn-open:hover { background: var(--accent); filter: brightness(1.08); }
/* Active/pressed toolbar toggle (Listen while speaking, Aa while menu open). */
.pete-reader-btn[aria-pressed="true"],
.pete-reader-btn[aria-expanded="true"] {
background: var(--accent);
border-color: transparent;
color: #1c1305;
}
/* Text-options popover, anchored under the Aa button in the reader bar. */
.pete-reader-type { position: relative; display: inline-flex; }
.pete-reader-typemenu[hidden] { display: none; }
.pete-reader-typemenu {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 10;
width: 15rem;
padding: 0.75rem;
border-radius: 1rem;
background: var(--card);
color: var(--ink);
border: 2px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 10px 30px rgba(20, 14, 6, 0.28);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
html[data-phase="night"] .pete-reader-typemenu { border-color: rgba(255, 255, 255, 0.12); }
.pete-reader-typerow { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; }
.pete-reader-typelabel { font-size: 0.8rem; font-weight: 700; opacity: 0.7; }
.pete-reader-typebtns { display: inline-flex; gap: 0.25rem; }
.pete-reader-typebtns button {
min-width: 2rem;
height: 2rem;
padding: 0 0.55rem;
border-radius: 0.6rem;
background: rgba(20, 14, 6, 0.06);
color: var(--ink);
font-weight: 700;
font-size: 0.82rem;
border: 2px solid transparent;
cursor: pointer;
transition: background 0.15s ease;
}
html[data-phase="night"] .pete-reader-typebtns button { background: rgba(255, 255, 255, 0.08); }
.pete-reader-typebtns button:hover { background: rgba(20, 14, 6, 0.12); }
.pete-reader-typebtns button.is-active {
background: var(--accent);
color: #1c1305;
border-color: transparent;
}
.pete-reader-voicerow[hidden] { display: none; }
.pete-reader-voice {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 0.25rem 0.4rem;
border-radius: 0.55rem;
border: 1px solid rgba(20, 14, 6, 0.18);
background: rgba(20, 14, 6, 0.06);
color: inherit;
max-width: 11rem;
cursor: pointer;
}
html[data-phase="night"] .pete-reader-voice {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
}
.pete-reader-scroll {
flex: 1 1 auto;
@@ -239,6 +312,7 @@ html[data-phase="night"] {
-webkit-overflow-scrolling: touch;
border-radius: 1.75rem;
}
.pete-reader-scroll:focus { outline: none; }
.pete-reader-article {
background: var(--card);
@@ -287,9 +361,33 @@ html[data-phase="night"] {
background: rgba(0, 0, 0, 0.05);
}
.pete-reader-body { font-size: 1.08rem; line-height: 1.75; }
/* Reader typography, driven by the Aa popover and persisted per-device. The
size lives as a CSS var on the scroll container; font + paper are classes. */
.pete-reader-scroll { --reader-fs: 1.08rem; }
.pete-reader-body { font-size: var(--reader-fs); line-height: 1.75; }
.pete-reader-body p { margin-bottom: 1.05em; }
.pete-reader-body p:last-child { margin-bottom: 0; }
.pete-reader-scroll.is-size-s { --reader-fs: 0.98rem; }
.pete-reader-scroll.is-size-m { --reader-fs: 1.08rem; }
.pete-reader-scroll.is-size-l { --reader-fs: 1.22rem; }
.pete-reader-scroll.is-size-xl { --reader-fs: 1.4rem; }
.pete-reader-scroll.is-serif .pete-reader-body {
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
}
/* Sepia paper: warm background + ink regardless of day/night phase. */
.pete-reader-scroll.is-sepia .pete-reader-article {
background: #f6ecd6;
color: #4a3a28;
border-color: rgba(74, 58, 40, 0.16);
}
.pete-reader-scroll.is-sepia .pete-reader-note { border-top-color: rgba(74, 58, 40, 0.18); opacity: 0.8; }
.pete-reader-scroll.is-sepia .pete-reader-hero { background: rgba(74, 58, 40, 0.08); }
/* Currently-spoken paragraph highlight while reading aloud. */
.pete-reader-body p.is-speaking {
background: color-mix(in srgb, var(--accent) 32%, transparent);
border-radius: 0.4rem;
box-shadow: 0 0 0 0.35rem color-mix(in srgb, var(--accent) 32%, transparent);
}
.pete-reader-note {
margin-top: 1.25rem;
@@ -313,6 +411,26 @@ html[data-phase="night"] {
}
.pete-reader-empty-emoji { font-size: 2.5rem; }
/* Transient confirmation toast (e.g. "Link copied") shown over the reader. */
.pete-reader-toast {
position: absolute;
left: 50%;
bottom: 4.5rem;
transform: translate(-50%, 0.5rem);
z-index: 20;
padding: 0.5rem 0.9rem;
border-radius: 9999px;
background: rgba(20, 14, 6, 0.9);
color: #fff;
font-weight: 700;
font-size: 0.85rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.pete-reader-toast.is-shown { opacity: 1; transform: translate(-50%, 0); }
.pete-reader-hint {
text-align: center;
color: rgba(255, 255, 255, 0.85);
@@ -328,6 +446,9 @@ html[data-phase="night"] {
@media (max-width: 640px) {
.pete-reader-hint { display: none; }
/* With Listen/Share/Aa added, let the toolbar wrap instead of overflowing. */
.pete-reader-bar { flex-wrap: wrap; }
.pete-reader-nav { flex-wrap: wrap; justify-content: flex-end; row-gap: 0.4rem; }
}
/* "You might also like" rail, shown under the article in feed mode. Lives

File diff suppressed because one or more lines are too long

View File

@@ -21,6 +21,12 @@
var closeBtn = overlay.querySelector("[data-reader-close]");
var backdrop = overlay.querySelector("[data-reader-backdrop]");
var readerBookmarkBtn = overlay.querySelector("[data-reader-bookmark]");
var listenBtn = overlay.querySelector("[data-reader-listen]");
var shareBtn = overlay.querySelector("[data-reader-share]");
var typeBtn = overlay.querySelector("[data-reader-type]");
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
var voiceRow = overlay.querySelector("[data-reader-voicerow]");
var voiceSel = overlay.querySelector("[data-reader-voice]");
var relatedEl = overlay.querySelector("[data-reader-related]");
var relatedCache = {}; // id -> results array
@@ -30,11 +36,19 @@
var SIGNED_IN = !!(window.PETE_USER);
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
// Read-aloud is a signed-in-only perk, backed by server-side Piper voices
// (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
// is available to everyone (native sheet where present, clipboard otherwise).
var TTS_CFG = (window.PETE_TTS && window.PETE_TTS.enabled &&
window.PETE_TTS.voices && window.PETE_TTS.voices.length) ? window.PETE_TTS : null;
var TTS_OK = SIGNED_IN && !!TTS_CFG;
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
var index = 0;
var open = false;
var contentCache = {}; // id -> {content, lede}
var inflight = null;
var bodyReady = false; // true once the real article body is rendered (not the loading placeholder)
// ---- read-state store -----------------------------------------------------
function loadRead() {
@@ -238,7 +252,7 @@
function renderBody(it, bodyHTML) {
var hero = it.image
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="" loading="lazy" decoding="async">'
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="' + escapeHTML(it.headline) + '" loading="lazy" decoding="async">'
: "";
var href = safeURL(it.url);
var note = '<p class="pete-reader-note">' +
@@ -255,6 +269,7 @@
}
function loadingBody(it) {
bodyReady = false; // read-aloud waits for the real body, not this placeholder
renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
}
@@ -265,6 +280,7 @@
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
}
renderBody(it, html);
bodyReady = true;
}
function fetchContent(it) {
@@ -312,7 +328,7 @@
escapeHTML(it.channel_title) + "</span>"
: "";
var thumb = it.thumb_url
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="" loading="lazy" decoding="async">'
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="' + escapeHTML(it.headline || "") + '" loading="lazy" decoding="async">'
: '<div class="pete-reader-related-thumb pete-reader-related-thumb-empty"></div>';
html += '<a class="pete-reader-related-card" href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
thumb +
@@ -345,6 +361,7 @@
// ---- navigation -----------------------------------------------------------
function show(i) {
stopSpeak(); // never keep reading a story the user navigated away from
index = i;
if (index >= items.length) { renderDone(); return; }
var it = items[index];
@@ -366,6 +383,7 @@
}
function renderDone() {
stopSpeak();
clearRelated();
progressEl.textContent = items.length + " / " + items.length;
linkEl.style.display = "none";
@@ -390,6 +408,263 @@
return 0;
}
// ---- typography (Aa popover) ----------------------------------------------
// Per-device reading preferences: text size, sans/serif, default/sepia paper.
// Kept in localStorage (like the read set) rather than the synced prefs blob,
// since they're a device-local reading-comfort choice.
var TYPE_KEY = "pete.reader.type.v1";
var SIZES = ["s", "m", "l", "xl"];
var typePrefs = { size: "m", font: "sans", paper: "default" };
(function loadType() {
try {
var raw = JSON.parse(localStorage.getItem(TYPE_KEY) || "{}");
if (raw && typeof raw === "object") {
if (SIZES.indexOf(raw.size) >= 0) typePrefs.size = raw.size;
if (raw.font === "serif" || raw.font === "sans") typePrefs.font = raw.font;
if (raw.paper === "sepia" || raw.paper === "default") typePrefs.paper = raw.paper;
}
} catch (e) {}
})();
function saveType() {
try { localStorage.setItem(TYPE_KEY, JSON.stringify(typePrefs)); } catch (e) {}
}
function applyType() {
if (!scrollEl) return;
SIZES.forEach(function (s) { scrollEl.classList.remove("is-size-" + s); });
scrollEl.classList.add("is-size-" + typePrefs.size);
scrollEl.classList.toggle("is-serif", typePrefs.font === "serif");
scrollEl.classList.toggle("is-sepia", typePrefs.paper === "sepia");
paintType();
}
function paintType() {
if (!typeMenu) return;
typeMenu.querySelectorAll("[data-size]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-size") === typePrefs.size);
});
typeMenu.querySelectorAll("[data-font]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-font") === typePrefs.font);
});
typeMenu.querySelectorAll("[data-paper]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-paper") === typePrefs.paper);
});
}
function toggleTypeMenu(force) {
if (!typeMenu || !typeBtn) return;
var openNow = force != null ? force : typeMenu.hidden;
typeMenu.hidden = !openNow;
typeBtn.setAttribute("aria-expanded", openNow ? "true" : "false");
}
// ---- share ----------------------------------------------------------------
var toastEl = null, toastTimer = null;
function toast(msg) {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.className = "pete-reader-toast";
overlay.appendChild(toastEl);
}
toastEl.textContent = msg;
toastEl.classList.add("is-shown");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("is-shown"); }, 1800);
}
function shareCurrent() {
var it = items[index];
if (!it) return;
var url = safeURL(it.url);
if (!url) { toast("No link to share"); return; }
var data = { title: it.headline || "Pete", text: it.headline || "", url: url };
if (navigator.share) {
navigator.share(data).catch(function () {});
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(
function () { toast("Link copied ✓"); },
function () { toast("Couldn't copy link"); }
);
return;
}
window.open(url, "_blank", "noopener");
}
// ---- read aloud (signed-in only, server-side Piper) -----------------------
// Each paragraph is synthesized on demand by POSTing its text to /api/tts and
// playing the returned WAV. We fetch the next paragraph while the current one
// plays, so the model-load + synthesis latency is hidden behind playback.
// speakGen is bumped on every stop/start/voice-change; any async callback that
// fires afterwards checks it against its captured gen and no-ops if stale.
var VOICE_KEY = "pete.reader.voice.v1";
var speaking = false;
var speakGen = 0;
var speakParas = []; // <p> elements currently highlighted
var speakItems = []; // [{el, text}] for the whole article
var speakIdx = 0; // index into speakItems currently playing
var speakReq = null; // in-flight fetch for the current paragraph (has .ctrl)
var prefetch = null; // { idx, gen, promise, abort } for the next paragraph
var audioEl = null; // shared <audio> element
var currentURL = null; // object URL currently loaded into audioEl
// Device-local voice choice, like the type prefs (see loadType).
var ttsVoice = "";
(function loadVoice() {
if (!TTS_CFG) return;
var saved = "";
try { saved = localStorage.getItem(VOICE_KEY) || ""; } catch (e) {}
var ok = TTS_CFG.voices.some(function (v) { return v.id === saved; });
ttsVoice = ok ? saved : TTS_CFG.default;
})();
function saveVoice() { try { localStorage.setItem(VOICE_KEY, ttsVoice); } catch (e) {} }
function speakingText() {
if (!articleEl) return [];
var out = [];
var h = articleEl.querySelector(".pete-reader-title");
if (h && h.textContent.trim()) out.push({ el: null, text: h.textContent.trim() });
articleEl.querySelectorAll(".pete-reader-body p").forEach(function (p) {
var t = p.textContent.trim();
if (t) out.push({ el: p, text: t });
});
return out;
}
function clearSpeakHighlight() {
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
speakParas = [];
}
function highlight(item) {
clearSpeakHighlight();
if (item && item.el) {
item.el.classList.add("is-speaking");
speakParas.push(item.el);
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
}
}
// ttsFetch kicks off synthesis of one paragraph. Returns { ctrl, promise },
// where promise resolves to an object URL for the audio blob.
function ttsFetch(text) {
var ctrl = new AbortController();
var promise = fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ voice: ttsVoice, text: text }),
signal: ctrl.signal,
}).then(function (r) {
if (!r.ok) throw new Error("tts " + r.status);
return r.blob();
}).then(function (b) { return URL.createObjectURL(b); });
return { ctrl: ctrl, promise: promise };
}
function abortPrefetch() {
if (!prefetch) return;
try { prefetch.ctrl.abort(); } catch (e) {}
// If it already resolved to a URL, reclaim it.
prefetch.promise.then(function (u) { URL.revokeObjectURL(u); }, function () {});
prefetch = null;
}
function startPrefetch(i, gen) {
var item = speakItems[i];
if (!item) { prefetch = null; return; }
var req = ttsFetch(item.text);
prefetch = { idx: i, gen: gen, ctrl: req.ctrl, promise: req.promise };
}
function playURL(url, gen) {
if (!audioEl) audioEl = new Audio();
if (currentURL) URL.revokeObjectURL(currentURL);
currentURL = url;
audioEl.src = url;
audioEl.onended = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
audioEl.onerror = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
var pr = audioEl.play();
if (pr && pr.catch) pr.catch(function () {}); // ignore autoplay/abort rejections
}
function playIdx(i) {
if (!speaking) return;
var gen = speakGen;
var item = speakItems[i];
if (!item) { stopSpeak(); return; }
speakIdx = i;
highlight(item);
var urlP;
if (prefetch && prefetch.idx === i && prefetch.gen === gen) {
urlP = prefetch.promise;
prefetch = null; // hand off; do not abort/revoke this one
} else {
abortPrefetch();
var req = ttsFetch(item.text);
speakReq = req;
urlP = req.promise;
}
urlP.then(function (url) {
if (!speaking || gen !== speakGen) { URL.revokeObjectURL(url); return; }
speakReq = null;
startPrefetch(i + 1, gen); // synthesize the next paragraph during playback
playURL(url, gen);
}).catch(function () {
if (!speaking || gen !== speakGen) return;
speakReq = null;
playIdx(i + 1); // skip a paragraph that failed rather than stalling
});
}
function teardownAudio() {
if (speakReq) { try { speakReq.ctrl.abort(); } catch (e) {} speakReq = null; }
abortPrefetch();
if (audioEl) { try { audioEl.pause(); } catch (e) {} audioEl.onended = null; audioEl.onerror = null; audioEl.removeAttribute("src"); }
if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
}
function stopSpeak() {
if (!TTS_OK) return;
speakGen++;
speaking = false;
teardownAudio();
speakItems = [];
clearSpeakHighlight();
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
}
function startSpeak() {
if (!TTS_OK) return;
if (!bodyReady) { toast("Still loading…"); return; }
var parts = speakingText();
if (!parts.length) { toast("Nothing to read yet"); return; }
speakGen++;
teardownAudio();
speaking = true;
speakItems = parts;
speakIdx = 0;
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
playIdx(0);
}
function toggleSpeak() {
if (!TTS_OK) return;
if (speaking) stopSpeak(); else startSpeak();
}
// Switching voice restarts the current paragraph with the new voice so the
// change is audible immediately, keeping our place in the article.
function changeVoice(id) {
if (!TTS_CFG) return;
ttsVoice = id;
saveVoice();
if (voiceSel) voiceSel.value = ttsVoice;
if (speaking) {
speakGen++;
teardownAudio();
playIdx(speakIdx);
}
}
function buildVoiceMenu() {
if (!voiceSel || !TTS_CFG) return;
voiceSel.innerHTML = "";
TTS_CFG.voices.forEach(function (v) {
var o = document.createElement("option");
o.value = v.id;
o.textContent = v.label;
voiceSel.appendChild(o);
});
voiceSel.value = ttsVoice;
voiceSel.addEventListener("change", function () { changeVoice(voiceSel.value); });
if (voiceRow) voiceRow.hidden = false;
}
// ---- open / close ---------------------------------------------------------
function openReader() {
items = collect();
@@ -397,10 +672,16 @@
open = true;
overlay.classList.remove("hidden");
document.body.classList.add("overflow-hidden");
applyType();
show(firstUnread());
// Move focus into the scroll region so Space / PageUp-Down / arrow keys
// scroll the story, not the page behind it, without a click first.
if (scrollEl) { try { scrollEl.focus({ preventScroll: true }); } catch (e) { scrollEl.focus(); } }
}
function closeReader() {
open = false;
stopSpeak();
toggleTypeMenu(false);
if (inflight) { inflight.abort(); inflight = null; }
clearRelated();
overlay.classList.add("hidden");
@@ -427,6 +708,37 @@
if (it) setBookmark(it.id, !isBookmarked(it.id));
});
// Read-aloud: signed-in only, and only when server-side TTS is configured.
if (listenBtn) {
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); buildVoiceMenu(); }
else listenBtn.style.display = "none";
}
// Share: available to everyone.
if (shareBtn) { shareBtn.style.display = ""; shareBtn.addEventListener("click", shareCurrent); }
// Text-options popover.
if (typeBtn) typeBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleTypeMenu(); });
if (typeMenu) {
typeMenu.addEventListener("click", function (e) {
var b = e.target.closest("button");
if (!b) return;
e.stopPropagation();
if (b.hasAttribute("data-size")) typePrefs.size = b.getAttribute("data-size");
else if (b.hasAttribute("data-font")) typePrefs.font = b.getAttribute("data-font");
else if (b.hasAttribute("data-paper")) typePrefs.paper = b.getAttribute("data-paper");
else return;
saveType();
applyType();
});
}
// Close the type popover on any outside click.
document.addEventListener("click", function (e) {
if (!typeMenu || typeMenu.hidden) return;
if (e.target.closest("[data-reader-type]")) return;
toggleTypeMenu(false);
});
applyType();
initUserState();
});
@@ -465,7 +777,14 @@
switch (e.key) {
case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break;
case "ArrowLeft": case "h": e.preventDefault(); prev(); break;
case "Escape": e.preventDefault(); closeReader(); break;
case "Escape":
e.preventDefault();
if (typeMenu && !typeMenu.hidden) toggleTypeMenu(false); // esc closes the popover first
else closeReader();
break;
case "s": case "S": e.preventDefault(); shareCurrent(); break;
case "t": case "T": e.preventDefault(); toggleTypeMenu(); break;
case "r": case "R": if (TTS_OK) { e.preventDefault(); toggleSpeak(); } break;
case "o": case "Enter": {
var it = items[index];
var href = it && safeURL(it.url);

View File

@@ -59,7 +59,7 @@
const html = items.map((r, i) => {
const thumb = r.thumb_url
? `<div class="hidden sm:block w-20 h-20 shrink-0 overflow-hidden rounded-2xl bg-[color:var(--ink)]/5">
<img src="${escapeHTML(r.thumb_url)}" alt="" loading="lazy" decoding="async"
<img src="${escapeHTML(r.thumb_url)}" alt="${escapeHTML(r.headline || "")}" loading="lazy" decoding="async"
class="h-full w-full object-cover">
</div>`
: `<div class="hidden sm:flex w-20 h-20 shrink-0 items-center justify-center rounded-2xl bg-[color:var(--ink)]/5 text-2xl">${escapeHTML(r.channel_emoji || "·")}</div>`;

View File

@@ -0,0 +1,487 @@
// Canvas2D weather engine — the fallback when WebGL2 isn't available. This is
// the original hand-drawn renderer wrapped as an engine factory; weather.js
// owns the toggle, storage and the PeteWeather API and just calls
// set/start/stop here. New GPU-only variants (hail, haze, wind, aurora) alias
// to their nearest 2D look so the fallback never renders a blank page.
(function () {
window.PeteWeatherEngines = window.PeteWeatherEngines || {};
window.PeteWeatherEngines.canvas2d = function (canvas) {
var ctx = canvas.getContext("2d");
if (!ctx) return null;
var root = document.documentElement;
var W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || window.innerWidth;
H = canvas.clientHeight || window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
resize();
window.addEventListener("resize", resize);
var aliases = { hail: "snow", haze: "motes", wind: "leaves", aurora: "clear" };
var counts = {
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
};
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
function rand(a, b) { return a + Math.random() * (b - a); }
var variant = null; // current rendered variant
var intensity = "heavy"; // light | medium | heavy
var particles = [];
var flash = 0; // lightning flash decay (storm only)
var nextBolt = 2; // seconds until next lightning bolt (storm only)
function spawn(initial) {
var p = {};
p.x = rand(0, W);
p.y = initial ? rand(0, H) : rand(-40, -10);
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
if (variant === "rain" || variant === "storm") {
p.vy = rand(700, 1100) * p.z;
p.vx = -rand(60, 120);
p.len = rand(10, 18) * p.z;
p.alpha = rand(0.25, 0.55);
} else if (variant === "snow") {
p.vy = rand(35, 75) * p.z;
p.swayAmp = rand(12, 34);
p.swayFreq = rand(0.3, 0.8);
p.swayPhase = rand(0, Math.PI * 2);
p.size = rand(2, 4.6) * p.z;
p.alpha = rand(0.65, 0.95);
} else if (variant === "clouds") {
// Soft puffs drifting across the upper sky.
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
p.x = initial ? rand(0, W) : -rand(120, 320);
p.vx = rand(8, 22);
p.size = rand(80, 170) * p.z;
p.alpha = rand(0.32, 0.55);
p.sprite = Math.floor(rand(0, 3));
} else if (variant === "fog") {
// Wide translucent bands creeping sideways.
p.y = rand(H * 0.1, H);
p.x = initial ? rand(0, W) : -rand(200, 500);
p.vx = rand(6, 16);
p.w = rand(260, 520);
p.h = rand(70, 150);
p.alpha = rand(0.05, 0.14);
} else if (variant === "petals" || variant === "jacaranda") {
p.vy = rand(60, 120);
p.swayAmp = rand(20, 50);
p.swayFreq = rand(0.4, 0.9);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-1.2, 1.2);
p.size = rand(8, 16) * p.z;
p.alpha = rand(0.75, 1.0);
if (variant === "jacaranda") {
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
p.sat = rand(55, 75);
p.light = rand(70, 82);
} else {
p.hue = rand(335, 355); // pink (almond/cherry)
p.sat = rand(60, 80);
p.light = rand(78, 88);
}
} else if (variant === "motes") {
p.vx = rand(-15, 25);
p.vy = rand(8, 25);
p.size = rand(1.2, 2.8) * p.z;
p.alpha = rand(0.35, 0.7);
p.twinklePhase = rand(0, Math.PI * 2);
p.twinkleFreq = rand(0.5, 1.5);
} else if (variant === "leaves") {
p.vy = rand(70, 130);
p.swayAmp = rand(30, 70);
p.swayFreq = rand(0.3, 0.6);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-2.0, 2.0);
p.size = rand(10, 18) * p.z;
p.alpha = rand(0.8, 1.0);
p.hue = rand(18, 42); // orange→amber→brown
p.light = rand(38, 55);
}
return p;
}
var night = root.dataset.phase === "night";
function refreshNight() { night = root.dataset.phase === "night"; }
function drawRain(p) {
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
// more saturated blue plus a slightly thicker line to stay visible. Night
// keeps a paler tone so streaks read against the dark palette.
if (night) {
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
ctx.lineWidth = 1;
} else {
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
ctx.lineWidth = 1.3;
}
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
ctx.stroke();
}
function drawSnow(p) {
// Soft round flake with a faint glow so it reads on light and dark palettes.
var col = night ? "235,242,255" : "255,255,255";
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
if (!night) {
// A thin cool outline keeps white flakes visible against cream backgrounds.
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
// offscreen canvases, then just drifted — cheap to animate.
var cloudSprites = null;
var cloudSpritesNight = null;
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
var cloudShapes = [
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
];
function makeCloudSprite(col, shape) {
var c = document.createElement("canvas");
var w = 320, h = 224;
c.width = w; c.height = h;
var cx = c.getContext("2d");
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
cx.fillStyle = "rgba(" + col + ",1)";
for (var i = 0; i < shape.length; i++) {
cx.beginPath();
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
cx.fill();
}
return c;
}
function ensureCloudSprites() {
if (cloudSprites && cloudSpritesNight === night) return;
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
// tone against the dark palette.
var col = night ? "206,216,236" : "150,164,190";
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
cloudSpritesNight = night;
}
function drawCloud(p) {
ensureCloudSprites();
var spr = cloudSprites[p.sprite % cloudSprites.length];
var w = p.size * 2.6;
var h = w * (spr.height / spr.width);
ctx.globalAlpha = p.alpha;
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
ctx.globalAlpha = 1;
}
function drawFog(p) {
var col = night ? "150,160,180" : "230,228,224";
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
g.addColorStop(0, "rgba(" + col + ",0)");
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
g.addColorStop(1, "rgba(" + col + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawMoon(cx, cy, mr) {
var TAU = Math.PI * 2;
ctx.save();
// Clip everything to the lunar disc so shading stays inside the sphere.
ctx.beginPath();
ctx.arc(cx, cy, mr, 0, TAU);
ctx.clip();
// Spherical body shading — light source upper-right, terminator lower-left.
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
body.addColorStop(0, "rgba(249,251,255,0.97)");
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
body.addColorStop(1, "rgba(140,151,180,0.85)");
ctx.fillStyle = body;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
limb.addColorStop(0, "rgba(18,24,46,0)");
limb.addColorStop(1, "rgba(18,24,46,0.4)");
ctx.fillStyle = limb;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
ctx.restore();
// Soft outer halo, drawn unclipped around the disc.
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
halo.addColorStop(0, "rgba(220,230,255,0.28)");
halo.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
ctx.fill();
}
function drawClearGlow(t) {
// No particles — render a single calm sun (day) or moon (night) glow that
// breathes very slowly, plus a few stars at night.
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
if (night) {
// Ambient sky glow around the moon.
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
g.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
drawMoon(cx, cy, r * 0.16);
} else {
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
gd.addColorStop(1, "rgba(255,214,120,0)");
ctx.fillStyle = gd;
ctx.fillRect(0, 0, W, H);
}
}
// Deterministic star field for clear nights (seeded so they don't jitter).
var stars = [];
function buildStars() {
stars = [];
var n = 60;
for (var i = 0; i < n; i++) {
// Cheap LCG-ish spread keyed by index — stable across frames.
var sx = ((i * 73 + 11) % 100) / 100;
var sy = ((i * 37 + 7) % 100) / 100;
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
}
}
function drawStars(t) {
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
ctx.beginPath();
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
ctx.fill();
}
}
function drawPetals(p) {
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
for (var i = 0; i < 5; i++) {
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
var cx = Math.cos(a) * s * 0.32;
var cy = Math.sin(a) * s * 0.32;
ctx.beginPath();
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
ctx.beginPath();
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawLeaf(p) {
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
ctx.fillStyle = g;
// Almond/lozenge leaf — pointed at both ends.
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
ctx.closePath();
ctx.fill();
// Midrib vein.
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(-s * 0.5, 0);
ctx.lineTo(s * 0.5, 0);
ctx.stroke();
// Small stem nub at the base.
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 1.0;
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.lineTo(-s * 0.7, -s * 0.04);
ctx.stroke();
ctx.restore();
}
function drawMote(p, t) {
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
// use a deeper saturated honey so the motes stand out against warm cream
// and peach backgrounds; alpha also gets a bump.
var color, a;
if (night) {
color = "255,240,180";
a = p.alpha * (0.45 + 0.55 * twinkle);
} else {
color = "130,80,180";
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
}
// Soft glow halo.
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
g.addColorStop(1, "rgba(" + color + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
ctx.fill();
// Bright core.
ctx.fillStyle = "rgba(" + color + "," + a + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
var last = performance.now();
var raf = 0;
function step(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
var t = now / 1000;
ctx.clearRect(0, 0, W, H);
refreshNight();
if (variant === "clear") {
drawClearGlow(t);
if (night) drawStars(t);
raf = requestAnimationFrame(step);
return;
}
// Storm: drive the lightning flash before drawing rain over it.
if (variant === "storm") {
nextBolt -= dt;
if (nextBolt <= 0) {
flash = 1;
nextBolt = rand(2.5, 7);
}
if (flash > 0) {
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
ctx.fillRect(0, 0, W, H);
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
}
}
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (variant === "rain" || variant === "storm") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 20 || p.x < -40) {
particles[i] = spawn(false);
particles[i].x = rand(0, W + 60);
continue;
}
drawRain(p);
} else if (variant === "snow") {
p.y += p.vy * dt;
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
var ox = p.x; p.x = ox + sxOff;
drawSnow(p);
p.x = ox;
} else if (variant === "clouds") {
p.x += p.vx * dt;
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
drawCloud(p);
} else if (variant === "fog") {
p.x += p.vx * dt;
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
drawFog(p);
} else if (variant === "motes") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
particles[i] = spawn(false);
continue;
}
drawMote(p, t);
} else {
// Drifting variants: petals, jacaranda, leaves.
p.y += p.vy * dt;
p.rot += p.vrot * dt;
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
var origX = p.x;
p.x = origX + sway;
if (p.y > H + 30) {
particles[i] = spawn(false);
continue;
}
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
else drawLeaf(p);
p.x = origX;
}
}
raf = requestAnimationFrame(step);
}
function start() {
if (raf) return;
last = performance.now();
raf = requestAnimationFrame(step);
}
function stop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
ctx.clearRect(0, 0, W, H);
}
// Build the particle pool for a variant. The controller decides whether to
// start rendering afterwards.
function set(v, inten) {
variant = aliases[v] || v || null;
intensity = inten || "heavy";
flash = 0; nextBolt = rand(1.5, 4);
particles = [];
if (variant === "clear") { buildStars(); }
if (!variant) { stop(); return; }
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
for (var i = 0; i < N; i++) particles.push(spawn(true));
}
return { name: "canvas2d", set: set, start: start, stop: stop };
};
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
// Canvas-based weather rendered behind the page. Two drivers feed it:
// Weather controller. Two drivers feed the background canvas:
//
// 1. The server picks a *seasonal* variant + intensity from the Lisbon-local
// date and writes them to <html data-weather / data-intensity>. This is the
@@ -7,15 +7,21 @@
// forecast and calls PeteWeather.set(variant, intensity) to override the
// background with live conditions.
//
// Each variant has a hand-drawn shape so silhouettes are recognizable instead
// of a generic blob. Seasonal variants: rain, petals, jacaranda, motes, leaves.
// Live-weather variants add: clear, clouds, snow, fog, storm.
// Rendering is delegated to an engine: the WebGL2 one (weather-gl.js) when the
// browser supports it — GPU sprites, shader fog/aurora, real lightning — with
// the original Canvas2D renderer (weather-2d.js) as the fallback. This file
// only owns the toggle button, the on/off preference and the public API.
(function () {
var root = document.documentElement;
var canvas = document.getElementById("pete-weather");
if (!canvas) return;
var reducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var engines = window.PeteWeatherEngines || {};
var engine = (engines.webgl2 && engines.webgl2(canvas)) || null;
if (!engine && engines.canvas2d) engine = engines.canvas2d(canvas);
if (!engine) return;
var STORAGE_KEY = "pete-weather-off";
var toggleBtn = document.querySelector("[data-weather-toggle]");
var star = document.querySelector("[data-weather-star]");
@@ -41,504 +47,38 @@
}
}
var ctx = canvas.getContext("2d");
var W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = canvas.clientWidth || window.innerWidth;
H = canvas.clientHeight || window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
resize();
window.addEventListener("resize", resize);
var counts = {
rain: 160, petals: 50, jacaranda: 55, motes: 70, leaves: 38,
snow: 150, clouds: 7, fog: 7, clear: 0, storm: 200
};
var mults = { light: 0.6, medium: 1.0, heavy: 1.4 };
function rand(a, b) { return a + Math.random() * (b - a); }
var variant = null; // current rendered variant
var intensity = "heavy"; // light | medium | heavy
var particles = [];
var flash = 0; // lightning flash decay (storm only)
var nextBolt = 2; // seconds until next lightning bolt (storm only)
function spawn(initial) {
var p = {};
p.x = rand(0, W);
p.y = initial ? rand(0, H) : rand(-40, -10);
p.z = rand(0.7, 1.3); // depth — affects size + speed + alpha
if (variant === "rain" || variant === "storm") {
p.vy = rand(700, 1100) * p.z;
p.vx = -rand(60, 120);
p.len = rand(10, 18) * p.z;
p.alpha = rand(0.25, 0.55);
} else if (variant === "snow") {
p.vy = rand(35, 75) * p.z;
p.swayAmp = rand(12, 34);
p.swayFreq = rand(0.3, 0.8);
p.swayPhase = rand(0, Math.PI * 2);
p.size = rand(2, 4.6) * p.z;
p.alpha = rand(0.65, 0.95);
} else if (variant === "clouds") {
// Soft puffs drifting across the upper sky.
p.y = initial ? rand(0, H * 0.5) : rand(-H * 0.1, H * 0.45);
p.x = initial ? rand(0, W) : -rand(120, 320);
p.vx = rand(8, 22);
p.size = rand(80, 170) * p.z;
p.alpha = rand(0.32, 0.55);
p.sprite = Math.floor(rand(0, 3));
} else if (variant === "fog") {
// Wide translucent bands creeping sideways.
p.y = rand(H * 0.1, H);
p.x = initial ? rand(0, W) : -rand(200, 500);
p.vx = rand(6, 16);
p.w = rand(260, 520);
p.h = rand(70, 150);
p.alpha = rand(0.05, 0.14);
} else if (variant === "petals" || variant === "jacaranda") {
p.vy = rand(60, 120);
p.swayAmp = rand(20, 50);
p.swayFreq = rand(0.4, 0.9);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-1.2, 1.2);
p.size = rand(8, 16) * p.z;
p.alpha = rand(0.75, 1.0);
if (variant === "jacaranda") {
p.hue = rand(265, 285); // blue-violet — jacaranda uses the petal silhouette in purple
p.sat = rand(55, 75);
p.light = rand(70, 82);
} else {
p.hue = rand(335, 355); // pink (almond/cherry)
p.sat = rand(60, 80);
p.light = rand(78, 88);
}
} else if (variant === "motes") {
p.vx = rand(-15, 25);
p.vy = rand(8, 25);
p.size = rand(1.2, 2.8) * p.z;
p.alpha = rand(0.35, 0.7);
p.twinklePhase = rand(0, Math.PI * 2);
p.twinkleFreq = rand(0.5, 1.5);
} else if (variant === "leaves") {
p.vy = rand(70, 130);
p.swayAmp = rand(30, 70);
p.swayFreq = rand(0.3, 0.6);
p.swayPhase = rand(0, Math.PI * 2);
p.rot = rand(0, Math.PI * 2);
p.vrot = rand(-2.0, 2.0);
p.size = rand(10, 18) * p.z;
p.alpha = rand(0.8, 1.0);
p.hue = rand(18, 42); // orange→amber→brown
p.light = rand(38, 55);
}
return p;
}
var night = root.dataset.phase === "night";
function refreshNight() { night = root.dataset.phase === "night"; }
function drawRain(p) {
// Day/dawn/dusk backgrounds are warm and pale, so the rain needs a darker,
// more saturated blue plus a slightly thicker line to stay visible. Night
// keeps a paler tone so streaks read against the dark palette.
if (night) {
ctx.strokeStyle = "rgba(200,215,240," + p.alpha + ")";
ctx.lineWidth = 1;
} else {
ctx.strokeStyle = "rgba(60,95,150," + Math.min(1, p.alpha + 0.25) + ")";
ctx.lineWidth = 1.3;
}
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.len * 0.12, p.y + p.len);
ctx.stroke();
}
function drawSnow(p) {
// Soft round flake with a faint glow so it reads on light and dark palettes.
var col = night ? "235,242,255" : "255,255,255";
ctx.fillStyle = "rgba(" + col + "," + p.alpha + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
if (!night) {
// A thin cool outline keeps white flakes visible against cream backgrounds.
ctx.strokeStyle = "rgba(150,180,220," + (p.alpha * 0.5) + ")";
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
// Pre-baked cloud sprites: clustered blobs softened with a heavy blur so each
// cloud reads as one fluffy mass. Built once per palette (day/night) onto
// offscreen canvases, then just drifted — cheap to animate.
var cloudSprites = null;
var cloudSpritesNight = null;
// Three silhouettes for variety. Each is a list of blobs [cx, cy, r] in a
// 0..1 box (x across, y down) with a flattish base around y≈0.66.
var cloudShapes = [
[[0.50, 0.50, 0.26], [0.34, 0.56, 0.20], [0.66, 0.56, 0.20], [0.42, 0.40, 0.18],
[0.58, 0.38, 0.17], [0.22, 0.60, 0.15], [0.78, 0.60, 0.15], [0.50, 0.62, 0.22]],
[[0.46, 0.52, 0.24], [0.30, 0.58, 0.18], [0.62, 0.50, 0.22], [0.74, 0.58, 0.16],
[0.40, 0.40, 0.16], [0.56, 0.36, 0.15], [0.18, 0.62, 0.13], [0.84, 0.62, 0.12]],
[[0.52, 0.50, 0.28], [0.36, 0.56, 0.19], [0.68, 0.56, 0.18], [0.48, 0.36, 0.16],
[0.26, 0.60, 0.14], [0.74, 0.60, 0.15], [0.60, 0.62, 0.18], [0.40, 0.62, 0.18]]
];
function makeCloudSprite(col, shape) {
var c = document.createElement("canvas");
var w = 320, h = 224;
c.width = w; c.height = h;
var cx = c.getContext("2d");
cx.filter = "blur(18px)"; // the softness that turns blobs into a cloud
cx.fillStyle = "rgba(" + col + ",1)";
for (var i = 0; i < shape.length; i++) {
cx.beginPath();
cx.arc(shape[i][0] * w, shape[i][1] * h, shape[i][2] * w, 0, Math.PI * 2);
cx.fill();
}
return c;
}
function ensureCloudSprites() {
if (cloudSprites && cloudSpritesNight === night) return;
// Day sky is warm cream so clouds need a cool slate-grey; night uses a pale
// tone against the dark palette.
var col = night ? "206,216,236" : "150,164,190";
cloudSprites = cloudShapes.map(function (s) { return makeCloudSprite(col, s); });
cloudSpritesNight = night;
}
function drawCloud(p) {
ensureCloudSprites();
var spr = cloudSprites[p.sprite % cloudSprites.length];
var w = p.size * 2.6;
var h = w * (spr.height / spr.width);
ctx.globalAlpha = p.alpha;
ctx.drawImage(spr, p.x - w / 2, p.y - h / 2, w, h);
ctx.globalAlpha = 1;
}
function drawFog(p) {
var col = night ? "150,160,180" : "230,228,224";
var g = ctx.createLinearGradient(p.x - p.w / 2, 0, p.x + p.w / 2, 0);
g.addColorStop(0, "rgba(" + col + ",0)");
g.addColorStop(0.5, "rgba(" + col + "," + p.alpha + ")");
g.addColorStop(1, "rgba(" + col + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(p.x, p.y, p.w / 2, p.h / 2, 0, 0, Math.PI * 2);
ctx.fill();
}
function drawMoon(cx, cy, mr) {
var TAU = Math.PI * 2;
ctx.save();
// Clip everything to the lunar disc so shading stays inside the sphere.
ctx.beginPath();
ctx.arc(cx, cy, mr, 0, TAU);
ctx.clip();
// Spherical body shading — light source upper-right, terminator lower-left.
var lx = cx + mr * 0.45, ly = cy - mr * 0.45;
var body = ctx.createRadialGradient(lx, ly, mr * 0.1, cx, cy, mr * 1.35);
body.addColorStop(0, "rgba(249,251,255,0.97)");
body.addColorStop(0.55, "rgba(214,222,240,0.92)");
body.addColorStop(1, "rgba(140,151,180,0.85)");
ctx.fillStyle = body;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
// Limb darkening — fade the edge so the disc reads as a ball, not a coin.
var limb = ctx.createRadialGradient(cx, cy, mr * 0.62, cx, cy, mr);
limb.addColorStop(0, "rgba(18,24,46,0)");
limb.addColorStop(1, "rgba(18,24,46,0.4)");
ctx.fillStyle = limb;
ctx.fillRect(cx - mr, cy - mr, mr * 2, mr * 2);
ctx.restore();
// Soft outer halo, drawn unclipped around the disc.
var halo = ctx.createRadialGradient(cx, cy, mr, cx, cy, mr * 2.4);
halo.addColorStop(0, "rgba(220,230,255,0.28)");
halo.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, cy, mr * 2.4, 0, TAU);
ctx.fill();
}
function drawClearGlow(t) {
// No particles — render a single calm sun (day) or moon (night) glow that
// breathes very slowly, plus a few stars at night.
var breathe = 0.5 + 0.5 * Math.sin(t * 0.25);
var cx = W * 0.82, cy = H * 0.18, r = Math.min(W, H) * 0.5;
if (night) {
// Ambient sky glow around the moon.
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, "rgba(220,230,255," + (0.12 + 0.05 * breathe) + ")");
g.addColorStop(1, "rgba(220,230,255,0)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
drawMoon(cx, cy, r * 0.16);
} else {
var gd = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
gd.addColorStop(0, "rgba(255,224,150," + (0.30 + 0.10 * breathe) + ")");
gd.addColorStop(0.5, "rgba(255,214,120," + (0.10 + 0.04 * breathe) + ")");
gd.addColorStop(1, "rgba(255,214,120,0)");
ctx.fillStyle = gd;
ctx.fillRect(0, 0, W, H);
}
}
// Deterministic star field for clear nights (seeded so they don't jitter).
var stars = [];
function buildStars() {
stars = [];
var n = 60;
for (var i = 0; i < n; i++) {
// Cheap LCG-ish spread keyed by index — stable across frames.
var sx = ((i * 73 + 11) % 100) / 100;
var sy = ((i * 37 + 7) % 100) / 100;
stars.push({ x: sx, y: sy * 0.7, r: 0.6 + (i % 3) * 0.5, ph: (i % 7) });
}
}
function drawStars(t) {
for (var i = 0; i < stars.length; i++) {
var s = stars[i];
var tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.ph));
ctx.fillStyle = "rgba(255,255,255," + (0.5 * tw) + ")";
ctx.beginPath();
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
ctx.fill();
}
}
function drawPetals(p) {
// Five-petal almond/cherry blossom viewed face-on, with a yellow center.
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = "hsla(" + p.hue + "," + p.sat + "%," + p.light + "%," + p.alpha + ")";
for (var i = 0; i < 5; i++) {
var a = (i / 5) * Math.PI * 2 - Math.PI / 2;
var cx = Math.cos(a) * s * 0.32;
var cy = Math.sin(a) * s * 0.32;
ctx.beginPath();
ctx.ellipse(cx, cy, s * 0.38, s * 0.24, a, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = "hsla(48,90%,68%," + p.alpha + ")";
ctx.beginPath();
ctx.arc(0, 0, s * 0.13, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawLeaf(p) {
var s = p.size;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
var g = ctx.createLinearGradient(-s * 0.5, 0, s * 0.5, 0);
g.addColorStop(0, "hsla(" + p.hue + ",70%," + (p.light - 10) + "%," + p.alpha + ")");
g.addColorStop(1, "hsla(" + p.hue + ",75%," + (p.light + 10) + "%," + p.alpha + ")");
ctx.fillStyle = g;
// Almond/lozenge leaf — pointed at both ends.
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.quadraticCurveTo(0, -s * 0.38, s * 0.55, 0);
ctx.quadraticCurveTo(0, s * 0.38, -s * 0.55, 0);
ctx.closePath();
ctx.fill();
// Midrib vein.
ctx.strokeStyle = "hsla(" + p.hue + ",60%," + (p.light - 22) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(-s * 0.5, 0);
ctx.lineTo(s * 0.5, 0);
ctx.stroke();
// Small stem nub at the base.
ctx.strokeStyle = "hsla(" + p.hue + ",55%," + (p.light - 25) + "%," + (p.alpha * 0.7) + ")";
ctx.lineWidth = 1.0;
ctx.beginPath();
ctx.moveTo(-s * 0.55, 0);
ctx.lineTo(-s * 0.7, -s * 0.04);
ctx.stroke();
ctx.restore();
}
function drawMote(p, t) {
var twinkle = 0.5 + 0.5 * Math.sin(t * p.twinkleFreq + p.twinklePhase);
// Night keeps a pale glowing mote against the dark palette. Day/dawn/dusk
// use a deeper saturated honey so the motes stand out against warm cream
// and peach backgrounds; alpha also gets a bump.
var color, a;
if (night) {
color = "255,240,180";
a = p.alpha * (0.45 + 0.55 * twinkle);
} else {
color = "130,80,180";
a = Math.min(1, (p.alpha + 0.2) * (0.6 + 0.4 * twinkle));
}
// Soft glow halo.
var g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 5);
g.addColorStop(0, "rgba(" + color + "," + (a * 0.55) + ")");
g.addColorStop(1, "rgba(" + color + ",0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 5, 0, Math.PI * 2);
ctx.fill();
// Bright core.
ctx.fillStyle = "rgba(" + color + "," + a + ")";
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fill();
}
var last = performance.now();
var raf = 0;
function step(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
var t = now / 1000;
ctx.clearRect(0, 0, W, H);
refreshNight();
if (variant === "clear") {
drawClearGlow(t);
if (night) drawStars(t);
raf = requestAnimationFrame(step);
return;
}
// Storm: drive the lightning flash before drawing rain over it.
if (variant === "storm") {
nextBolt -= dt;
if (nextBolt <= 0) {
flash = 1;
nextBolt = rand(2.5, 7);
}
if (flash > 0) {
ctx.fillStyle = "rgba(235,240,255," + (flash * 0.5) + ")";
ctx.fillRect(0, 0, W, H);
flash = Math.max(0, flash - dt * 4); // ~0.25s decay
}
}
for (var i = 0; i < particles.length; i++) {
var p = particles[i];
if (variant === "rain" || variant === "storm") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 20 || p.x < -40) {
particles[i] = spawn(false);
particles[i].x = rand(0, W + 60);
continue;
}
drawRain(p);
} else if (variant === "snow") {
p.y += p.vy * dt;
var sxOff = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
if (p.y > H + 10) { particles[i] = spawn(false); continue; }
var ox = p.x; p.x = ox + sxOff;
drawSnow(p);
p.x = ox;
} else if (variant === "clouds") {
p.x += p.vx * dt;
if (p.x - p.size * 1.3 > W + 40) { particles[i] = spawn(false); continue; }
drawCloud(p);
} else if (variant === "fog") {
p.x += p.vx * dt;
if (p.x - p.w / 2 > W + 40) { particles[i] = spawn(false); continue; }
drawFog(p);
} else if (variant === "motes") {
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H + 10 || p.x < -10 || p.x > W + 10) {
particles[i] = spawn(false);
continue;
}
drawMote(p, t);
} else {
// Drifting variants: petals, jacaranda, leaves.
p.y += p.vy * dt;
p.rot += p.vrot * dt;
var sway = Math.sin(t * p.swayFreq + p.swayPhase) * p.swayAmp;
var origX = p.x;
p.x = origX + sway;
if (p.y > H + 30) {
particles[i] = spawn(false);
continue;
}
if (variant === "jacaranda" || variant === "petals") drawPetals(p);
else drawLeaf(p);
p.x = origX;
}
}
raf = requestAnimationFrame(step);
}
function start() {
if (raf) return;
last = performance.now();
raf = requestAnimationFrame(step);
}
function stop() {
if (raf) cancelAnimationFrame(raf);
raf = 0;
ctx.clearRect(0, 0, W, H);
}
// Build the particle pool for a variant and (re)start rendering if enabled.
function configure(v, inten) {
variant = v || null;
intensity = inten || "heavy";
flash = 0; nextBolt = rand(1.5, 4);
particles = [];
if (variant === "clear") { buildStars(); }
if (!variant) { stop(); return; }
var N = Math.round((counts[variant] || 0) * (mults[intensity] || 1));
for (var i = 0; i < N; i++) particles.push(spawn(true));
if (enabled) start();
}
var enabled = !userDisabled() && !reducedMotion;
// Public hook so weather-forecast.js can swap the seasonal default for live
// conditions. Passing a falsy variant clears the canvas.
// Public hook so weather-forecast.js (and the /weather demo) can swap the
// seasonal default for other conditions. Passing a falsy variant clears the
// canvas.
window.PeteWeather = {
set: function (v, inten) {
root.dataset.weather = v || "";
if (inten) root.dataset.intensity = inten;
configure(v, inten || intensity);
engine.set(v || null, inten || root.dataset.intensity || "heavy");
if (enabled && v) engine.start();
},
isEnabled: function () { return enabled; }
isEnabled: function () { return enabled; },
renderer: function () { return engine.name; }
};
syncBtn(enabled);
configure(root.dataset.weather, root.dataset.intensity);
engine.set(root.dataset.weather || null, root.dataset.intensity || "heavy");
if (enabled && root.dataset.weather) engine.start();
if (toggleBtn) {
toggleBtn.addEventListener("click", function () {
enabled = !enabled;
setDisabled(!enabled);
syncBtn(enabled);
if (enabled) start(); else stop();
if (enabled) engine.start(); else engine.stop();
});
}
document.addEventListener("visibilitychange", function () {
if (!enabled) return;
if (document.hidden) stop();
else start();
if (document.hidden) engine.stop();
else engine.start();
});
})();

View File

@@ -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 = "v3";
var CACHE_VERSION = "v4";
var SHELL_CACHE = "pete-shell-" + CACHE_VERSION;
var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
@@ -13,6 +13,8 @@ var RUNTIME_CACHE = "pete-runtime-" + CACHE_VERSION;
var SHELL_ASSETS = [
"/static/css/output.css",
"/static/js/prefs.js",
"/static/js/weather-2d.js",
"/static/js/weather-gl.js",
"/static/js/weather.js",
"/static/js/weather-forecast.js",
"/static/js/search.js",

View File

@@ -9,7 +9,7 @@ import (
)
func TestStatusTemplateExecutes(t *testing.T) {
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -20,7 +20,7 @@
</span>
{{if .Story.ImageURL}}
<div class="aspect-[16/10] w-full overflow-hidden bg-[color:var(--ink)]/5">
<img src="{{thumb .Story.ImageURL}}" alt="" loading="lazy" decoding="async"
<img src="{{thumb .Story.ImageURL}}" alt="{{.Story.Headline}}" loading="lazy" decoding="async"
class="h-full w-full object-cover group-hover:scale-105 transition duration-500">
</div>
{{end}}
@@ -37,6 +37,10 @@
</span>
{{end}}
<span class="text-[color:var(--ink)]/50">{{timeAgo .Story.SeenAt}}</span>
{{if .Story.ReadMins}}<span class="text-[color:var(--ink)]/45">· {{.Story.ReadMins}} min read</span>{{end}}
{{if .Story.Views}}<span class="ml-auto inline-flex items-center gap-1 text-[color:var(--ink)]/45 tabular-nums" title="{{.Story.Views}} reads">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"></path><circle cx="12" cy="12" r="3"></circle></svg>
{{.Story.Views}}</span>{{end}}
</div>
<h3 class="font-display text-lg font-semibold leading-snug group-hover:underline underline-offset-2 decoration-theme-{{.Theme}}">
{{.Story.Headline}}

View File

@@ -13,10 +13,31 @@
Pete reads RSS and sorts stories into channels, fresh from his feeds.
{{end}}
</p>
<p class="mx-auto mt-5 inline-flex items-center gap-2 rounded-full bg-[color:var(--card)] px-4 py-2 text-sm font-semibold shadow-pete border-2 border-[color:var(--ink)]/10">
<img src="/static/img/pete.avif" alt="Pete" width="24" height="24"
class="h-6 w-6 rounded-full object-cover">
<span data-pete-greeting>Hey, I'm Pete. Here's what's new.</span>
</p>
</section>
<section data-weather-card class="mb-10 empty:hidden"></section>
{{if .Trending}}
<section class="mb-10">
<div class="flex items-end justify-between gap-3 mb-4">
<h2 class="font-display text-xl sm:text-2xl font-bold">
<span aria-hidden="true">🔥</span> Popular this week
</h2>
<span class="text-xs text-[color:var(--ink)]/50">what readers are opening most</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{{range .Trending}}
{{template "card" dict "Story" . "Theme" .Channel "ShowChannel" true}}
{{end}}
</div>
</section>
{{end}}
{{if .ForYou}}
<section class="mb-10">
<div class="flex items-end justify-between gap-3 mb-4">
@@ -104,7 +125,7 @@
</div>
{{else}}
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-dashed border-[color:var(--ink)]/15 p-8 text-center text-[color:var(--ink)]/60">
Nothing here yet Pete's still listening.
Nothing here yet. Pete's still listening for the first story.
</div>
{{end}}
</section>

View File

@@ -4,6 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}"><meta name="twitter:card" content="summary_large_image">{{end}}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
@@ -218,17 +220,51 @@
<div class="pete-reader-nav">
<button type="button" data-reader-prev class="pete-reader-btn" title="Previous (←)" aria-label="Previous article"></button>
<button type="button" data-reader-next class="pete-reader-btn" title="Next (→)" aria-label="Next article"></button>
<button type="button" data-reader-listen class="pete-reader-btn" style="display:none" title="Read aloud (r)" aria-label="Read this story aloud" aria-pressed="false">🔊 Listen</button>
<button type="button" data-reader-share class="pete-reader-btn" style="display:none" title="Share (s)" aria-label="Share this story">Share</button>
<div class="pete-reader-type">
<button type="button" data-reader-type class="pete-reader-btn" title="Text options (t)" aria-label="Text options" aria-expanded="false"><span style="font-size:1.05rem">A</span><span style="font-size:.8rem">a</span></button>
<div class="pete-reader-typemenu" data-reader-typemenu hidden>
<div class="pete-reader-typerow">
<span class="pete-reader-typelabel">Size</span>
<div class="pete-reader-typebtns" data-reader-size>
<button type="button" data-size="s" title="Small">S</button>
<button type="button" data-size="m" title="Medium">M</button>
<button type="button" data-size="l" title="Large">L</button>
<button type="button" data-size="xl" title="Extra large">XL</button>
</div>
</div>
<div class="pete-reader-typerow">
<span class="pete-reader-typelabel">Font</span>
<div class="pete-reader-typebtns" data-reader-font>
<button type="button" data-font="sans">Sans</button>
<button type="button" data-font="serif" style="font-family:Georgia,'Times New Roman',serif">Serif</button>
</div>
</div>
<div class="pete-reader-typerow">
<span class="pete-reader-typelabel">Paper</span>
<div class="pete-reader-typebtns" data-reader-paper>
<button type="button" data-paper="default">Default</button>
<button type="button" data-paper="sepia">Sepia</button>
</div>
</div>
<div class="pete-reader-typerow pete-reader-voicerow" data-reader-voicerow hidden>
<span class="pete-reader-typelabel">Voice</span>
<select class="pete-reader-voice" data-reader-voice aria-label="Read-aloud voice"></select>
</div>
</div>
</div>
<button type="button" data-reader-bookmark class="pete-reader-btn" style="display:none" title="Bookmark (b)" aria-label="Bookmark this story" aria-pressed="false">🔖 Save</button>
<a data-reader-link target="_blank" rel="noopener noreferrer" class="pete-reader-btn pete-reader-btn-open" title="Open original (o)">Open&nbsp;</a>
<button type="button" data-reader-close class="pete-reader-btn" title="Close (esc)" aria-label="Close reader">esc</button>
</div>
</div>
<div class="pete-reader-scroll" data-reader-scroll>
<div class="pete-reader-scroll" data-reader-scroll tabindex="-1">
<article class="pete-reader-article" data-reader-article></article>
<div class="pete-reader-related" data-reader-related hidden></div>
</div>
<div class="pete-reader-hint">
<span><kbd></kbd> <kbd></kbd> navigate · <kbd>o</kbd> open · <kbd>u</kbd> mark unread · <kbd>esc</kbd> close</span>
<span><kbd></kbd> <kbd></kbd> navigate · <kbd>o</kbd> open · <kbd>s</kbd> share · <kbd>t</kbd> text · <kbd>u</kbd> unread · <kbd>esc</kbd> close</span>
</div>
</div>
</div>
@@ -244,8 +280,16 @@
</footer>
<script>
// Tiny clock + phase label updater for the header pill / footer.
// Tiny clock + phase label updater for the header pill / footer, plus Pete's
// time-of-day greeting on the home hero (whichever of these exist).
(function () {
function greeting(h) {
if (h >= 5 && h < 8) return "Early start? Here's what came in overnight.";
if (h >= 8 && h < 12) return "Morning! Here's what's fresh.";
if (h >= 12 && h < 17) return "Afternoon. Here's what's new.";
if (h >= 17 && h < 21) return "Evening. Here's what you missed today.";
return "Late one? Here's the quiet-hours roundup.";
}
function tick() {
var now = new Date();
var hh = String(now.getHours()).padStart(2, "0");
@@ -254,6 +298,8 @@
if (el) el.textContent = hh + ":" + mm;
var label = document.querySelector("[data-phase-label]");
if (label) label.textContent = document.documentElement.dataset.phase;
var greet = document.querySelector("[data-pete-greeting]");
if (greet) greet.textContent = greeting(now.getHours());
}
tick();
setInterval(tick, 30000);
@@ -265,8 +311,11 @@
window.PETE_USER = {{if .User}}{ name: {{.User.Display}}, email: {{.User.Email}} }{{else}}null{{end}};
window.PETE_PREFS = {{.UserPrefs}};
window.PETE_PUSH = {{if .PushEnabled}}{ enabled: true, publicKey: {{.PushPublicKey}} }{{else}}null{{end}};
window.PETE_TTS = {{.TTS}};
</script>
<script src="/static/js/prefs.js" defer></script>
<script src="/static/js/weather-2d.js" defer></script>
<script src="/static/js/weather-gl.js" defer></script>
<script src="/static/js/weather.js" defer></script>
<script src="/static/js/weather-forecast.js" defer></script>
<script src="/static/js/search.js" defer></script>

View File

@@ -0,0 +1,27 @@
{{define "title"}}{{.Headline}} — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> All dispatches
</a>
</nav>
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.EventLabel}}</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Headline}}</h1>
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">Reported {{.When}}{{if .Region}} · {{.Region}}{{end}}</p>
</div>
</header>
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
Reporting from the realm, this is Pete.
</p>
</div>
</article>
{{end}}

View File

@@ -3,14 +3,21 @@
{{define "main"}}
<div class="space-y-8">
<section class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<h1 class="font-display text-3xl font-bold mb-1">Weather demo</h1>
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. The canvas reloads on each click.</p>
<div class="flex flex-wrap items-start justify-between gap-3 mb-1">
<h1 class="font-display text-3xl font-bold">Weather demo</h1>
<div class="flex items-center gap-2 text-xs font-semibold">
<span data-demo-renderer class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 uppercase tracking-wider text-[color:var(--ink)]/60">renderer: …</span>
<span data-demo-fps class="rounded-full border-2 border-[color:var(--ink)]/10 px-3 py-1 tabular-nums text-[color:var(--ink)]/60">— fps</span>
</div>
</div>
<p class="text-sm text-[color:var(--ink)]/60 mb-5">Pick a variant, intensity, and phase. Switching is instant, no reload.</p>
<div class="space-y-3 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Variant</span>
{{range $v := .Variants}}
<a href="?variant={{$v}}&amp;intensity={{$.Intensity}}&amp;phase={{$.PhaseSel}}"
data-demo-variant="{{$v}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.Variant $v}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$v}}</a>
{{end}}
@@ -19,6 +26,7 @@
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Intensity</span>
{{range $i := .Intensities}}
<a href="?variant={{$.Variant}}&amp;intensity={{$i}}&amp;phase={{$.PhaseSel}}"
data-demo-intensity="{{$i}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.Intensity $i}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$i}}</a>
{{end}}
@@ -27,6 +35,7 @@
<span class="w-20 shrink-0 font-semibold uppercase tracking-wider text-[color:var(--ink)]/60">Phase</span>
{{range $p := .Phases}}
<a href="?variant={{$.Variant}}&amp;intensity={{$.Intensity}}&amp;phase={{$p}}"
data-demo-phase="{{$p}}"
class="rounded-full px-3 py-1 transition border-2 border-[color:var(--ink)]/10
{{if eq $.PhaseSel $p}}bg-[color:var(--accent)] text-white font-semibold{{else}}hover:bg-[color:var(--ink)]/5{{end}}">{{$p}}</a>
{{end}}
@@ -34,7 +43,8 @@
</div>
<div class="mt-5 text-xs text-[color:var(--ink)]/50">
Current: <code class="font-semibold">{{.Weather.Season}} / {{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
Current: <code data-demo-current class="font-semibold">{{.Variant}} / {{.Intensity}} / {{.PhaseSel}}</code>
<span class="mx-1">·</span> Tip: aurora and clear night want the night phase; wind and haze are the new autumn gusts and Saharan calima.
</div>
</section>
@@ -50,8 +60,76 @@
</div>
<div class="rounded-3xl bg-[color:var(--card)] p-6 shadow-pete border-2 border-[color:var(--ink)]/10">
<p class="font-display">Card B</p>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Move your window taller to give the particles more room.</p>
<p class="text-sm text-[color:var(--ink)]/60 mt-1">Make your window taller to give the particles more room.</p>
</div>
</section>
</div>
<script>
// Instant switching: the pill links stay real URLs for no-JS visitors, but
// with JS we swap the effect in place via PeteWeather.set and keep the URL
// in sync with replaceState.
document.addEventListener("DOMContentLoaded", function () {
var root = document.documentElement;
var state = {
variant: {{.Variant}},
intensity: {{.Intensity}},
phase: {{.PhaseSel}}
};
function markActive(attr, value) {
document.querySelectorAll("[" + attr + "]").forEach(function (el) {
var on = el.getAttribute(attr) === value;
el.classList.toggle("bg-[color:var(--accent)]", on);
el.classList.toggle("text-white", on);
el.classList.toggle("font-semibold", on);
});
}
function apply() {
root.dataset.phase = state.phase;
if (window.PeteWeather) window.PeteWeather.set(state.variant, state.intensity);
markActive("data-demo-variant", state.variant);
markActive("data-demo-intensity", state.intensity);
markActive("data-demo-phase", state.phase);
var cur = document.querySelector("[data-demo-current]");
if (cur) cur.textContent = state.variant + " / " + state.intensity + " / " + state.phase;
var label = document.querySelector("[data-phase-label]");
if (label) label.textContent = state.phase;
history.replaceState(null, "", "?variant=" + state.variant + "&intensity=" + state.intensity + "&phase=" + state.phase);
}
document.addEventListener("click", function (e) {
var a = e.target.closest && e.target.closest("[data-demo-variant],[data-demo-intensity],[data-demo-phase]");
if (!a || !window.PeteWeather) return;
e.preventDefault();
if (a.hasAttribute("data-demo-variant")) state.variant = a.getAttribute("data-demo-variant");
if (a.hasAttribute("data-demo-intensity")) state.intensity = a.getAttribute("data-demo-intensity");
if (a.hasAttribute("data-demo-phase")) state.phase = a.getAttribute("data-demo-phase");
apply();
});
var rEl = document.querySelector("[data-demo-renderer]");
if (rEl && window.PeteWeather && window.PeteWeather.renderer) {
rEl.textContent = "renderer: " + window.PeteWeather.renderer();
}
// Lightweight FPS meter, demo page only. Counts frames on its own rAF and
// refreshes the pill once a second.
var fpsEl = document.querySelector("[data-demo-fps]");
if (fpsEl) {
var frames = 0, lastStamp = performance.now();
function tick(now) {
frames++;
if (now - lastStamp >= 1000) {
fpsEl.textContent = Math.round(frames * 1000 / (now - lastStamp)) + " fps";
frames = 0;
lastStamp = now;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
});
</script>
{{end}}

View File

@@ -67,6 +67,14 @@ func thumbURL(src string) string {
if src == "" {
return ""
}
// Root-relative sources are our own local assets (e.g. the adventure
// emblems) — serve them directly; the thumbnailer only handles remote
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
// it's a protocol-relative remote image, and it has to keep going through
// the guarded thumbnailer like any other third-party URL.
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
return src
}
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
}

View File

@@ -0,0 +1,69 @@
package web
import (
"path/filepath"
"strings"
"testing"
"time"
"net/http/httptest"
"pete/internal/config"
"pete/internal/storage"
)
// TestIndexTrendingAndBadges seeds a story with captured content and some reader
// views, then asserts the home page renders the "Popular this week" rail plus the
// per-card reading-time chip and read-count badge that decorate() fills in.
func TestIndexTrendingAndBadges(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "trending.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
// ~1200 chars of body → about a 1 min read.
body := strings.Repeat("word ", 240)
story := &storage.Story{
GUID: "trend-e2e",
Headline: "A Trending Headline",
Lede: "Lede.",
Content: body,
ArticleURL: "https://example.com/trend",
Source: "Example Wire",
Channel: "tech",
Classified: true,
SeenAt: time.Now().Unix(),
}
if err := storage.InsertStory(story); err != nil {
t.Fatal(err)
}
var id int64
if err := storage.Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, story.GUID).Scan(&id); err != nil {
t.Fatal(err)
}
// Two reads so the story trends and the badge shows a count.
storage.RecordStoryView(id)
storage.RecordStoryView(id)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
rw := httptest.NewRecorder()
s.handleIndex(rw, httptest.NewRequest("GET", "/", nil))
if rw.Code != 200 {
t.Fatalf("index status = %d", rw.Code)
}
body = rw.Body.String()
for _, want := range []string{
"Popular this week", // trending rail heading
"min read", // reading-time chip
`title="2 reads"`, // view-count badge
} {
if !strings.Contains(body, want) {
t.Errorf("index HTML missing %q", want)
}
}
}

199
internal/web/tts.go Normal file
View File

@@ -0,0 +1,199 @@
package web
import (
"bytes"
"context"
"encoding/json"
"html/template"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"pete/internal/config"
)
const (
ttsMaxTextLen = 6000 // per-request cap; a single paragraph is ~1-2k chars
ttsTimeout = 60 * time.Second
ttsMaxConcurrent = 3 // cap simultaneous piper processes so a burst can't pin the box
)
// ttsVoice is one selectable voice, as sent to the client.
type ttsVoice struct {
ID string `json:"id"`
Label string `json:"label"`
}
// ttsService runs Piper (https://github.com/rhasspy/piper) as a subprocess to
// synthesize read-aloud audio server-side. It holds the validated voice
// registry and a concurrency semaphore; there is no long-lived process, each
// request spawns a short-lived piper that loads its model, synthesizes one
// chunk of text to a WAV on stdout, and exits (~0.5s for a paragraph).
type ttsService struct {
piperBin string
voices []ttsVoice // menu order, for the client selector
models map[string]string // voice id -> absolute .onnx path
def string // default voice id
sem chan struct{} // buffered to ttsMaxConcurrent
}
// newTTS validates the Piper install and builds the voice registry. It returns
// (nil, nil) when TTS is disabled. A configured voice whose model file is
// missing is skipped with a warning rather than failing startup; if that leaves
// no usable voices, TTS is disabled.
func newTTS(cfg config.TTSConfig) (*ttsService, error) {
if !cfg.Enabled {
return nil, nil
}
bin := cfg.PiperBin
if bin == "" {
bin = "piper"
}
if p, err := exec.LookPath(bin); err != nil {
slog.Error("web: TTS enabled but piper binary not found; read-aloud disabled", "piper_bin", bin, "err", err)
return nil, nil
} else {
bin = p
}
dir := cfg.VoicesDir
svc := &ttsService{piperBin: bin, models: make(map[string]string)}
want := cfg.Voices
if len(want) == 0 {
// Auto-discover every *.onnx in voices_dir, labelled by filename stem.
entries, err := os.ReadDir(dir)
if err != nil {
slog.Error("web: TTS voices_dir unreadable; read-aloud disabled", "voices_dir", dir, "err", err)
return nil, nil
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".onnx") {
continue
}
id := strings.TrimSuffix(name, ".onnx")
want = append(want, config.VoiceConfig{ID: id, Label: id})
}
sort.Slice(want, func(i, j int) bool { return want[i].ID < want[j].ID })
}
for _, v := range want {
if v.ID == "" {
continue
}
model := filepath.Join(dir, v.ID+".onnx")
if _, err := os.Stat(model); err != nil {
slog.Warn("web: TTS voice model missing; skipping", "voice", v.ID, "path", model)
continue
}
label := v.Label
if label == "" {
label = v.ID
}
svc.models[v.ID] = model
svc.voices = append(svc.voices, ttsVoice{ID: v.ID, Label: label})
}
if len(svc.voices) == 0 {
slog.Error("web: TTS enabled but no usable voices found; read-aloud disabled", "voices_dir", dir)
return nil, nil
}
svc.def = cfg.Default
if _, ok := svc.models[svc.def]; !ok {
svc.def = svc.voices[0].ID
}
svc.sem = make(chan struct{}, ttsMaxConcurrent)
slog.Info("web: server-side TTS enabled", "piper", bin, "voices", len(svc.voices), "default", svc.def)
return svc, nil
}
// clientConfig is the JSON handed to the page as window.PETE_TTS so the reader
// can build its voice selector and know TTS is available.
func (t *ttsService) clientConfig() template.JS {
payload := struct {
Enabled bool `json:"enabled"`
Default string `json:"default"`
Voices []ttsVoice `json:"voices"`
}{Enabled: true, Default: t.def, Voices: t.voices}
b, err := json.Marshal(payload)
if err != nil {
return template.JS("null")
}
return jsForScript(b)
}
// handleTTS synthesizes one chunk of text to WAV audio with the requested
// voice. It is registered only under the authenticated route group, so callers
// are already signed in (read-aloud is a signed-in perk). The request body is
// JSON {voice, text}; the response is audio/wav.
func (s *Server) handleTTS(w http.ResponseWriter, r *http.Request) {
if s.tts == nil {
http.Error(w, "tts disabled", http.StatusNotFound)
return
}
if s.auth == nil || s.auth.userFromRequest(r) == nil {
http.Error(w, "sign-in required", http.StatusUnauthorized)
return
}
var req struct {
Voice string `json:"voice"`
Text string `json:"text"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
text := strings.TrimSpace(req.Text)
if text == "" {
http.Error(w, "empty text", http.StatusBadRequest)
return
}
if len(text) > ttsMaxTextLen {
text = text[:ttsMaxTextLen]
}
model, ok := s.tts.models[req.Voice]
if !ok {
model = s.tts.models[s.tts.def] // unknown/blank voice -> default
}
// Bound concurrent piper processes; give up if the client leaves or we wait
// too long for a slot rather than queueing unboundedly.
slotCtx, cancelSlot := context.WithTimeout(r.Context(), ttsTimeout)
defer cancelSlot()
select {
case s.tts.sem <- struct{}{}:
defer func() { <-s.tts.sem }()
case <-slotCtx.Done():
http.Error(w, "tts busy", http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), ttsTimeout)
defer cancel()
// piper -m <model> -f - : write a full WAV to stdout. Text is fed on stdin,
// so there is no shell and nothing user-controlled reaches the arg list
// besides the model path, which is looked up from a fixed allowlist above.
cmd := exec.CommandContext(ctx, s.tts.piperBin, "-m", model, "-f", "-")
cmd.Stdin = strings.NewReader(text)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
slog.Error("web: piper synthesis failed", "err", err, "stderr", strings.TrimSpace(errb.String()))
http.Error(w, "synthesis failed", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "audio/wav")
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Header().Set("Content-Length", strconv.Itoa(out.Len()))
_, _ = w.Write(out.Bytes())
}

35
main.go
View File

@@ -248,6 +248,13 @@ func main() {
if postingEnabled && roundRobinMode {
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
for name := range cfg.Matrix.Channels {
// The adventure channel drives its own posting: priority beats go
// live at ingest, bulletins wait for the daily digest. Leaving it in
// the rotation would let the scheduler post bulletins one-by-one
// (they're "classified, not yet posted") and steal them from the digest.
if cfg.Adventure.Enabled && name == cfg.Adventure.Channel {
continue
}
channelNames = append(channelNames, name)
}
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
@@ -258,14 +265,35 @@ func main() {
}
}
// Adapter: the web ingest handler posts priority adventure beats live to
// Matrix through the same queue everything else uses (bypasses pacing).
// PostNow doesn't consult posting.enabled, so gate here: with posting off,
// adventure stays website-only like every other channel (nil poster also
// keeps the digest loop from starting).
var advPost web.PriorityPoster
if postingEnabled {
advPost = func(p web.AdvPost) {
queue.PostNow(poster.QueueItem{
GUID: p.GUID,
Headline: p.Headline,
Lede: p.Lede,
ImageURL: p.ImageURL,
ArticleURL: p.ArticleURL,
Source: p.Source,
Channel: p.Channel,
})
}
}
// Start the read-only web UI alongside the Matrix bot.
if cfg.Web.Enabled {
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled)
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled, cfg.Adventure, advPost)
if err != nil {
slog.Error("web server init failed", "err", err)
} else {
go ws.Start(ctx)
ws.StartPushSender(ctx)
ws.StartAdventureDigest(ctx)
}
}
@@ -370,8 +398,9 @@ func runLocal(cfg *config.Config) {
poller.Start(ctx)
slog.Info("local: pollers started")
// Local mode never connects to Matrix, so it's always web-only.
ws, err := web.New(cfg.Web, cfg.Sources, false)
// Local mode never connects to Matrix, so it's always web-only: adventure
// stories still ingest and render, but nothing posts (nil priority poster).
ws, err := web.New(cfg.Web, cfg.Sources, false, cfg.Adventure, nil)
if err != nil {
slog.Error("local: web server init failed", "err", err)
os.Exit(1)