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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user