Add full-text search, EU channel theme, and web-only sources

Search: new FTS5-backed SearchStories query, /search JSON endpoint,
client-side overlay (search.js) wired into the layout header. EU
channel gets its own theme color (#003399) across bg/text/border/glow
classes. Sources can now route to non-Matrix channels without
validation error (web-only mode); a warning still flags typos.
This commit is contained in:
prosolis
2026-05-25 11:23:52 -07:00
parent 509a0fc7a7
commit ec1f130ed5
8 changed files with 363 additions and 28 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"log/slog"
"strings"
"time"
)
@@ -347,6 +348,67 @@ func SetRoundRobinState(lastChannel string, tickAt int64) {
lastChannel, tickAt)
}
// SearchStories runs an FTS5 query against headline + lede and returns the
// best-ranked classified stories (real channels only), newest-ish first via
// bm25. The user query is tokenized and each token becomes a prefix match;
// FTS5 special characters are stripped so user input cannot break syntax.
func SearchStories(query string, limit int) ([]Story, error) {
fts := buildFTSQuery(query)
if fts == "" {
return nil, nil
}
rows, err := Get().Query(
`SELECT 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_fts f
JOIN stories s ON s.id = f.rowid
WHERE f.stories_fts MATCH ?
AND s.classified = 1
AND s.channel IS NOT NULL
AND s.channel NOT IN ('_discarded', '_duplicate')
ORDER BY bm25(stories_fts) ASC, s.seen_at DESC
LIMIT ?`, fts, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&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()
}
func buildFTSQuery(raw string) string {
var tokens []string
var cur strings.Builder
flush := func() {
if cur.Len() > 0 {
tokens = append(tokens, cur.String())
cur.Reset()
}
}
for _, r := range raw {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
cur.WriteRune(r)
case r > 127:
cur.WriteRune(r)
default:
flush()
}
}
flush()
for i, t := range tokens {
tokens[i] = "\"" + t + "\"*"
}
return strings.Join(tokens, " ")
}
// MarshalPlatforms converts a string slice to a JSON array string for storage.
func MarshalPlatforms(platforms []string) string {
if len(platforms) == 0 {