nandi/gleanpublic Fork 0
0be7f42
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add daily digest featureUnverified

Julien Robert committed 2026-05-17T21:00:53+02:00 Browse files
0be7f42 parent: 4bd7c6a
modified internal/db/article.go +27 -0
@@ -318,6 +318,33 @@ func (s *ArticleStore) MarkAllSubscribedRead(ctx context.Context, userDID string
318318 return err
319319 }
320320
321+func (s *ArticleStore) MarkArticlesRead(ctx context.Context, userDID string, ids []int64) error {
322+ if len(ids) == 0 {
323+ return nil
324+ }
325+ tx, err := s.db.BeginTx(ctx, nil)
326+ if err != nil {
327+ return err
328+ }
329+ defer tx.Rollback()
330+ stmt, err := tx.PrepareContext(ctx, `
331+ INSERT INTO articles.read_state (user_did, article_id, is_read, read_at)
332+ VALUES (?, ?, 1, CURRENT_TIMESTAMP)
333+ ON CONFLICT(user_did, article_id) DO UPDATE SET
334+ is_read = 1, read_at = CURRENT_TIMESTAMP
335+ `)
336+ if err != nil {
337+ return err
338+ }
339+ defer stmt.Close()
340+ for _, id := range ids {
341+ if _, err := stmt.ExecContext(ctx, userDID, id); err != nil {
342+ return err
343+ }
344+ }
345+ return tx.Commit()
346+}
347+
321348 func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) {
322349 rs := &ReadState{}
323350 err := s.db.QueryRowContext(ctx, `
@@ -318,6 +318,33 @@ func (s *ArticleStore) MarkAllSubscribedRead(ctx context.Context, userDID string
318 return err318 return err
319 }319 }
320 320
321+func (s *ArticleStore) MarkArticlesRead(ctx context.Context, userDID string, ids []int64) error {
322+ if len(ids) == 0 {
323+ return nil
324+ }
325+ tx, err := s.db.BeginTx(ctx, nil)
326+ if err != nil {
327+ return err
328+ }
329+ defer tx.Rollback()
330+ stmt, err := tx.PrepareContext(ctx, `
331+ INSERT INTO articles.read_state (user_did, article_id, is_read, read_at)
332+ VALUES (?, ?, 1, CURRENT_TIMESTAMP)
333+ ON CONFLICT(user_did, article_id) DO UPDATE SET
334+ is_read = 1, read_at = CURRENT_TIMESTAMP
335+ `)
336+ if err != nil {
337+ return err
338+ }
339+ defer stmt.Close()
340+ for _, id := range ids {
341+ if _, err := stmt.ExecContext(ctx, userDID, id); err != nil {
342+ return err
343+ }
344+ }
345+ return tx.Commit()
346+}
347+
321 func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) {348 func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) {
322 rs := &ReadState{}349 rs := &ReadState{}
323 err := s.db.QueryRowContext(ctx, `350 err := s.db.QueryRowContext(ctx, `
modified internal/db/db.go +1 -0
@@ -257,6 +257,7 @@ var usersSchema = []string{
257257 did TEXT PRIMARY KEY,
258258 languages TEXT,
259259 expanded_view BOOLEAN NOT NULL DEFAULT 0,
260+ digest_enabled BOOLEAN NOT NULL DEFAULT 0,
260261 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
261262 )`,
262263
@@ -257,6 +257,7 @@ var usersSchema = []string{
257 did TEXT PRIMARY KEY,257 did TEXT PRIMARY KEY,
258 languages TEXT,258 languages TEXT,
259 expanded_view BOOLEAN NOT NULL DEFAULT 0,259 expanded_view BOOLEAN NOT NULL DEFAULT 0,
260+ digest_enabled BOOLEAN NOT NULL DEFAULT 0,
260 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP261 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
261 )`,262 )`,
262 263
modified internal/db/migrations.go +20 -1
@@ -13,7 +13,7 @@ func init() {
1313 }
1414
1515 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).
16-const SchemaVersion = 6
16+const SchemaVersion = 7
1717
1818 type migration struct {
1919 id int
@@ -52,6 +52,11 @@ var migrations = []migration{
5252 name: "drop_redundant_follows_user_index",
5353 run: migrateDropFollowsUserIndex,
5454 },
55+ {
56+ id: 7,
57+ name: "user_settings_digest_enabled",
58+ run: migrateUserSettingsDigestEnabled,
59+ },
5560 }
5661
5762 func runMigrations(db *DB) error {
@@ -254,6 +259,20 @@ func migrateUserSettingsExpandedView(db *DB) error {
254259 return nil
255260 }
256261
262+func migrateUserSettingsDigestEnabled(db *DB) error {
263+ var colCount int
264+ err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('user_settings') WHERE name='digest_enabled'").Scan(&colCount)
265+ if err != nil {
266+ return fmt.Errorf("check user_settings.digest_enabled column: %w", err)
267+ }
268+ if colCount == 0 {
269+ if _, err := db.Exec("ALTER TABLE user_settings ADD COLUMN digest_enabled BOOLEAN NOT NULL DEFAULT 0"); err != nil {
270+ return fmt.Errorf("add user_settings.digest_enabled: %w", err)
271+ }
272+ }
273+ return nil
274+}
275+
257276 func migrateDropFollowsUserIndex(db *DB) error {
258277 for _, idx := range []string{
259278 "idx_follows_user",
@@ -13,7 +13,7 @@ func init() {
13 }13 }
14 14
15 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).15 // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations).
16-const SchemaVersion = 616+const SchemaVersion = 7
17 17
18 type migration struct {18 type migration struct {
19 id int19 id int
@@ -52,6 +52,11 @@ var migrations = []migration{
52 name: "drop_redundant_follows_user_index",52 name: "drop_redundant_follows_user_index",
53 run: migrateDropFollowsUserIndex,53 run: migrateDropFollowsUserIndex,
54 },54 },
55+ {
56+ id: 7,
57+ name: "user_settings_digest_enabled",
58+ run: migrateUserSettingsDigestEnabled,
59+ },
55 }60 }
56 61
57 func runMigrations(db *DB) error {62 func runMigrations(db *DB) error {
@@ -254,6 +259,20 @@ func migrateUserSettingsExpandedView(db *DB) error {
254 return nil259 return nil
255 }260 }
256 261
262+func migrateUserSettingsDigestEnabled(db *DB) error {
263+ var colCount int
264+ err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('user_settings') WHERE name='digest_enabled'").Scan(&colCount)
265+ if err != nil {
266+ return fmt.Errorf("check user_settings.digest_enabled column: %w", err)
267+ }
268+ if colCount == 0 {
269+ if _, err := db.Exec("ALTER TABLE user_settings ADD COLUMN digest_enabled BOOLEAN NOT NULL DEFAULT 0"); err != nil {
270+ return fmt.Errorf("add user_settings.digest_enabled: %w", err)
271+ }
272+ }
273+ return nil
274+}
275+
257 func migrateDropFollowsUserIndex(db *DB) error {276 func migrateDropFollowsUserIndex(db *DB) error {
258 for _, idx := range []string{277 for _, idx := range []string{
259 "idx_follows_user",278 "idx_follows_user",
modified internal/db/user_settings.go +16 -6
@@ -7,22 +7,24 @@ import (
77 )
88
99 type UserSettings struct {
10- DID string
11- Languages []string
12- ExpandedView bool
10+ DID string
11+ Languages []string
12+ ExpandedView bool
13+ DigestEnabled bool
1314 }
1415
1516 func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) {
1617 var langs sql.NullString
1718 var expandedView bool
18- err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView)
19+ var digestEnabled bool
20+ err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0), COALESCE(digest_enabled, 1) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView, &digestEnabled)
1921 if err != nil {
2022 if err == sql.ErrNoRows {
21- return &UserSettings{DID: did}, nil
23+ return &UserSettings{DID: did, DigestEnabled: false}, nil
2224 }
2325 return nil, err
2426 }
25- us := &UserSettings{DID: did, ExpandedView: expandedView}
27+ us := &UserSettings{DID: did, ExpandedView: expandedView, DigestEnabled: digestEnabled}
2628 if langs.Valid && langs.String != "" {
2729 us.Languages = strings.Split(langs.String, ",")
2830 }
@@ -56,3 +58,11 @@ func (s *UserStore) SetExpandedView(ctx context.Context, did string, enabled boo
5658 `, did, enabled)
5759 return err
5860 }
61+
62+func (s *UserStore) SetDigestEnabled(ctx context.Context, did string, enabled bool) error {
63+ _, err := s.db.ExecContext(ctx, `
64+ INSERT INTO user_settings (did, digest_enabled, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
65+ ON CONFLICT(did) DO UPDATE SET digest_enabled = excluded.digest_enabled, updated_at = CURRENT_TIMESTAMP
66+ `, did, enabled)
67+ return err
68+}
@@ -7,22 +7,24 @@ import (
7 )7 )
8 8
9 type UserSettings struct {9 type UserSettings struct {
10- DID string10+ DID string
11- Languages []string11+ Languages []string
12- ExpandedView bool12+ ExpandedView bool
13+ DigestEnabled bool
13 }14 }
14 15
15 func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) {16 func (s *UserStore) GetSettings(ctx context.Context, did string) (*UserSettings, error) {
16 var langs sql.NullString17 var langs sql.NullString
17 var expandedView bool18 var expandedView bool
18- err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView)19+ var digestEnabled bool
20+ err := s.db.QueryRowContext(ctx, `SELECT languages, COALESCE(expanded_view, 0), COALESCE(digest_enabled, 1) FROM user_settings WHERE did = ?`, did).Scan(&langs, &expandedView, &digestEnabled)
19 if err != nil {21 if err != nil {
20 if err == sql.ErrNoRows {22 if err == sql.ErrNoRows {
21- return &UserSettings{DID: did}, nil23+ return &UserSettings{DID: did, DigestEnabled: false}, nil
22 }24 }
23 return nil, err25 return nil, err
24 }26 }
25- us := &UserSettings{DID: did, ExpandedView: expandedView}27+ us := &UserSettings{DID: did, ExpandedView: expandedView, DigestEnabled: digestEnabled}
26 if langs.Valid && langs.String != "" {28 if langs.Valid && langs.String != "" {
27 us.Languages = strings.Split(langs.String, ",")29 us.Languages = strings.Split(langs.String, ",")
28 }30 }
@@ -56,3 +58,11 @@ func (s *UserStore) SetExpandedView(ctx context.Context, did string, enabled boo
56 `, did, enabled)58 `, did, enabled)
57 return err59 return err
58 }60 }
61+
62+func (s *UserStore) SetDigestEnabled(ctx context.Context, did string, enabled bool) error {
63+ _, err := s.db.ExecContext(ctx, `
64+ INSERT INTO user_settings (did, digest_enabled, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
65+ ON CONFLICT(did) DO UPDATE SET digest_enabled = excluded.digest_enabled, updated_at = CURRENT_TIMESTAMP
66+ `, did, enabled)
67+ return err
68+}
modified internal/ml/llm.go +52 -0
@@ -11,6 +11,7 @@ import (
1111
1212 type TextModel interface {
1313 DetectLanguages(ctx context.Context, texts []string) ([]string, error)
14+ GenerateDigest(ctx context.Context, articleTitles []string, trendingTopics []string) (title, body string, err error)
1415 }
1516
1617 type llm struct {
@@ -76,3 +77,54 @@ func (c *llm) DetectLanguages(ctx context.Context, texts []string) ([]string, er
7677 }
7778 return result, nil
7879 }
80+
81+func (c *llm) GenerateDigest(ctx context.Context, articleTitles []string, trendingTopics []string) (string, string, error) {
82+ var b strings.Builder
83+ b.WriteString(`You are the editor of a sharply written daily briefing for a tech-savvy reader. Write a digest that groups the day's articles into thematic sections.
84+
85+Rules:
86+- First line must be a newspaper-style headline (max 8 words) that captures the dominant theme or tension across the articles. Be specific — name the biggest topic, company, or conflict. Never use meta-phrases like "Daily digest", "Today's briefing", or "Your daily roundup". Just the raw headline, no quotes, no prefix.
87+- Then a blank line, then the body.
88+- Organize into 3-5 thematic sections. Each section has:
89+ - A bold inline heading wrapped in <strong> tags (2-4 words, e.g. <strong>Open source</strong>).
90+ - Followed by a colon and 2-3 sentences weaving the relevant articles into a narrative.
91+- When you mention an article from the numbered list, append its number in brackets right after, like: "Nvidia raised prices on the RTX 5090 [1]." Do this for every article you reference.
92+- Be specific — name companies, projects, and technologies. Vague summaries are useless.
93+- Use a confident, conversational tone. Short sentences. No filler, no hedging.
94+- End with a one-line note on the most interesting or under-the-radar article.
95+- Use <p> tags to separate sections. Each <p> contains exactly one theme.
96+- Don't write a theme if there aren't at least 2 articles for it. Pick the most interesting and diverse themes.
97+
98+`)
99+
100+ fmt.Fprintf(&b, "Here are the %d most recent unread articles (each has a title, optional feed name in brackets, URL, and content excerpt):\n\n", len(articleTitles))
101+ for i, t := range articleTitles {
102+ if i >= 50 {
103+ break
104+ }
105+ fmt.Fprintf(&b, "%d. %s\n", i+1, t)
106+ }
107+ if len(trendingTopics) > 0 {
108+ b.WriteString("\nTrending in the reader's network:\n")
109+ for _, t := range trendingTopics {
110+ fmt.Fprintf(&b, "- %s\n", t)
111+ }
112+ }
113+
114+ resp, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
115+ Model: c.model,
116+ Messages: []openai.ChatCompletionMessageParamUnion{
117+ openai.UserMessage(b.String()),
118+ },
119+ Temperature: openai.Float(0.7),
120+ })
121+ if err != nil {
122+ return "", "", err
123+ }
124+
125+ content := strings.TrimSpace(resp.Choices[0].Message.Content)
126+ title, body, _ := strings.Cut(content, "\n")
127+ title = strings.TrimSpace(title)
128+ body = strings.TrimSpace(body)
129+ return title, body, nil
130+}
@@ -11,6 +11,7 @@ import (
11 11
12 type TextModel interface {12 type TextModel interface {
13 DetectLanguages(ctx context.Context, texts []string) ([]string, error)13 DetectLanguages(ctx context.Context, texts []string) ([]string, error)
14+ GenerateDigest(ctx context.Context, articleTitles []string, trendingTopics []string) (title, body string, err error)
14 }15 }
15 16
16 type llm struct {17 type llm struct {
@@ -76,3 +77,54 @@ func (c *llm) DetectLanguages(ctx context.Context, texts []string) ([]string, er
76 }77 }
77 return result, nil78 return result, nil
78 }79 }
80+
81+func (c *llm) GenerateDigest(ctx context.Context, articleTitles []string, trendingTopics []string) (string, string, error) {
82+ var b strings.Builder
83+ b.WriteString(`You are the editor of a sharply written daily briefing for a tech-savvy reader. Write a digest that groups the day's articles into thematic sections.
84+
85+Rules:
86+- First line must be a newspaper-style headline (max 8 words) that captures the dominant theme or tension across the articles. Be specific — name the biggest topic, company, or conflict. Never use meta-phrases like "Daily digest", "Today's briefing", or "Your daily roundup". Just the raw headline, no quotes, no prefix.
87+- Then a blank line, then the body.
88+- Organize into 3-5 thematic sections. Each section has:
89+ - A bold inline heading wrapped in <strong> tags (2-4 words, e.g. <strong>Open source</strong>).
90+ - Followed by a colon and 2-3 sentences weaving the relevant articles into a narrative.
91+- When you mention an article from the numbered list, append its number in brackets right after, like: "Nvidia raised prices on the RTX 5090 [1]." Do this for every article you reference.
92+- Be specific — name companies, projects, and technologies. Vague summaries are useless.
93+- Use a confident, conversational tone. Short sentences. No filler, no hedging.
94+- End with a one-line note on the most interesting or under-the-radar article.
95+- Use <p> tags to separate sections. Each <p> contains exactly one theme.
96+- Don't write a theme if there aren't at least 2 articles for it. Pick the most interesting and diverse themes.
97+
98+`)
99+
100+ fmt.Fprintf(&b, "Here are the %d most recent unread articles (each has a title, optional feed name in brackets, URL, and content excerpt):\n\n", len(articleTitles))
101+ for i, t := range articleTitles {
102+ if i >= 50 {
103+ break
104+ }
105+ fmt.Fprintf(&b, "%d. %s\n", i+1, t)
106+ }
107+ if len(trendingTopics) > 0 {
108+ b.WriteString("\nTrending in the reader's network:\n")
109+ for _, t := range trendingTopics {
110+ fmt.Fprintf(&b, "- %s\n", t)
111+ }
112+ }
113+
114+ resp, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
115+ Model: c.model,
116+ Messages: []openai.ChatCompletionMessageParamUnion{
117+ openai.UserMessage(b.String()),
118+ },
119+ Temperature: openai.Float(0.7),
120+ })
121+ if err != nil {
122+ return "", "", err
123+ }
124+
125+ content := strings.TrimSpace(resp.Choices[0].Message.Content)
126+ title, body, _ := strings.Cut(content, "\n")
127+ title = strings.TrimSpace(title)
128+ body = strings.TrimSpace(body)
129+ return title, body, nil
130+}
modified internal/server/dashboard_handler.go +4 -0
@@ -125,6 +125,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
125125 }
126126 }
127127
128+ settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)
129+ digestEnabled := settings != nil && settings.DigestEnabled
130+
128131 s.render(w, r, "dashboard.html", map[string]any{
129132 "User": user,
130133 "SubscriptionCount": subCount,
@@ -137,6 +140,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
137140 "PersonalTrending": personalTrending,
138141 "GlobalTrending": globalTrending,
139142 "Now": time.Now(),
143+ "DigestEnabled": digestEnabled,
140144 })
141145 }
142146
@@ -125,6 +125,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
125 }125 }
126 }126 }
127 127
128+ settings, _ := s.dbs.Users.GetSettings(ctx, user.DID)
129+ digestEnabled := settings != nil && settings.DigestEnabled
130+
128 s.render(w, r, "dashboard.html", map[string]any{131 s.render(w, r, "dashboard.html", map[string]any{
129 "User": user,132 "User": user,
130 "SubscriptionCount": subCount,133 "SubscriptionCount": subCount,
@@ -137,6 +140,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
137 "PersonalTrending": personalTrending,140 "PersonalTrending": personalTrending,
138 "GlobalTrending": globalTrending,141 "GlobalTrending": globalTrending,
139 "Now": time.Now(),142 "Now": time.Now(),
143+ "DigestEnabled": digestEnabled,
140 })144 })
141 }145 }
142 146
added internal/server/digest_handler.go +286 -0
new file mode 100644
@@ -0,0 +1,286 @@
1+package server
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "regexp"
8+ "strconv"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "golang.org/x/sync/errgroup"
14+ "golang.org/x/sync/singleflight"
15+
16+ "pkg.rbrt.fr/glean/internal/db"
17+)
18+
19+type digestCtx struct {
20+ Title string
21+ Summary string
22+ Excerpt string
23+ ArticleIDs []int64
24+ GeneratedAt time.Time
25+ CSRFToken string
26+ Consumed bool
27+}
28+
29+type digestCacheEntry struct {
30+ html string
31+ expiry time.Time
32+ articleIDs []int64
33+ consumed bool
34+}
35+
36+var (
37+ digestCache sync.Map
38+ digestFlight singleflight.Group
39+)
40+
41+const digestTTL = 24 * time.Hour
42+
43+var refRe = regexp.MustCompile(`\[(\d+)\]`)
44+
45+func linkifyRefs(html string, articles []*db.Article) string {
46+ return refRe.ReplaceAllStringFunc(html, func(match string) string {
47+ n, err := strconv.Atoi(match[1 : len(match)-1])
48+ if err != nil || n < 1 || n > len(articles) {
49+ return match
50+ }
51+ a := articles[n-1]
52+ return fmt.Sprintf(`<a href="/articles/%d" class="text-spot-green hover:underline">[%d]</a>`, a.ID, n)
53+ })
54+}
55+
56+func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx {
57+ var (
58+ articles []*db.Article
59+ userLangs []string
60+ )
61+
62+ g, gCtx := errgroup.WithContext(ctx)
63+
64+ g.Go(func() error {
65+ var err error
66+ articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 50, 0)
67+ return err
68+ })
69+
70+ g.Go(func() error {
71+ var err error
72+ userLangs, err = s.dbs.Users.GetLanguages(gCtx, user.DID)
73+ return err
74+ })
75+
76+ if err := g.Wait(); err != nil {
77+ s.logger.Warn("digest data error", "error", err, "did", user.DID)
78+ return nil
79+ }
80+
81+ if len(articles) == 0 {
82+ return nil
83+ }
84+
85+ trending, err := s.engine.GetPersonalTrending(ctx, user.DID, userLangs, 5, 0)
86+ if err != nil {
87+ s.logger.Warn("failed to get trending for digest", "error", err, "did", user.DID)
88+ }
89+
90+ var articleEntries []string
91+ for _, a := range articles {
92+ var parts []string
93+ parts = append(parts, a.Title)
94+ if a.FeedTitle != "" {
95+ parts = append(parts, "["+a.FeedTitle+"]")
96+ }
97+ if a.URL.Valid && a.URL.String != "" {
98+ parts = append(parts, a.URL.String)
99+ }
100+ if content := digestArticleContent(a); content != "" {
101+ parts = append(parts, content)
102+ }
103+ articleEntries = append(articleEntries, strings.Join(parts, " "))
104+ }
105+
106+ trendingTopics := make([]string, len(trending))
107+ for i, t := range trending {
108+ trendingTopics[i] = t.Title
109+ }
110+
111+ title, summary, err := s.llm.GenerateDigest(ctx, articleEntries, trendingTopics)
112+ if err != nil {
113+ s.logger.Warn("failed to generate digest", "error", err, "did", user.DID)
114+ return nil
115+ }
116+
117+ summary = linkifyRefs(summary, articles)
118+
119+ return &digestCtx{
120+ Title: title,
121+ Summary: summary,
122+ Excerpt: excerptFromHTML(summary),
123+ ArticleIDs: func() []int64 {
124+ ids := make([]int64, len(articles))
125+ for i, a := range articles {
126+ ids[i] = a.ID
127+ }
128+ return ids
129+ }(),
130+ GeneratedAt: time.Now(),
131+ }
132+}
133+
134+const digestContentLimit = 500
135+
136+func digestArticleContent(a *db.Article) string {
137+ src := a.FullContent
138+ if !src.Valid || src.String == "" {
139+ src = a.Content
140+ }
141+ if !src.Valid || src.String == "" {
142+ src = a.Summary
143+ }
144+ if !src.Valid || src.String == "" {
145+ return ""
146+ }
147+ text := plainText(src.String)
148+ runes := []rune(text)
149+ if len(runes) > digestContentLimit {
150+ runes = runes[:digestContentLimit]
151+ }
152+ return string(runes)
153+}
154+
155+func excerptFromHTML(html string) string {
156+ text := plainText(html)
157+ if len(text) > 200 {
158+ text = text[:200]
159+ if idx := strings.LastIndex(text, ". "); idx > 0 {
160+ text = text[:idx+1]
161+ }
162+ }
163+ return text
164+}
165+
166+func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
167+ user := currentUser(r)
168+ ctx := r.Context()
169+
170+ settings, err := s.dbs.Users.GetSettings(ctx, user.DID)
171+ if err != nil {
172+ s.logger.Warn("failed to get settings for digest", "error", err, "did", user.DID)
173+ }
174+
175+ if settings != nil && !settings.DigestEnabled {
176+ w.WriteHeader(http.StatusNoContent)
177+ return
178+ }
179+
180+ if s.llm == nil {
181+ w.WriteHeader(http.StatusNoContent)
182+ return
183+ }
184+
185+ unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "")
186+ if unreadCount == 0 {
187+ w.WriteHeader(http.StatusNoContent)
188+ return
189+ }
190+
191+ key := user.DID
192+ if cached, ok := digestCache.Load(key); ok {
193+ entry := cached.(*digestCacheEntry)
194+ if time.Now().Before(entry.expiry) {
195+ if entry.consumed {
196+ s.renderDigest(w, &digestCtx{Consumed: true})
197+ } else {
198+ w.Header().Set("Content-Type", "text/html")
199+ w.Write([]byte(entry.html))
200+ }
201+ return
202+ }
203+ digestCache.Delete(key)
204+ }
205+
206+ result, _, _ := digestFlight.Do(key, func() (any, error) {
207+ d := s.buildDigestData(ctx, user)
208+ if d == nil {
209+ return nil, nil
210+ }
211+
212+ if cookie, err := r.Cookie("glean_csrf"); err == nil {
213+ d.CSRFToken = cookie.Value
214+ }
215+
216+ var buf strings.Builder
217+ if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil {
218+ s.logger.Error("digest template error", "error", err)
219+ return nil, nil
220+ }
221+
222+ html := buf.String()
223+ digestCache.Store(key, &digestCacheEntry{
224+ html: html,
225+ expiry: time.Now().Add(digestTTL),
226+ articleIDs: d.ArticleIDs,
227+ })
228+
229+ return html, nil
230+ })
231+
232+ if result == nil {
233+ w.WriteHeader(http.StatusNoContent)
234+ return
235+ }
236+
237+ w.Header().Set("Content-Type", "text/html")
238+ w.Write([]byte(result.(string)))
239+}
240+
241+func (s *Server) renderDigest(w http.ResponseWriter, d *digestCtx) {
242+ var buf strings.Builder
243+ if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil {
244+ s.logger.Error("digest template error", "error", err)
245+ w.WriteHeader(http.StatusNoContent)
246+ return
247+ }
248+ w.Header().Set("Content-Type", "text/html")
249+ w.Write([]byte(buf.String()))
250+}
251+
252+func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {
253+ user := currentUser(r)
254+
255+ if err := r.ParseForm(); err != nil {
256+ http.Error(w, err.Error(), http.StatusBadRequest)
257+ return
258+ }
259+
260+ var ids []int64
261+ for _, idStr := range r.Form["ids"] {
262+ id, err := strconv.ParseInt(idStr, 10, 64)
263+ if err != nil {
264+ continue
265+ }
266+ ids = append(ids, id)
267+ }
268+
269+ if len(ids) == 0 {
270+ w.WriteHeader(http.StatusNoContent)
271+ return
272+ }
273+
274+ if err := s.dbs.Articles.MarkArticlesRead(r.Context(), user.DID, ids); err != nil {
275+ s.logger.Error("failed to mark digest articles read", "error", err)
276+ http.Error(w, err.Error(), http.StatusInternalServerError)
277+ return
278+ }
279+
280+ digestCache.Store(user.DID, &digestCacheEntry{
281+ expiry: time.Now().Add(digestTTL),
282+ consumed: true,
283+ })
284+
285+ s.renderDigest(w, &digestCtx{Consumed: true})
286+}
new file mode 100644
@@ -0,0 +1,286 @@
1+package server
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "regexp"
8+ "strconv"
9+ "strings"
10+ "sync"
11+ "time"
12+
13+ "golang.org/x/sync/errgroup"
14+ "golang.org/x/sync/singleflight"
15+
16+ "pkg.rbrt.fr/glean/internal/db"
17+)
18+
19+type digestCtx struct {
20+ Title string
21+ Summary string
22+ Excerpt string
23+ ArticleIDs []int64
24+ GeneratedAt time.Time
25+ CSRFToken string
26+ Consumed bool
27+}
28+
29+type digestCacheEntry struct {
30+ html string
31+ expiry time.Time
32+ articleIDs []int64
33+ consumed bool
34+}
35+
36+var (
37+ digestCache sync.Map
38+ digestFlight singleflight.Group
39+)
40+
41+const digestTTL = 24 * time.Hour
42+
43+var refRe = regexp.MustCompile(`\[(\d+)\]`)
44+
45+func linkifyRefs(html string, articles []*db.Article) string {
46+ return refRe.ReplaceAllStringFunc(html, func(match string) string {
47+ n, err := strconv.Atoi(match[1 : len(match)-1])
48+ if err != nil || n < 1 || n > len(articles) {
49+ return match
50+ }
51+ a := articles[n-1]
52+ return fmt.Sprintf(`<a href="/articles/%d" class="text-spot-green hover:underline">[%d]</a>`, a.ID, n)
53+ })
54+}
55+
56+func (s *Server) buildDigestData(ctx context.Context, user *db.User) *digestCtx {
57+ var (
58+ articles []*db.Article
59+ userLangs []string
60+ )
61+
62+ g, gCtx := errgroup.WithContext(ctx)
63+
64+ g.Go(func() error {
65+ var err error
66+ articles, err = s.dbs.Articles.ListUnreadArticles(gCtx, user.DID, "", 50, 0)
67+ return err
68+ })
69+
70+ g.Go(func() error {
71+ var err error
72+ userLangs, err = s.dbs.Users.GetLanguages(gCtx, user.DID)
73+ return err
74+ })
75+
76+ if err := g.Wait(); err != nil {
77+ s.logger.Warn("digest data error", "error", err, "did", user.DID)
78+ return nil
79+ }
80+
81+ if len(articles) == 0 {
82+ return nil
83+ }
84+
85+ trending, err := s.engine.GetPersonalTrending(ctx, user.DID, userLangs, 5, 0)
86+ if err != nil {
87+ s.logger.Warn("failed to get trending for digest", "error", err, "did", user.DID)
88+ }
89+
90+ var articleEntries []string
91+ for _, a := range articles {
92+ var parts []string
93+ parts = append(parts, a.Title)
94+ if a.FeedTitle != "" {
95+ parts = append(parts, "["+a.FeedTitle+"]")
96+ }
97+ if a.URL.Valid && a.URL.String != "" {
98+ parts = append(parts, a.URL.String)
99+ }
100+ if content := digestArticleContent(a); content != "" {
101+ parts = append(parts, content)
102+ }
103+ articleEntries = append(articleEntries, strings.Join(parts, " "))
104+ }
105+
106+ trendingTopics := make([]string, len(trending))
107+ for i, t := range trending {
108+ trendingTopics[i] = t.Title
109+ }
110+
111+ title, summary, err := s.llm.GenerateDigest(ctx, articleEntries, trendingTopics)
112+ if err != nil {
113+ s.logger.Warn("failed to generate digest", "error", err, "did", user.DID)
114+ return nil
115+ }
116+
117+ summary = linkifyRefs(summary, articles)
118+
119+ return &digestCtx{
120+ Title: title,
121+ Summary: summary,
122+ Excerpt: excerptFromHTML(summary),
123+ ArticleIDs: func() []int64 {
124+ ids := make([]int64, len(articles))
125+ for i, a := range articles {
126+ ids[i] = a.ID
127+ }
128+ return ids
129+ }(),
130+ GeneratedAt: time.Now(),
131+ }
132+}
133+
134+const digestContentLimit = 500
135+
136+func digestArticleContent(a *db.Article) string {
137+ src := a.FullContent
138+ if !src.Valid || src.String == "" {
139+ src = a.Content
140+ }
141+ if !src.Valid || src.String == "" {
142+ src = a.Summary
143+ }
144+ if !src.Valid || src.String == "" {
145+ return ""
146+ }
147+ text := plainText(src.String)
148+ runes := []rune(text)
149+ if len(runes) > digestContentLimit {
150+ runes = runes[:digestContentLimit]
151+ }
152+ return string(runes)
153+}
154+
155+func excerptFromHTML(html string) string {
156+ text := plainText(html)
157+ if len(text) > 200 {
158+ text = text[:200]
159+ if idx := strings.LastIndex(text, ". "); idx > 0 {
160+ text = text[:idx+1]
161+ }
162+ }
163+ return text
164+}
165+
166+func (s *Server) handleDigest(w http.ResponseWriter, r *http.Request) {
167+ user := currentUser(r)
168+ ctx := r.Context()
169+
170+ settings, err := s.dbs.Users.GetSettings(ctx, user.DID)
171+ if err != nil {
172+ s.logger.Warn("failed to get settings for digest", "error", err, "did", user.DID)
173+ }
174+
175+ if settings != nil && !settings.DigestEnabled {
176+ w.WriteHeader(http.StatusNoContent)
177+ return
178+ }
179+
180+ if s.llm == nil {
181+ w.WriteHeader(http.StatusNoContent)
182+ return
183+ }
184+
185+ unreadCount, _ := s.dbs.Articles.GetUnreadCount(ctx, user.DID, "")
186+ if unreadCount == 0 {
187+ w.WriteHeader(http.StatusNoContent)
188+ return
189+ }
190+
191+ key := user.DID
192+ if cached, ok := digestCache.Load(key); ok {
193+ entry := cached.(*digestCacheEntry)
194+ if time.Now().Before(entry.expiry) {
195+ if entry.consumed {
196+ s.renderDigest(w, &digestCtx{Consumed: true})
197+ } else {
198+ w.Header().Set("Content-Type", "text/html")
199+ w.Write([]byte(entry.html))
200+ }
201+ return
202+ }
203+ digestCache.Delete(key)
204+ }
205+
206+ result, _, _ := digestFlight.Do(key, func() (any, error) {
207+ d := s.buildDigestData(ctx, user)
208+ if d == nil {
209+ return nil, nil
210+ }
211+
212+ if cookie, err := r.Cookie("glean_csrf"); err == nil {
213+ d.CSRFToken = cookie.Value
214+ }
215+
216+ var buf strings.Builder
217+ if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil {
218+ s.logger.Error("digest template error", "error", err)
219+ return nil, nil
220+ }
221+
222+ html := buf.String()
223+ digestCache.Store(key, &digestCacheEntry{
224+ html: html,
225+ expiry: time.Now().Add(digestTTL),
226+ articleIDs: d.ArticleIDs,
227+ })
228+
229+ return html, nil
230+ })
231+
232+ if result == nil {
233+ w.WriteHeader(http.StatusNoContent)
234+ return
235+ }
236+
237+ w.Header().Set("Content-Type", "text/html")
238+ w.Write([]byte(result.(string)))
239+}
240+
241+func (s *Server) renderDigest(w http.ResponseWriter, d *digestCtx) {
242+ var buf strings.Builder
243+ if err := s.templates.ExecuteTemplate(&buf, "partials/digest.html", d); err != nil {
244+ s.logger.Error("digest template error", "error", err)
245+ w.WriteHeader(http.StatusNoContent)
246+ return
247+ }
248+ w.Header().Set("Content-Type", "text/html")
249+ w.Write([]byte(buf.String()))
250+}
251+
252+func (s *Server) handleDigestMarkRead(w http.ResponseWriter, r *http.Request) {
253+ user := currentUser(r)
254+
255+ if err := r.ParseForm(); err != nil {
256+ http.Error(w, err.Error(), http.StatusBadRequest)
257+ return
258+ }
259+
260+ var ids []int64
261+ for _, idStr := range r.Form["ids"] {
262+ id, err := strconv.ParseInt(idStr, 10, 64)
263+ if err != nil {
264+ continue
265+ }
266+ ids = append(ids, id)
267+ }
268+
269+ if len(ids) == 0 {
270+ w.WriteHeader(http.StatusNoContent)
271+ return
272+ }
273+
274+ if err := s.dbs.Articles.MarkArticlesRead(r.Context(), user.DID, ids); err != nil {
275+ s.logger.Error("failed to mark digest articles read", "error", err)
276+ http.Error(w, err.Error(), http.StatusInternalServerError)
277+ return
278+ }
279+
280+ digestCache.Store(user.DID, &digestCacheEntry{
281+ expiry: time.Now().Add(digestTTL),
282+ consumed: true,
283+ })
284+
285+ s.renderDigest(w, &digestCtx{Consumed: true})
286+}
modified internal/server/profile_handler.go +1 -0
@@ -111,5 +111,6 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
111111 "UserLanguages": userLangs,
112112 "AvailableLanguages": ml.KnownLanguages(),
113113 "ExpandedView": userSettings.ExpandedView,
114+ "DigestEnabled": userSettings.DigestEnabled,
114115 })
115116 }
@@ -111,5 +111,6 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
111 "UserLanguages": userLangs,111 "UserLanguages": userLangs,
112 "AvailableLanguages": ml.KnownLanguages(),112 "AvailableLanguages": ml.KnownLanguages(),
113 "ExpandedView": userSettings.ExpandedView,113 "ExpandedView": userSettings.ExpandedView,
114+ "DigestEnabled": userSettings.DigestEnabled,
114 })115 })
115 }116 }
modified internal/server/server.go +17 -1
@@ -28,6 +28,7 @@ import (
2828 "pkg.rbrt.fr/glean/internal/feed"
2929 "pkg.rbrt.fr/glean/internal/feedback"
3030 "pkg.rbrt.fr/glean/internal/metrics"
31+ "pkg.rbrt.fr/glean/internal/ml"
3132 "pkg.rbrt.fr/glean/internal/scraper"
3233 "pkg.rbrt.fr/glean/internal/tmpl"
3334 "pkg.rbrt.fr/glean/static"
@@ -70,12 +71,22 @@ type Server struct {
7071 engine *cluster.Engine
7172 feedback *feedback.Service
7273 scraper *scraper.Scraper
74+ llm ml.TextModel
7375 clientID string
7476 callbackURL string
7577 sessionKey []byte
7678 }
7779
78-func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Scheduler, fetcher *feed.Fetcher, engine *cluster.Engine, logger *slog.Logger, sessionKey []byte) *Server {
80+func New(
81+ dbs *db.Store,
82+ clientID, callbackURL, addr string,
83+ scheduler *feed.Scheduler,
84+ fetcher *feed.Fetcher,
85+ engine *cluster.Engine,
86+ logger *slog.Logger,
87+ sessionKey []byte,
88+ textModel ml.TextModel,
89+) *Server {
7990 oauthStore := db.NewOAuthStore(dbs)
8091
8192 var config oauth.ClientConfig
@@ -102,6 +113,7 @@ func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Sche
102113 engine: engine,
103114 feedback: feedback.NewService(dbs.SQLDB()),
104115 scraper: scraper.New(logger),
116+ llm: textModel,
105117 clientID: clientID,
106118 callbackURL: callbackURL,
107119 sessionKey: sessionKey,
@@ -211,8 +223,12 @@ func (s *Server) setupRoutes() {
211223 r.Use(s.requireAuth)
212224 r.Post("/languages", s.handleUpdateLanguages)
213225 r.Post("/expanded-view", s.handleToggleExpandedView)
226+ r.Post("/digest-enabled", s.handleToggleDigestEnabled)
214227 })
215228
229+ s.router.With(s.requireAuth).Get("/digest", s.handleDigest)
230+ s.router.With(s.requireAuth).Post("/digest/mark-read", s.handleDigestMarkRead)
231+
216232 s.router.Get("/auth/login", s.handleAuthLogin)
217233 s.router.Get("/auth/register", s.handleAuthRegister)
218234 s.router.Get("/auth/resolve", s.handleAuthResolve)
@@ -28,6 +28,7 @@ import (
28 "pkg.rbrt.fr/glean/internal/feed"28 "pkg.rbrt.fr/glean/internal/feed"
29 "pkg.rbrt.fr/glean/internal/feedback"29 "pkg.rbrt.fr/glean/internal/feedback"
30 "pkg.rbrt.fr/glean/internal/metrics"30 "pkg.rbrt.fr/glean/internal/metrics"
31+ "pkg.rbrt.fr/glean/internal/ml"
31 "pkg.rbrt.fr/glean/internal/scraper"32 "pkg.rbrt.fr/glean/internal/scraper"
32 "pkg.rbrt.fr/glean/internal/tmpl"33 "pkg.rbrt.fr/glean/internal/tmpl"
33 "pkg.rbrt.fr/glean/static"34 "pkg.rbrt.fr/glean/static"
@@ -70,12 +71,22 @@ type Server struct {
70 engine *cluster.Engine71 engine *cluster.Engine
71 feedback *feedback.Service72 feedback *feedback.Service
72 scraper *scraper.Scraper73 scraper *scraper.Scraper
74+ llm ml.TextModel
73 clientID string75 clientID string
74 callbackURL string76 callbackURL string
75 sessionKey []byte77 sessionKey []byte
76 }78 }
77 79
78-func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Scheduler, fetcher *feed.Fetcher, engine *cluster.Engine, logger *slog.Logger, sessionKey []byte) *Server {80+func New(
81+ dbs *db.Store,
82+ clientID, callbackURL, addr string,
83+ scheduler *feed.Scheduler,
84+ fetcher *feed.Fetcher,
85+ engine *cluster.Engine,
86+ logger *slog.Logger,
87+ sessionKey []byte,
88+ textModel ml.TextModel,
89+) *Server {
79 oauthStore := db.NewOAuthStore(dbs)90 oauthStore := db.NewOAuthStore(dbs)
80 91
81 var config oauth.ClientConfig92 var config oauth.ClientConfig
@@ -102,6 +113,7 @@ func New(dbs *db.Store, clientID, callbackURL, addr string, scheduler *feed.Sche
102 engine: engine,113 engine: engine,
103 feedback: feedback.NewService(dbs.SQLDB()),114 feedback: feedback.NewService(dbs.SQLDB()),
104 scraper: scraper.New(logger),115 scraper: scraper.New(logger),
116+ llm: textModel,
105 clientID: clientID,117 clientID: clientID,
106 callbackURL: callbackURL,118 callbackURL: callbackURL,
107 sessionKey: sessionKey,119 sessionKey: sessionKey,
@@ -211,8 +223,12 @@ func (s *Server) setupRoutes() {
211 r.Use(s.requireAuth)223 r.Use(s.requireAuth)
212 r.Post("/languages", s.handleUpdateLanguages)224 r.Post("/languages", s.handleUpdateLanguages)
213 r.Post("/expanded-view", s.handleToggleExpandedView)225 r.Post("/expanded-view", s.handleToggleExpandedView)
226+ r.Post("/digest-enabled", s.handleToggleDigestEnabled)
214 })227 })
215 228
229+ s.router.With(s.requireAuth).Get("/digest", s.handleDigest)
230+ s.router.With(s.requireAuth).Post("/digest/mark-read", s.handleDigestMarkRead)
231+
216 s.router.Get("/auth/login", s.handleAuthLogin)232 s.router.Get("/auth/login", s.handleAuthLogin)
217 s.router.Get("/auth/register", s.handleAuthRegister)233 s.router.Get("/auth/register", s.handleAuthRegister)
218 s.router.Get("/auth/resolve", s.handleAuthResolve)234 s.router.Get("/auth/resolve", s.handleAuthResolve)
modified internal/server/settings_handler.go +20 -0
@@ -57,3 +57,23 @@ func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request
5757 w.Header().Set("HX-Redirect", "/profile/"+user.DID)
5858 w.WriteHeader(http.StatusOK)
5959 }
60+
61+func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Request) {
62+ user := currentUser(r)
63+
64+ if err := r.ParseForm(); err != nil {
65+ http.Error(w, err.Error(), http.StatusBadRequest)
66+ return
67+ }
68+
69+ enabled := r.FormValue("digest_enabled") == "1"
70+
71+ if err := s.dbs.Users.SetDigestEnabled(r.Context(), user.DID, enabled); err != nil {
72+ s.logger.Error("failed to update digest enabled", "error", err)
73+ http.Error(w, err.Error(), http.StatusInternalServerError)
74+ return
75+ }
76+
77+ w.Header().Set("HX-Redirect", "/profile/"+user.DID)
78+ w.WriteHeader(http.StatusOK)
79+}
@@ -57,3 +57,23 @@ func (s *Server) handleToggleExpandedView(w http.ResponseWriter, r *http.Request
57 w.Header().Set("HX-Redirect", "/profile/"+user.DID)57 w.Header().Set("HX-Redirect", "/profile/"+user.DID)
58 w.WriteHeader(http.StatusOK)58 w.WriteHeader(http.StatusOK)
59 }59 }
60+
61+func (s *Server) handleToggleDigestEnabled(w http.ResponseWriter, r *http.Request) {
62+ user := currentUser(r)
63+
64+ if err := r.ParseForm(); err != nil {
65+ http.Error(w, err.Error(), http.StatusBadRequest)
66+ return
67+ }
68+
69+ enabled := r.FormValue("digest_enabled") == "1"
70+
71+ if err := s.dbs.Users.SetDigestEnabled(r.Context(), user.DID, enabled); err != nil {
72+ s.logger.Error("failed to update digest enabled", "error", err)
73+ http.Error(w, err.Error(), http.StatusInternalServerError)
74+ return
75+ }
76+
77+ w.Header().Set("HX-Redirect", "/profile/"+user.DID)
78+ w.WriteHeader(http.StatusOK)
79+}
modified internal/tmpl/dashboard.html +36 -0
@@ -38,6 +38,24 @@
3838 </div>
3939 {{end}}
4040
41+{{if .DigestEnabled}}
42+<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">
43+ <div class="mb-6">
44+ <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
45+ <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4">
46+ <div class="flex items-start gap-2.5">
47+ <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
48+ <div class="flex-1 space-y-2">
49+ <div class="h-4 bg-spot-hover rounded w-2/3"></div>
50+ <div class="h-3 bg-spot-hover rounded w-full"></div>
51+ <div class="h-3 bg-spot-hover rounded w-4/5"></div>
52+ </div>
53+ </div>
54+ </div>
55+ </div>
56+</div>
57+{{end}}
58+
4159 <div class="mb-8">
4260 <div class="flex items-center justify-between mb-4">
4361 <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2>
@@ -64,6 +82,24 @@
6482 </div>
6583 {{end}}
6684
85+{{if .DigestEnabled}}
86+<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">
87+ <div class="mb-6">
88+ <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
89+ <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4">
90+ <div class="flex items-start gap-2.5">
91+ <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
92+ <div class="flex-1 space-y-2">
93+ <div class="h-4 bg-spot-hover rounded w-2/3"></div>
94+ <div class="h-3 bg-spot-hover rounded w-full"></div>
95+ <div class="h-3 bg-spot-hover rounded w-4/5"></div>
96+ </div>
97+ </div>
98+ </div>
99+ </div>
100+</div>
101+{{end}}
102+
67103 <div class="mb-8">
68104 <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>
69105 <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center">
@@ -38,6 +38,24 @@
38 </div>38 </div>
39 {{end}}39 {{end}}
40 40
41+{{if .DigestEnabled}}
42+<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">
43+ <div class="mb-6">
44+ <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
45+ <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4">
46+ <div class="flex items-start gap-2.5">
47+ <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
48+ <div class="flex-1 space-y-2">
49+ <div class="h-4 bg-spot-hover rounded w-2/3"></div>
50+ <div class="h-3 bg-spot-hover rounded w-full"></div>
51+ <div class="h-3 bg-spot-hover rounded w-4/5"></div>
52+ </div>
53+ </div>
54+ </div>
55+ </div>
56+</div>
57+{{end}}
58+
41 <div class="mb-8">59 <div class="mb-8">
42 <div class="flex items-center justify-between mb-4">60 <div class="flex items-center justify-between mb-4">
43 <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2>61 <h2 class="text-lg font-semibold text-spot-text">Unread articles</h2>
@@ -64,6 +82,24 @@
64 </div>82 </div>
65 {{end}}83 {{end}}
66 84
85+{{if .DigestEnabled}}
86+<div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest">
87+ <div class="mb-6">
88+ <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
89+ <div class="animate-pulse bg-spot-surface rounded-xl px-5 py-4">
90+ <div class="flex items-start gap-2.5">
91+ <div class="w-4 h-4 bg-spot-hover rounded shrink-0 mt-0.5"></div>
92+ <div class="flex-1 space-y-2">
93+ <div class="h-4 bg-spot-hover rounded w-2/3"></div>
94+ <div class="h-3 bg-spot-hover rounded w-full"></div>
95+ <div class="h-3 bg-spot-hover rounded w-4/5"></div>
96+ </div>
97+ </div>
98+ </div>
99+ </div>
100+</div>
101+{{end}}
102+
67 <div class="mb-8">103 <div class="mb-8">
68 <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>104 <h2 class="text-lg font-semibold text-spot-text mb-4">Unread articles</h2>
69 <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center">105 <div class="bg-spot-surface rounded-2xl py-14 px-6 text-center">
added internal/tmpl/partials/digest.html +46 -0
new file mode 100644
@@ -0,0 +1,46 @@
1+{{define "partials/digest.html"}}
2+<div id="digest" class="mb-8">
3+ <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
4+ <article class="bg-spot-surface rounded-xl px-5 py-4 shadow-spot{{if not .Consumed}} border-l border-spot-green/80{{end}}">
5+ {{if .Consumed}}
6+ <p class="text-sm text-spot-secondary">That's all for today. Come back tomorrow for a fresh digest.</p>
7+ {{else}}
8+ <div class="flex items-start justify-between gap-4">
9+ <div class="min-w-0 flex-1">
10+ <button onclick="var b=document.getElementById('digest-body');var h=document.getElementById('digest-header');var i=document.getElementById('digest-chevron');if(b.classList.contains('hidden')){b.classList.remove('hidden');h.classList.add('hidden');i.style.transform=''}else{b.classList.add('hidden');h.classList.remove('hidden');i.style.transform='rotate(-90deg)'}"
11+ class="text-left w-full">
12+ <span id="digest-chevron" class="inline-block mr-1.5 transition-transform rotate(-90deg)">
13+ <svg class="w-3.5 h-3.5 text-spot-secondary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
14+ </span>
15+ <span class="font-bold text-[17px] text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</span>
16+ </button>
17+ <div id="digest-header">
18+ <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5">
19+ <svg class="w-3.5 h-3.5 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>
20+ <span class="font-medium">AI summary</span>
21+ <span class="text-spot-muted">&middot;</span>
22+ <span>{{.GeneratedAt.Format "Jan 2, 2006 15:04"}}</span>
23+ <span class="text-spot-muted">&middot;</span>
24+ <span>{{len .ArticleIDs}} articles</span>
25+ </div>
26+ <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{.Excerpt}}</p>
27+ </div>
28+ </div>
29+ <div class="shrink-0 pt-0.5">
30+ <form hx-post="/digest/mark-read" hx-confirm="Mark these articles as read?" hx-target="#digest" hx-swap="outerHTML">
31+ {{csrfInput .CSRFToken}}
32+ {{range .ArticleIDs}}<input type="hidden" name="ids" value="{{.}}">{{end}}
33+ <button type="submit" class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-green hover:bg-spot-green/15 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover transition">
34+ <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
35+ <span>Read</span>
36+ </button>
37+ </form>
38+ </div>
39+ </div>
40+ <div id="digest-body" class="hidden">
41+ <div class="text-sm text-spot-text leading-relaxed space-y-3 border-t border-spot-divider pt-4 mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:text-spot-text [&_strong]:font-semibold [&_strong]:text-[13px] [&_strong]:uppercase [&_strong]:tracking-wide">{{sanitizeHTML .Summary}}</div>
42+ </div>
43+ {{end}}
44+ </article>
45+</div>
46+{{end}}
new file mode 100644
@@ -0,0 +1,46 @@
1+{{define "partials/digest.html"}}
2+<div id="digest" class="mb-8">
3+ <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2>
4+ <article class="bg-spot-surface rounded-xl px-5 py-4 shadow-spot{{if not .Consumed}} border-l border-spot-green/80{{end}}">
5+ {{if .Consumed}}
6+ <p class="text-sm text-spot-secondary">That's all for today. Come back tomorrow for a fresh digest.</p>
7+ {{else}}
8+ <div class="flex items-start justify-between gap-4">
9+ <div class="min-w-0 flex-1">
10+ <button onclick="var b=document.getElementById('digest-body');var h=document.getElementById('digest-header');var i=document.getElementById('digest-chevron');if(b.classList.contains('hidden')){b.classList.remove('hidden');h.classList.add('hidden');i.style.transform=''}else{b.classList.add('hidden');h.classList.remove('hidden');i.style.transform='rotate(-90deg)'}"
11+ class="text-left w-full">
12+ <span id="digest-chevron" class="inline-block mr-1.5 transition-transform rotate(-90deg)">
13+ <svg class="w-3.5 h-3.5 text-spot-secondary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
14+ </span>
15+ <span class="font-bold text-[17px] text-spot-text hover:text-spot-green transition leading-snug">{{.Title}}</span>
16+ </button>
17+ <div id="digest-header">
18+ <div class="text-xs text-spot-secondary mt-2 flex items-center gap-1.5">
19+ <svg class="w-3.5 h-3.5 text-spot-green shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"/></svg>
20+ <span class="font-medium">AI summary</span>
21+ <span class="text-spot-muted">&middot;</span>
22+ <span>{{.GeneratedAt.Format "Jan 2, 2006 15:04"}}</span>
23+ <span class="text-spot-muted">&middot;</span>
24+ <span>{{len .ArticleIDs}} articles</span>
25+ </div>
26+ <p class="text-sm text-spot-secondary mt-2.5 line-clamp-2 leading-relaxed">{{.Excerpt}}</p>
27+ </div>
28+ </div>
29+ <div class="shrink-0 pt-0.5">
30+ <form hx-post="/digest/mark-read" hx-confirm="Mark these articles as read?" hx-target="#digest" hx-swap="outerHTML">
31+ {{csrfInput .CSRFToken}}
32+ {{range .ArticleIDs}}<input type="hidden" name="ids" value="{{.}}">{{end}}
33+ <button type="submit" class="group inline-flex items-center justify-center gap-1 text-[10px] text-spot-text hover:text-spot-green hover:bg-spot-green/15 uppercase tracking-button px-2 py-0.5 rounded-pill bg-spot-hover transition">
34+ <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
35+ <span>Read</span>
36+ </button>
37+ </form>
38+ </div>
39+ </div>
40+ <div id="digest-body" class="hidden">
41+ <div class="text-sm text-spot-text leading-relaxed space-y-3 border-t border-spot-divider pt-4 mt-3 [&_p]:mb-3 [&_p:last-child]:mb-0 [&_strong]:text-spot-text [&_strong]:font-semibold [&_strong]:text-[13px] [&_strong]:uppercase [&_strong]:tracking-wide">{{sanitizeHTML .Summary}}</div>
42+ </div>
43+ {{end}}
44+ </article>
45+</div>
46+{{end}}
modified internal/tmpl/profile.html +13 -0
@@ -25,6 +25,19 @@
2525 {{if eq .ProfileUser.DID .CurrentUserDID}}
2626 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2>
2727 <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5">
28+ <form hx-post="/settings/digest-enabled" hx-swap="none" hx-on::after-request="location.reload()">
29+ <input type="hidden" name="digest_enabled" value="{{if .DigestEnabled}}0{{else}}1{{end}}">
30+ <div class="flex items-center justify-between">
31+ <div>
32+ <p class="text-sm font-semibold text-spot-text mb-0.5">Daily digest</p>
33+ <p class="text-xs text-spot-secondary">Show an AI-generated summary of your 50 most recent unread articles on the dashboard.</p>
34+ </div>
35+ <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .DigestEnabled}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle daily digest">
36+ <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .DigestEnabled}}translate-x-6{{else}}translate-x-1{{end}}"></span>
37+ </button>
38+ </div>
39+ </form>
40+ <hr class="border-spot-divider">
2841 <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()">
2942 <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}">
3043 <div class="flex items-center justify-between">
@@ -25,6 +25,19 @@
25 {{if eq .ProfileUser.DID .CurrentUserDID}}25 {{if eq .ProfileUser.DID .CurrentUserDID}}
26 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2>26 <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Settings</h2>
27 <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5">27 <div class="bg-spot-surface rounded-xl p-5 mb-6 space-y-5">
28+ <form hx-post="/settings/digest-enabled" hx-swap="none" hx-on::after-request="location.reload()">
29+ <input type="hidden" name="digest_enabled" value="{{if .DigestEnabled}}0{{else}}1{{end}}">
30+ <div class="flex items-center justify-between">
31+ <div>
32+ <p class="text-sm font-semibold text-spot-text mb-0.5">Daily digest</p>
33+ <p class="text-xs text-spot-secondary">Show an AI-generated summary of your 50 most recent unread articles on the dashboard.</p>
34+ </div>
35+ <button type="submit" class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-xl border-0 p-0 cursor-pointer transition-colors appearance-none {{if .DigestEnabled}}bg-spot-green{{else}}bg-spot-hover{{end}}" aria-label="Toggle daily digest">
36+ <span class="pointer-events-none inline-block h-4 w-4 rounded-xl bg-white shadow-sm transition-transform {{if .DigestEnabled}}translate-x-6{{else}}translate-x-1{{end}}"></span>
37+ </button>
38+ </div>
39+ </form>
40+ <hr class="border-spot-divider">
28 <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()">41 <form hx-post="/settings/expanded-view" hx-swap="none" hx-on::after-request="location.reload()">
29 <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}">42 <input type="hidden" name="expanded_view" value="{{if .ExpandedView}}0{{else}}1{{end}}">
30 <div class="flex items-center justify-between">43 <div class="flex items-center justify-between">
modified main.go +1 -1
@@ -104,7 +104,7 @@ func main() {
104104 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig())
105105
106106 fetcher := feed.NewFetcher(siteFetcher)
107- srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))
107+ srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
108108
109109 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)
110110
@@ -104,7 +104,7 @@ func main() {
104 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig())104 engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, *clusterInterval, cluster.DefaultConfig())
105 105
106 fetcher := feed.NewFetcher(siteFetcher)106 fetcher := feed.NewFetcher(siteFetcher)
107- srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey))107+ srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, fetcher, engine, logger, []byte(sessionKey), llm)
108 108
109 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)109 cron := cluster.NewCron(engine, *clusterInterval, logger, dbs)
110 110