nandi/gleanpublic⑂ Fork 0
⑂ 6691ef3
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.

refactor database queries and improve performanceUnverified

Julien Robert committed 2026-04-21T12:19:23+02:00 Browse files
6691ef3 parent: 1702c76
modified internal/atproto/stream_handler.go +3 -5
@@ -63,11 +63,9 @@ func (h *StreamDBHandler) handleSubscription(ctx context.Context, event *Event)
6363 if !ok {
6464 return nil
6565 }
66- subs, _ := h.db.ListSubscriptions(ctx, event.DID, "", 100, 0)
67- for _, sub := range subs {
68- if sub.URI.Valid && sub.URI.String == event.URI {
69- return h.db.DeleteSubscription(ctx, event.DID, sub.FeedURL)
70- }
66+ sub, err := h.db.GetSubscriptionByURI(ctx, event.DID, event.URI)
67+ if err == nil && sub != nil {
68+ return h.db.DeleteSubscription(ctx, event.DID, sub.FeedURL)
7169 }
7270 _ = parsed
7371 }
@@ -63,11 +63,9 @@ func (h *StreamDBHandler) handleSubscription(ctx context.Context, event *Event)
63 if !ok {63 if !ok {
64 return nil64 return nil
65 }65 }
66- subs, _ := h.db.ListSubscriptions(ctx, event.DID, "", 100, 0)66+ sub, err := h.db.GetSubscriptionByURI(ctx, event.DID, event.URI)
67- for _, sub := range subs {67+ if err == nil && sub != nil {
68- if sub.URI.Valid && sub.URI.String == event.URI {68+ return h.db.DeleteSubscription(ctx, event.DID, sub.FeedURL)
69- return h.db.DeleteSubscription(ctx, event.DID, sub.FeedURL)
70- }
71 }69 }
72 _ = parsed70 _ = parsed
73 }71 }
modified internal/atproto/sync.go +6 -12
@@ -140,12 +140,9 @@ func (s *Sync) reconcileAnnotation(ctx context.Context, userDID, uri, cid string
140140 return nil
141141 }
142142
143- var existing []*db.Annotation
144- existing, _ = s.db.ListAnnotations(ctx, rec.FeedURL, rec.ArticleURL, userDID, 100, 0)
145- for _, a := range existing {
146- if a.URI == uri {
147- return nil
148- }
143+ exists, err := s.db.AnnotationExists(ctx, uri)
144+ if err != nil || exists {
145+ return err
149146 }
150147
151148 t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
@@ -177,12 +174,9 @@ func (s *Sync) reconcileMarginNote(ctx context.Context, userDID, uri, cid string
177174 return nil
178175 }
179176
180- var existing []*db.Annotation
181- existing, _ = s.db.ListAnnotations(ctx, "", articleURL, userDID, 100, 0)
182- for _, a := range existing {
183- if a.URI == uri {
184- return nil
185- }
177+ exists, err := s.db.AnnotationExists(ctx, uri)
178+ if err != nil || exists {
179+ return err
186180 }
187181
188182 feedURL := ""
@@ -140,12 +140,9 @@ func (s *Sync) reconcileAnnotation(ctx context.Context, userDID, uri, cid string
140 return nil140 return nil
141 }141 }
142 142
143- var existing []*db.Annotation143+ exists, err := s.db.AnnotationExists(ctx, uri)
144- existing, _ = s.db.ListAnnotations(ctx, rec.FeedURL, rec.ArticleURL, userDID, 100, 0)144+ if err != nil || exists {
145- for _, a := range existing {145+ return err
146- if a.URI == uri {
147- return nil
148- }
149 }146 }
150 147
151 t, _ := time.Parse(time.RFC3339, rec.CreatedAt)148 t, _ := time.Parse(time.RFC3339, rec.CreatedAt)
@@ -177,12 +174,9 @@ func (s *Sync) reconcileMarginNote(ctx context.Context, userDID, uri, cid string
177 return nil174 return nil
178 }175 }
179 176
180- var existing []*db.Annotation177+ exists, err := s.db.AnnotationExists(ctx, uri)
181- existing, _ = s.db.ListAnnotations(ctx, "", articleURL, userDID, 100, 0)178+ if err != nil || exists {
182- for _, a := range existing {179+ return err
183- if a.URI == uri {
184- return nil
185- }
186 }180 }
187 181
188 feedURL := ""182 feedURL := ""
modified internal/atproto/xrpc.go +28 -12
@@ -422,6 +422,12 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
422422 defer rows.Close()
423423
424424 feedLists := make([]FeedListEntry, 0)
425+ type userRow struct {
426+ did string
427+ subCount int
428+ }
429+ var users []userRow
430+
425431 for rows.Next() {
426432 var did, handle string
427433 var subCount int
@@ -429,40 +435,50 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
429435 http.Error(w, err.Error(), http.StatusInternalServerError)
430436 return
431437 }
438+ users = append(users, userRow{did: did, subCount: subCount})
439+ }
432440
441+ subsByDID := make(map[string][]SubscriptionRecord)
442+ if len(users) > 0 {
443+ ph := make([]string, len(users))
444+ args := make([]any, len(users))
445+ for i, u := range users {
446+ ph[i] = "?"
447+ args[i] = u.did
448+ }
433449 subRows, err := h.db.QueryContext(r.Context(), `
434- SELECT s.feed_url, COALESCE(s.title, f.title), s.category
450+ SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category
435451 FROM subscriptions s
436452 JOIN feeds f ON s.feed_url = f.feed_url
437- WHERE s.user_did = ?
438- ORDER BY s.added_at DESC
439- `, did)
453+ WHERE s.user_did IN (`+strings.Join(ph, ",")+`)
454+ ORDER BY s.user_did, s.added_at DESC
455+ `, args...)
440456 if err != nil {
441457 http.Error(w, err.Error(), http.StatusInternalServerError)
442458 return
443459 }
444-
445- subs := make([]SubscriptionRecord, 0)
446460 for subRows.Next() {
447- var feedURL, title string
461+ var did, feedURL, title string
448462 var cat sql.NullString
449- if err := subRows.Scan(&feedURL, &title, &cat); err != nil {
463+ if err := subRows.Scan(&did, &feedURL, &title, &cat); err != nil {
450464 subRows.Close()
451465 http.Error(w, err.Error(), http.StatusInternalServerError)
452466 return
453467 }
454- subs = append(subs, SubscriptionRecord{
468+ subsByDID[did] = append(subsByDID[did], SubscriptionRecord{
455469 FeedURL: feedURL,
456470 Title: title,
457471 Category: cat.String,
458472 })
459473 }
460474 subRows.Close()
475+ }
461476
477+ for _, u := range users {
462478 feedLists = append(feedLists, FeedListEntry{
463- DID: did,
464- SubscriptionCount: subCount,
465- Subscriptions: subs,
479+ DID: u.did,
480+ SubscriptionCount: u.subCount,
481+ Subscriptions: subsByDID[u.did],
466482 })
467483 }
468484
@@ -422,6 +422,12 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
422 defer rows.Close()422 defer rows.Close()
423 423
424 feedLists := make([]FeedListEntry, 0)424 feedLists := make([]FeedListEntry, 0)
425+ type userRow struct {
426+ did string
427+ subCount int
428+ }
429+ var users []userRow
430+
425 for rows.Next() {431 for rows.Next() {
426 var did, handle string432 var did, handle string
427 var subCount int433 var subCount int
@@ -429,40 +435,50 @@ func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) {
429 http.Error(w, err.Error(), http.StatusInternalServerError)435 http.Error(w, err.Error(), http.StatusInternalServerError)
430 return436 return
431 }437 }
438+ users = append(users, userRow{did: did, subCount: subCount})
439+ }
432 440
441+ subsByDID := make(map[string][]SubscriptionRecord)
442+ if len(users) > 0 {
443+ ph := make([]string, len(users))
444+ args := make([]any, len(users))
445+ for i, u := range users {
446+ ph[i] = "?"
447+ args[i] = u.did
448+ }
433 subRows, err := h.db.QueryContext(r.Context(), `449 subRows, err := h.db.QueryContext(r.Context(), `
434- SELECT s.feed_url, COALESCE(s.title, f.title), s.category450+ SELECT s.user_did, s.feed_url, COALESCE(s.title, f.title), s.category
435 FROM subscriptions s451 FROM subscriptions s
436 JOIN feeds f ON s.feed_url = f.feed_url452 JOIN feeds f ON s.feed_url = f.feed_url
437- WHERE s.user_did = ?453+ WHERE s.user_did IN (`+strings.Join(ph, ",")+`)
438- ORDER BY s.added_at DESC454+ ORDER BY s.user_did, s.added_at DESC
439- `, did)455+ `, args...)
440 if err != nil {456 if err != nil {
441 http.Error(w, err.Error(), http.StatusInternalServerError)457 http.Error(w, err.Error(), http.StatusInternalServerError)
442 return458 return
443 }459 }
444-
445- subs := make([]SubscriptionRecord, 0)
446 for subRows.Next() {460 for subRows.Next() {
447- var feedURL, title string461+ var did, feedURL, title string
448 var cat sql.NullString462 var cat sql.NullString
449- if err := subRows.Scan(&feedURL, &title, &cat); err != nil {463+ if err := subRows.Scan(&did, &feedURL, &title, &cat); err != nil {
450 subRows.Close()464 subRows.Close()
451 http.Error(w, err.Error(), http.StatusInternalServerError)465 http.Error(w, err.Error(), http.StatusInternalServerError)
452 return466 return
453 }467 }
454- subs = append(subs, SubscriptionRecord{468+ subsByDID[did] = append(subsByDID[did], SubscriptionRecord{
455 FeedURL: feedURL,469 FeedURL: feedURL,
456 Title: title,470 Title: title,
457 Category: cat.String,471 Category: cat.String,
458 })472 })
459 }473 }
460 subRows.Close()474 subRows.Close()
475+ }
461 476
477+ for _, u := range users {
462 feedLists = append(feedLists, FeedListEntry{478 feedLists = append(feedLists, FeedListEntry{
463- DID: did,479+ DID: u.did,
464- SubscriptionCount: subCount,480+ SubscriptionCount: u.subCount,
465- Subscriptions: subs,481+ Subscriptions: subsByDID[u.did],
466 })482 })
467 }483 }
468 484
modified internal/db/article.go +6 -4
@@ -3,7 +3,6 @@ package db
33 import (
44 "context"
55 "database/sql"
6- "fmt"
76 )
87
98 type Article struct {
@@ -78,7 +77,8 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
7877 args = append(args, feedURL)
7978 }
8079
81- query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
80+ query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?`
81+ args = append(args, limit, offset)
8282
8383 rows, err := db.QueryContext(ctx, query, args...)
8484 if err != nil {
@@ -117,7 +117,8 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
117117 args = append(args, feedURL)
118118 }
119119
120- query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
120+ query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?`
121+ args = append(args, limit, offset)
121122
122123 rows, err := db.QueryContext(ctx, query, args...)
123124 if err != nil {
@@ -156,7 +157,8 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
156157 args = append(args, feedURL)
157158 }
158159
159- query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)
160+ query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?`
161+ args = append(args, limit, offset)
160162
161163 rows, err := db.QueryContext(ctx, query, args...)
162164 if err != nil {
@@ -3,7 +3,6 @@ package db
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6- "fmt"
7 )6 )
8 7
9 type Article struct {8 type Article struct {
@@ -78,7 +77,8 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
78 args = append(args, feedURL)77 args = append(args, feedURL)
79 }78 }
80 79
81- query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)80+ query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?`
81+ args = append(args, limit, offset)
82 82
83 rows, err := db.QueryContext(ctx, query, args...)83 rows, err := db.QueryContext(ctx, query, args...)
84 if err != nil {84 if err != nil {
@@ -117,7 +117,8 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
117 args = append(args, feedURL)117 args = append(args, feedURL)
118 }118 }
119 119
120- query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)120+ query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?`
121+ args = append(args, limit, offset)
121 122
122 rows, err := db.QueryContext(ctx, query, args...)123 rows, err := db.QueryContext(ctx, query, args...)
123 if err != nil {124 if err != nil {
@@ -156,7 +157,8 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
156 args = append(args, feedURL)157 args = append(args, feedURL)
157 }158 }
158 159
159- query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset)160+ query += ` ORDER BY a.published DESC LIMIT ? OFFSET ?`
161+ args = append(args, limit, offset)
160 162
161 rows, err := db.QueryContext(ctx, query, args...)163 rows, err := db.QueryContext(ctx, query, args...)
162 if err != nil {164 if err != nil {
modified internal/db/cluster.go +66 -30
@@ -3,16 +3,21 @@ package db
33 import (
44 "context"
55 "database/sql"
6- "fmt"
76 )
87
98 func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
10- _, err := db.ExecContext(ctx, `DELETE FROM feed_similarity`)
9+ tx, err := db.BeginTx(ctx, nil)
1110 if err != nil {
1211 return err
1312 }
13+ defer tx.Rollback()
1414
15- rows, err := db.QueryContext(ctx, `
15+ _, err = tx.ExecContext(ctx, `DELETE FROM feed_similarity`)
16+ if err != nil {
17+ return err
18+ }
19+
20+ rows, err := tx.QueryContext(ctx, `
1621 SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap
1722 FROM subscriptions s1
1823 JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
@@ -48,7 +53,7 @@ func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
4853 }
4954
5055 if len(subCounts) > 0 {
51- countRows, err := db.QueryContext(ctx, `
56+ countRows, err := tx.QueryContext(ctx, `
5257 SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url
5358 `)
5459 if err != nil {
@@ -66,31 +71,42 @@ func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
6671 countRows.Close()
6772 }
6873
74+ stmt, err := tx.PrepareContext(ctx, `
75+ INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at)
76+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
77+ `)
78+ if err != nil {
79+ return err
80+ }
81+ defer stmt.Close()
82+
6983 for _, p := range pairs {
7084 total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap
7185 if total == 0 {
7286 continue
7387 }
7488 jaccard := float64(p.overlap) / float64(total)
75- _, err := db.ExecContext(ctx, `
76- INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at)
77- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
78- `, p.feedA, p.feedB, jaccard)
79- if err != nil {
89+ if _, err := stmt.ExecContext(ctx, p.feedA, p.feedB, jaccard); err != nil {
8090 return err
8191 }
8292 }
8393
84- return nil
94+ return tx.Commit()
8595 }
8696
8797 func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
88- _, err := db.ExecContext(ctx, `DELETE FROM user_similarity`)
98+ tx, err := db.BeginTx(ctx, nil)
8999 if err != nil {
90100 return err
91101 }
102+ defer tx.Rollback()
92103
93- rows, err := db.QueryContext(ctx, `
104+ _, err = tx.ExecContext(ctx, `DELETE FROM user_similarity`)
105+ if err != nil {
106+ return err
107+ }
108+
109+ rows, err := tx.QueryContext(ctx, `
94110 SELECT s1.user_did, s2.user_did, COUNT(*) AS common
95111 FROM subscriptions s1
96112 JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url
@@ -126,7 +142,7 @@ func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
126142 }
127143
128144 if len(subCounts) > 0 {
129- countRows, err := db.QueryContext(ctx, `
145+ countRows, err := tx.QueryContext(ctx, `
130146 SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did
131147 `)
132148 if err != nil {
@@ -144,33 +160,44 @@ func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
144160 countRows.Close()
145161 }
146162
163+ stmt, err := tx.PrepareContext(ctx, `
164+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at)
165+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
166+ `)
167+ if err != nil {
168+ return err
169+ }
170+ defer stmt.Close()
171+
147172 for _, p := range pairs {
148173 total := subCounts[p.userA] + subCounts[p.userB] - p.common
149174 if total == 0 {
150175 continue
151176 }
152177 jaccard := float64(p.common) / float64(total)
153- _, err := db.ExecContext(ctx, `
154- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at)
155- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
156- `, p.userA, p.userB, jaccard, p.common)
157- if err != nil {
178+ if _, err := stmt.ExecContext(ctx, p.userA, p.userB, jaccard, p.common); err != nil {
158179 return err
159180 }
160181 }
161182
162- return nil
183+ return tx.Commit()
163184 }
164185
165186 func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error {
166- _, err := db.ExecContext(ctx, `
187+ tx, err := db.BeginTx(ctx, nil)
188+ if err != nil {
189+ return err
190+ }
191+ defer tx.Rollback()
192+
193+ _, err = tx.ExecContext(ctx, `
167194 DELETE FROM user_feed_recommendations WHERE user_did = ?
168195 `, userDID)
169196 if err != nil {
170197 return err
171198 }
172199
173- rows, err := db.QueryContext(ctx, `
200+ rows, err := tx.QueryContext(ctx, `
174201 SELECT
175202 CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed,
176203 SUM(fs.jaccard) AS score
@@ -186,21 +213,30 @@ func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) er
186213 }
187214 defer rows.Close()
188215
216+ stmt, err := tx.PrepareContext(ctx, `
217+ INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at)
218+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
219+ `)
220+ if err != nil {
221+ return err
222+ }
223+ defer stmt.Close()
224+
189225 for rows.Next() {
190226 var feedURL string
191227 var score float64
192228 if err := rows.Scan(&feedURL, &score); err != nil {
193229 return err
194230 }
195- _, err := db.ExecContext(ctx, `
196- INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at)
197- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
198- `, userDID, feedURL, score)
199- if err != nil {
231+ if _, err := stmt.ExecContext(ctx, userDID, feedURL, score); err != nil {
200232 return err
201233 }
202234 }
203- return rows.Err()
235+ if err := rows.Err(); err != nil {
236+ return err
237+ }
238+
239+ return tx.Commit()
204240 }
205241
206242 func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
@@ -241,7 +277,7 @@ func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit
241277 }
242278
243279 func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
244- rows, err := db.QueryContext(ctx, fmt.Sprintf(`
280+ rows, err := db.QueryContext(ctx, `
245281 SELECT
246282 CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user,
247283 us.jaccard, us.common_feeds,
@@ -251,8 +287,8 @@ func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limi
251287 WHERE (us.user_a = ? OR us.user_b = ?)
252288 AND u.handle IS NOT NULL AND u.handle != ''
253289 ORDER BY us.jaccard DESC
254- LIMIT %d
255- `, limit), userDID, userDID, userDID, userDID)
290+ LIMIT ?
291+ `, userDID, userDID, userDID, userDID, limit)
256292 if err != nil {
257293 return nil, err
258294 }
@@ -3,16 +3,21 @@ package db
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6- "fmt"
7 )6 )
8 7
9 func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {8 func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
10- _, err := db.ExecContext(ctx, `DELETE FROM feed_similarity`)9+ tx, err := db.BeginTx(ctx, nil)
11 if err != nil {10 if err != nil {
12 return err11 return err
13 }12 }
13+ defer tx.Rollback()
14 14
15- rows, err := db.QueryContext(ctx, `15+ _, err = tx.ExecContext(ctx, `DELETE FROM feed_similarity`)
16+ if err != nil {
17+ return err
18+ }
19+
20+ rows, err := tx.QueryContext(ctx, `
16 SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap21 SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap
17 FROM subscriptions s122 FROM subscriptions s1
18 JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url23 JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
@@ -48,7 +53,7 @@ func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
48 }53 }
49 54
50 if len(subCounts) > 0 {55 if len(subCounts) > 0 {
51- countRows, err := db.QueryContext(ctx, `56+ countRows, err := tx.QueryContext(ctx, `
52 SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url57 SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url
53 `)58 `)
54 if err != nil {59 if err != nil {
@@ -66,31 +71,42 @@ func (db *DB) ComputeFeedSimilarity(ctx context.Context) error {
66 countRows.Close()71 countRows.Close()
67 }72 }
68 73
74+ stmt, err := tx.PrepareContext(ctx, `
75+ INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at)
76+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
77+ `)
78+ if err != nil {
79+ return err
80+ }
81+ defer stmt.Close()
82+
69 for _, p := range pairs {83 for _, p := range pairs {
70 total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap84 total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap
71 if total == 0 {85 if total == 0 {
72 continue86 continue
73 }87 }
74 jaccard := float64(p.overlap) / float64(total)88 jaccard := float64(p.overlap) / float64(total)
75- _, err := db.ExecContext(ctx, `89+ if _, err := stmt.ExecContext(ctx, p.feedA, p.feedB, jaccard); err != nil {
76- INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at)
77- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
78- `, p.feedA, p.feedB, jaccard)
79- if err != nil {
80 return err90 return err
81 }91 }
82 }92 }
83 93
84- return nil94+ return tx.Commit()
85 }95 }
86 96
87 func (db *DB) ComputeUserSimilarity(ctx context.Context) error {97 func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
88- _, err := db.ExecContext(ctx, `DELETE FROM user_similarity`)98+ tx, err := db.BeginTx(ctx, nil)
89 if err != nil {99 if err != nil {
90 return err100 return err
91 }101 }
102+ defer tx.Rollback()
92 103
93- rows, err := db.QueryContext(ctx, `104+ _, err = tx.ExecContext(ctx, `DELETE FROM user_similarity`)
105+ if err != nil {
106+ return err
107+ }
108+
109+ rows, err := tx.QueryContext(ctx, `
94 SELECT s1.user_did, s2.user_did, COUNT(*) AS common110 SELECT s1.user_did, s2.user_did, COUNT(*) AS common
95 FROM subscriptions s1111 FROM subscriptions s1
96 JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url112 JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url
@@ -126,7 +142,7 @@ func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
126 }142 }
127 143
128 if len(subCounts) > 0 {144 if len(subCounts) > 0 {
129- countRows, err := db.QueryContext(ctx, `145+ countRows, err := tx.QueryContext(ctx, `
130 SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did146 SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did
131 `)147 `)
132 if err != nil {148 if err != nil {
@@ -144,33 +160,44 @@ func (db *DB) ComputeUserSimilarity(ctx context.Context) error {
144 countRows.Close()160 countRows.Close()
145 }161 }
146 162
163+ stmt, err := tx.PrepareContext(ctx, `
164+ INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at)
165+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
166+ `)
167+ if err != nil {
168+ return err
169+ }
170+ defer stmt.Close()
171+
147 for _, p := range pairs {172 for _, p := range pairs {
148 total := subCounts[p.userA] + subCounts[p.userB] - p.common173 total := subCounts[p.userA] + subCounts[p.userB] - p.common
149 if total == 0 {174 if total == 0 {
150 continue175 continue
151 }176 }
152 jaccard := float64(p.common) / float64(total)177 jaccard := float64(p.common) / float64(total)
153- _, err := db.ExecContext(ctx, `178+ if _, err := stmt.ExecContext(ctx, p.userA, p.userB, jaccard, p.common); err != nil {
154- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at)
155- VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
156- `, p.userA, p.userB, jaccard, p.common)
157- if err != nil {
158 return err179 return err
159 }180 }
160 }181 }
161 182
162- return nil183+ return tx.Commit()
163 }184 }
164 185
165 func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error {186 func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error {
166- _, err := db.ExecContext(ctx, `187+ tx, err := db.BeginTx(ctx, nil)
188+ if err != nil {
189+ return err
190+ }
191+ defer tx.Rollback()
192+
193+ _, err = tx.ExecContext(ctx, `
167 DELETE FROM user_feed_recommendations WHERE user_did = ?194 DELETE FROM user_feed_recommendations WHERE user_did = ?
168 `, userDID)195 `, userDID)
169 if err != nil {196 if err != nil {
170 return err197 return err
171 }198 }
172 199
173- rows, err := db.QueryContext(ctx, `200+ rows, err := tx.QueryContext(ctx, `
174 SELECT201 SELECT
175 CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed,202 CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed,
176 SUM(fs.jaccard) AS score203 SUM(fs.jaccard) AS score
@@ -186,21 +213,30 @@ func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) er
186 }213 }
187 defer rows.Close()214 defer rows.Close()
188 215
216+ stmt, err := tx.PrepareContext(ctx, `
217+ INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at)
218+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
219+ `)
220+ if err != nil {
221+ return err
222+ }
223+ defer stmt.Close()
224+
189 for rows.Next() {225 for rows.Next() {
190 var feedURL string226 var feedURL string
191 var score float64227 var score float64
192 if err := rows.Scan(&feedURL, &score); err != nil {228 if err := rows.Scan(&feedURL, &score); err != nil {
193 return err229 return err
194 }230 }
195- _, err := db.ExecContext(ctx, `231+ if _, err := stmt.ExecContext(ctx, userDID, feedURL, score); err != nil {
196- INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at)
197- VALUES (?, ?, ?, CURRENT_TIMESTAMP)
198- `, userDID, feedURL, score)
199- if err != nil {
200 return err232 return err
201 }233 }
202 }234 }
203- return rows.Err()235+ if err := rows.Err(); err != nil {
236+ return err
237+ }
238+
239+ return tx.Commit()
204 }240 }
205 241
206 func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {242 func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
@@ -241,7 +277,7 @@ func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit
241 }277 }
242 278
243 func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {279 func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) {
244- rows, err := db.QueryContext(ctx, fmt.Sprintf(`280+ rows, err := db.QueryContext(ctx, `
245 SELECT281 SELECT
246 CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user,282 CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user,
247 us.jaccard, us.common_feeds,283 us.jaccard, us.common_feeds,
@@ -251,8 +287,8 @@ func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limi
251 WHERE (us.user_a = ? OR us.user_b = ?)287 WHERE (us.user_a = ? OR us.user_b = ?)
252 AND u.handle IS NOT NULL AND u.handle != ''288 AND u.handle IS NOT NULL AND u.handle != ''
253 ORDER BY us.jaccard DESC289 ORDER BY us.jaccard DESC
254- LIMIT %d290+ LIMIT ?
255- `, limit), userDID, userDID, userDID, userDID)291+ `, userDID, userDID, userDID, userDID, limit)
256 if err != nil {292 if err != nil {
257 return nil, err293 return nil, err
258 }294 }
modified internal/db/db.go +10 -1
@@ -31,12 +31,14 @@ type DB struct {
3131 }
3232
3333 func Open(path string) (*DB, error) {
34- db, err := sql.Open("sqlite3", path)
34+ db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
3535 if err != nil {
3636 return nil, err
3737 }
3838
3939 db.SetMaxOpenConns(1)
40+ db.SetMaxIdleConns(2)
41+ db.SetConnMaxLifetime(30 * time.Minute)
4042
4143 if err := initSchema(db); err != nil {
4244 db.Close()
@@ -189,12 +191,19 @@ var schema = []string{
189191 )`,
190192 `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
191193 `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
194+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`,
192195 `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
193196 `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
197+ `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`,
194198 `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
195199 `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
200+ `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`,
201+ `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`,
196202 `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
197203 `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
204+ `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`,
198205 `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
199206 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
207+ `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
208+ `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`,
200209 }
@@ -31,12 +31,14 @@ type DB struct {
31 }31 }
32 32
33 func Open(path string) (*DB, error) {33 func Open(path string) (*DB, error) {
34- db, err := sql.Open("sqlite3", path)34+ db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
35 if err != nil {35 if err != nil {
36 return nil, err36 return nil, err
37 }37 }
38 38
39 db.SetMaxOpenConns(1)39 db.SetMaxOpenConns(1)
40+ db.SetMaxIdleConns(2)
41+ db.SetConnMaxLifetime(30 * time.Minute)
40 42
41 if err := initSchema(db); err != nil {43 if err := initSchema(db); err != nil {
42 db.Close()44 db.Close()
@@ -189,12 +191,19 @@ var schema = []string{
189 )`,191 )`,
190 `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,192 `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
191 `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,193 `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
194+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`,
192 `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,195 `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
193 `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,196 `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
197+ `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`,
194 `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,198 `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
195 `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,199 `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
200+ `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`,
201+ `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`,
196 `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,202 `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
197 `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,203 `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
204+ `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`,
198 `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,205 `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
199 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,206 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
207+ `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
208+ `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`,
200 }209 }
modified internal/db/feed.go +58 -10
@@ -3,7 +3,7 @@ package db
33 import (
44 "context"
55 "database/sql"
6- "fmt"
6+ "strings"
77 "time"
88 )
99
@@ -203,8 +203,18 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
203203 return err
204204 }
205205
206- for _, u := range feedURLs {
207- if _, err := tx.ExecContext(ctx, `UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, u); err != nil {
206+ if len(feedURLs) > 0 {
207+ ph := make([]string, len(feedURLs))
208+ args := make([]any, len(feedURLs))
209+ for i, u := range feedURLs {
210+ ph[i] = "?"
211+ args[i] = u
212+ }
213+ _, err = tx.ExecContext(ctx, `
214+ UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0)
215+ WHERE feed_url IN (`+strings.Join(ph, ",")+`)
216+ `, args...)
217+ if err != nil {
208218 return err
209219 }
210220 }
@@ -212,6 +222,21 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
212222 return tx.Commit()
213223 }
214224
225+func (db *DB) GetSubscriptionByURI(ctx context.Context, userDID, uri string) (*Subscription, error) {
226+ s := &Subscription{}
227+ err := db.QueryRowContext(ctx, `
228+ SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
229+ s.uri, s.cid
230+ FROM subscriptions s
231+ LEFT JOIN feeds f ON s.feed_url = f.feed_url
232+ WHERE s.user_did = ? AND s.uri = ?
233+ `, userDID, uri).Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.URI, &s.CID)
234+ if err != nil {
235+ return nil, err
236+ }
237+ return s, nil
238+}
239+
215240 func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) {
216241 s := &Subscription{}
217242 err := db.QueryRowContext(ctx, `
@@ -240,7 +265,8 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l
240265 args = append(args, category)
241266 }
242267
243- query += fmt.Sprintf(` ORDER BY s.added_at DESC LIMIT %d OFFSET %d`, limit, offset)
268+ query += ` ORDER BY s.added_at DESC LIMIT ? OFFSET ?`
269+ args = append(args, limit, offset)
244270
245271 rows, err := db.QueryContext(ctx, query, args...)
246272 if err != nil {
@@ -267,6 +293,28 @@ func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, er
267293 return count, err
268294 }
269295
296+func (db *DB) GetCategories(ctx context.Context, userDID string) ([]string, error) {
297+ rows, err := db.QueryContext(ctx, `
298+ SELECT DISTINCT category FROM subscriptions
299+ WHERE user_did = ? AND category IS NOT NULL AND category != ''
300+ ORDER BY category
301+ `, userDID)
302+ if err != nil {
303+ return nil, err
304+ }
305+ defer rows.Close()
306+
307+ var categories []string
308+ for rows.Next() {
309+ var cat string
310+ if err := rows.Scan(&cat); err != nil {
311+ return nil, err
312+ }
313+ categories = append(categories, cat)
314+ }
315+ return categories, rows.Err()
316+}
317+
270318 func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error {
271319 _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL)
272320 return err
@@ -301,14 +349,14 @@ func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int)
301349 }
302350
303351 func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
304- rows, err := db.QueryContext(ctx, fmt.Sprintf(`
352+ rows, err := db.QueryContext(ctx, `
305353 SELECT feed_url, title, site_url, description, feed_type,
306354 last_fetched_at, last_error, subscriber_count, etag, last_modified,
307355 fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url
308356 FROM feeds
309357 ORDER BY subscriber_count DESC
310- LIMIT %d OFFSET %d
311- `, limit, offset))
358+ LIMIT ? OFFSET ?
359+ `, limit, offset)
312360 if err != nil {
313361 return nil, err
314362 }
@@ -328,15 +376,15 @@ func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, err
328376 }
329377
330378 func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {
331- rows, err := db.QueryContext(ctx, fmt.Sprintf(`
379+ rows, err := db.QueryContext(ctx, `
332380 SELECT feed_url, title, site_url, description, feed_type,
333381 last_fetched_at, last_error, subscriber_count, etag, last_modified,
334382 fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url
335383 FROM feeds
336384 WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
337385 ORDER BY subscriber_count DESC
338- LIMIT %d OFFSET %d
339- `, limit, offset), userDID)
386+ LIMIT ? OFFSET ?
387+ `, userDID, limit, offset)
340388 if err != nil {
341389 return nil, err
342390 }
@@ -3,7 +3,7 @@ package db
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6- "fmt"6+ "strings"
7 "time"7 "time"
8 )8 )
9 9
@@ -203,8 +203,18 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
203 return err203 return err
204 }204 }
205 205
206- for _, u := range feedURLs {206+ if len(feedURLs) > 0 {
207- if _, err := tx.ExecContext(ctx, `UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?`, u); err != nil {207+ ph := make([]string, len(feedURLs))
208+ args := make([]any, len(feedURLs))
209+ for i, u := range feedURLs {
210+ ph[i] = "?"
211+ args[i] = u
212+ }
213+ _, err = tx.ExecContext(ctx, `
214+ UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0)
215+ WHERE feed_url IN (`+strings.Join(ph, ",")+`)
216+ `, args...)
217+ if err != nil {
208 return err218 return err
209 }219 }
210 }220 }
@@ -212,6 +222,21 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
212 return tx.Commit()222 return tx.Commit()
213 }223 }
214 224
225+func (db *DB) GetSubscriptionByURI(ctx context.Context, userDID, uri string) (*Subscription, error) {
226+ s := &Subscription{}
227+ err := db.QueryRowContext(ctx, `
228+ SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
229+ s.uri, s.cid
230+ FROM subscriptions s
231+ LEFT JOIN feeds f ON s.feed_url = f.feed_url
232+ WHERE s.user_did = ? AND s.uri = ?
233+ `, userDID, uri).Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.URI, &s.CID)
234+ if err != nil {
235+ return nil, err
236+ }
237+ return s, nil
238+}
239+
215 func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) {240 func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) {
216 s := &Subscription{}241 s := &Subscription{}
217 err := db.QueryRowContext(ctx, `242 err := db.QueryRowContext(ctx, `
@@ -240,7 +265,8 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l
240 args = append(args, category)265 args = append(args, category)
241 }266 }
242 267
243- query += fmt.Sprintf(` ORDER BY s.added_at DESC LIMIT %d OFFSET %d`, limit, offset)268+ query += ` ORDER BY s.added_at DESC LIMIT ? OFFSET ?`
269+ args = append(args, limit, offset)
244 270
245 rows, err := db.QueryContext(ctx, query, args...)271 rows, err := db.QueryContext(ctx, query, args...)
246 if err != nil {272 if err != nil {
@@ -267,6 +293,28 @@ func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, er
267 return count, err293 return count, err
268 }294 }
269 295
296+func (db *DB) GetCategories(ctx context.Context, userDID string) ([]string, error) {
297+ rows, err := db.QueryContext(ctx, `
298+ SELECT DISTINCT category FROM subscriptions
299+ WHERE user_did = ? AND category IS NOT NULL AND category != ''
300+ ORDER BY category
301+ `, userDID)
302+ if err != nil {
303+ return nil, err
304+ }
305+ defer rows.Close()
306+
307+ var categories []string
308+ for rows.Next() {
309+ var cat string
310+ if err := rows.Scan(&cat); err != nil {
311+ return nil, err
312+ }
313+ categories = append(categories, cat)
314+ }
315+ return categories, rows.Err()
316+}
317+
270 func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error {318 func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error {
271 _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL)319 _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL)
272 return err320 return err
@@ -301,14 +349,14 @@ func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int)
301 }349 }
302 350
303 func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {351 func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
304- rows, err := db.QueryContext(ctx, fmt.Sprintf(`352+ rows, err := db.QueryContext(ctx, `
305 SELECT feed_url, title, site_url, description, feed_type,353 SELECT feed_url, title, site_url, description, feed_type,
306 last_fetched_at, last_error, subscriber_count, etag, last_modified,354 last_fetched_at, last_error, subscriber_count, etag, last_modified,
307 fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url355 fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url
308 FROM feeds356 FROM feeds
309 ORDER BY subscriber_count DESC357 ORDER BY subscriber_count DESC
310- LIMIT %d OFFSET %d358+ LIMIT ? OFFSET ?
311- `, limit, offset))359+ `, limit, offset)
312 if err != nil {360 if err != nil {
313 return nil, err361 return nil, err
314 }362 }
@@ -328,15 +376,15 @@ func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, err
328 }376 }
329 377
330 func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {378 func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {
331- rows, err := db.QueryContext(ctx, fmt.Sprintf(`379+ rows, err := db.QueryContext(ctx, `
332 SELECT feed_url, title, site_url, description, feed_type,380 SELECT feed_url, title, site_url, description, feed_type,
333 last_fetched_at, last_error, subscriber_count, etag, last_modified,381 last_fetched_at, last_error, subscriber_count, etag, last_modified,
334 fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url382 fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url
335 FROM feeds383 FROM feeds
336 WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)384 WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
337 ORDER BY subscriber_count DESC385 ORDER BY subscriber_count DESC
338- LIMIT %d OFFSET %d386+ LIMIT ? OFFSET ?
339- `, limit, offset), userDID)387+ `, userDID, limit, offset)
340 if err != nil {388 if err != nil {
341 return nil, err389 return nil, err
342 }390 }
modified internal/db/social.go +25 -12
@@ -3,7 +3,6 @@ package db
33 import (
44 "context"
55 "database/sql"
6- "fmt"
76 "strings"
87 )
98
@@ -60,6 +59,18 @@ func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error {
6059 return err
6160 }
6261
62+func (db *DB) AnnotationExists(ctx context.Context, uri string) (bool, error) {
63+ var exists int
64+ err := db.QueryRowContext(ctx, `SELECT 1 FROM annotations WHERE uri = ?`, uri).Scan(&exists)
65+ if err == sql.ErrNoRows {
66+ return false, nil
67+ }
68+ if err != nil {
69+ return false, err
70+ }
71+ return true, nil
72+}
73+
6374 func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) {
6475 var conds []string
6576 var args []any
@@ -83,7 +94,8 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
8394 if len(conds) > 0 {
8495 query += ` WHERE ` + strings.Join(conds, " AND ")
8596 }
86- query += fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT %d OFFSET %d`, limit, offset)
97+ query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?`
98+ args = append(args, limit, offset)
8799
88100 rows, err := db.QueryContext(ctx, query, args...)
89101 if err != nil {
@@ -140,7 +152,8 @@ func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, o
140152 if len(conds) > 0 {
141153 query += ` WHERE ` + strings.Join(conds, " AND ")
142154 }
143- query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset)
155+ query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
156+ args = append(args, limit, offset)
144157
145158 rows, err := db.QueryContext(ctx, query, args...)
146159 if err != nil {
@@ -207,7 +220,7 @@ type TrendingItem struct {
207220 }
208221
209222 func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) {
210- rows, err := db.QueryContext(ctx, fmt.Sprintf(`
223+ rows, err := db.QueryContext(ctx, `
211224 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
212225 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
213226 COALESCE(f.favicon_url, ''),
@@ -227,8 +240,8 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st
227240 )
228241 GROUP BY ar.id
229242 ORDER BY like_count DESC, annotation_count DESC
230- LIMIT %d OFFSET %d
231- `, limit, offset), since, since, userDID, userDID, userDID, userDID, userDID)
243+ LIMIT ? OFFSET ?
244+ `, since, since, userDID, userDID, userDID, userDID, userDID, limit, offset)
232245 if err != nil {
233246 return nil, err
234247 }
@@ -248,7 +261,7 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st
248261 }
249262
250263 func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) {
251- rows, err := db.QueryContext(ctx, fmt.Sprintf(`
264+ rows, err := db.QueryContext(ctx, `
252265 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
253266 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
254267 COALESCE(f.favicon_url, ''),
@@ -261,8 +274,8 @@ func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, off
261274 WHERE l.created_at >= ?
262275 GROUP BY ar.id
263276 ORDER BY like_count DESC, annotation_count DESC
264- LIMIT %d OFFSET %d
265- `, limit, offset), since, since)
277+ LIMIT ? OFFSET ?
278+ `, since, since, limit, offset)
266279 if err != nil {
267280 return nil, err
268281 }
@@ -282,7 +295,7 @@ func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, off
282295 }
283296
284297 func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) {
285- rows, err := db.QueryContext(ctx, fmt.Sprintf(`
298+ rows, err := db.QueryContext(ctx, `
286299 SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content,
287300 a.published, a.updated, a.fetched_at,
288301 COALESCE(f.title, '')
@@ -291,8 +304,8 @@ func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offs
291304 LEFT JOIN feeds f ON f.feed_url = a.feed_url
292305 WHERE l.author_did = ?
293306 ORDER BY l.created_at DESC
294- LIMIT %d OFFSET %d
295- `, limit, offset), userDID)
307+ LIMIT ? OFFSET ?
308+ `, userDID, limit, offset)
296309 if err != nil {
297310 return nil, err
298311 }
@@ -3,7 +3,6 @@ package db
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6- "fmt"
7 "strings"6 "strings"
8 )7 )
9 8
@@ -60,6 +59,18 @@ func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error {
60 return err59 return err
61 }60 }
62 61
62+func (db *DB) AnnotationExists(ctx context.Context, uri string) (bool, error) {
63+ var exists int
64+ err := db.QueryRowContext(ctx, `SELECT 1 FROM annotations WHERE uri = ?`, uri).Scan(&exists)
65+ if err == sql.ErrNoRows {
66+ return false, nil
67+ }
68+ if err != nil {
69+ return false, err
70+ }
71+ return true, nil
72+}
73+
63 func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) {74 func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) {
64 var conds []string75 var conds []string
65 var args []any76 var args []any
@@ -83,7 +94,8 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
83 if len(conds) > 0 {94 if len(conds) > 0 {
84 query += ` WHERE ` + strings.Join(conds, " AND ")95 query += ` WHERE ` + strings.Join(conds, " AND ")
85 }96 }
86- query += fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT %d OFFSET %d`, limit, offset)97+ query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?`
98+ args = append(args, limit, offset)
87 99
88 rows, err := db.QueryContext(ctx, query, args...)100 rows, err := db.QueryContext(ctx, query, args...)
89 if err != nil {101 if err != nil {
@@ -140,7 +152,8 @@ func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, o
140 if len(conds) > 0 {152 if len(conds) > 0 {
141 query += ` WHERE ` + strings.Join(conds, " AND ")153 query += ` WHERE ` + strings.Join(conds, " AND ")
142 }154 }
143- query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset)155+ query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
156+ args = append(args, limit, offset)
144 157
145 rows, err := db.QueryContext(ctx, query, args...)158 rows, err := db.QueryContext(ctx, query, args...)
146 if err != nil {159 if err != nil {
@@ -207,7 +220,7 @@ type TrendingItem struct {
207 }220 }
208 221
209 func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) {222 func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) {
210- rows, err := db.QueryContext(ctx, fmt.Sprintf(`223+ rows, err := db.QueryContext(ctx, `
211 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),224 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
212 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),225 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
213 COALESCE(f.favicon_url, ''),226 COALESCE(f.favicon_url, ''),
@@ -227,8 +240,8 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st
227 )240 )
228 GROUP BY ar.id241 GROUP BY ar.id
229 ORDER BY like_count DESC, annotation_count DESC242 ORDER BY like_count DESC, annotation_count DESC
230- LIMIT %d OFFSET %d243+ LIMIT ? OFFSET ?
231- `, limit, offset), since, since, userDID, userDID, userDID, userDID, userDID)244+ `, since, since, userDID, userDID, userDID, userDID, userDID, limit, offset)
232 if err != nil {245 if err != nil {
233 return nil, err246 return nil, err
234 }247 }
@@ -248,7 +261,7 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st
248 }261 }
249 262
250 func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) {263 func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) {
251- rows, err := db.QueryContext(ctx, fmt.Sprintf(`264+ rows, err := db.QueryContext(ctx, `
252 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),265 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
253 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),266 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
254 COALESCE(f.favicon_url, ''),267 COALESCE(f.favicon_url, ''),
@@ -261,8 +274,8 @@ func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, off
261 WHERE l.created_at >= ?274 WHERE l.created_at >= ?
262 GROUP BY ar.id275 GROUP BY ar.id
263 ORDER BY like_count DESC, annotation_count DESC276 ORDER BY like_count DESC, annotation_count DESC
264- LIMIT %d OFFSET %d277+ LIMIT ? OFFSET ?
265- `, limit, offset), since, since)278+ `, since, since, limit, offset)
266 if err != nil {279 if err != nil {
267 return nil, err280 return nil, err
268 }281 }
@@ -282,7 +295,7 @@ func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, off
282 }295 }
283 296
284 func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) {297 func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) {
285- rows, err := db.QueryContext(ctx, fmt.Sprintf(`298+ rows, err := db.QueryContext(ctx, `
286 SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content,299 SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content,
287 a.published, a.updated, a.fetched_at,300 a.published, a.updated, a.fetched_at,
288 COALESCE(f.title, '')301 COALESCE(f.title, '')
@@ -291,8 +304,8 @@ func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offs
291 LEFT JOIN feeds f ON f.feed_url = a.feed_url304 LEFT JOIN feeds f ON f.feed_url = a.feed_url
292 WHERE l.author_did = ?305 WHERE l.author_did = ?
293 ORDER BY l.created_at DESC306 ORDER BY l.created_at DESC
294- LIMIT %d OFFSET %d307+ LIMIT ? OFFSET ?
295- `, limit, offset), userDID)308+ `, userDID, limit, offset)
296 if err != nil {309 if err != nil {
297 return nil, err310 return nil, err
298 }311 }
modified internal/feed/discover.go +6 -6
@@ -21,20 +21,21 @@ var (
2121 relFeedRe = regexp.MustCompile(`rel="(alternate|feed)"`)
2222 typeFeedRe = regexp.MustCompile(`type="([^"]*(?:rss|atom|feed|xml)[^"]*)"`)
2323 relIconRe = regexp.MustCompile(`rel="[^"]*icon[^"]*"`)
24+ baseHrefRe = regexp.MustCompile(`<base[^>]+href="([^"]*)"`)
2425 faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"}
26+
27+ discoverClient = &http.Client{Timeout: 15 * time.Second}
2528 )
2629
2730 func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) {
28- client := &http.Client{Timeout: 15 * time.Second}
29-
3031 result := &DiscoveryResult{}
3132
32- favicon := discoverFavicon(ctx, client, siteURL)
33+ favicon := discoverFavicon(ctx, discoverClient, siteURL)
3334 if favicon != "" {
3435 result.Favicon = favicon
3536 }
3637
37- feeds := discoverFeedLinks(ctx, client, siteURL)
38+ feeds := discoverFeedLinks(ctx, discoverClient, siteURL)
3839 result.FeedURLs = feeds
3940
4041 return result, nil
@@ -165,8 +166,7 @@ func tryDefaultFavicons(ctx context.Context, client *http.Client, siteURL string
165166 }
166167
167168 func resolveBaseURL(siteURL, html string) string {
168- baseRe := regexp.MustCompile(`<base[^>]+href="([^"]*)"`)
169- match := baseRe.FindStringSubmatch(html)
169+ match := baseHrefRe.FindStringSubmatch(html)
170170 if len(match) >= 2 && match[1] != "" {
171171 return resolveURL(siteURL, match[1])
172172 }
@@ -21,20 +21,21 @@ var (
21 relFeedRe = regexp.MustCompile(`rel="(alternate|feed)"`)21 relFeedRe = regexp.MustCompile(`rel="(alternate|feed)"`)
22 typeFeedRe = regexp.MustCompile(`type="([^"]*(?:rss|atom|feed|xml)[^"]*)"`)22 typeFeedRe = regexp.MustCompile(`type="([^"]*(?:rss|atom|feed|xml)[^"]*)"`)
23 relIconRe = regexp.MustCompile(`rel="[^"]*icon[^"]*"`)23 relIconRe = regexp.MustCompile(`rel="[^"]*icon[^"]*"`)
24+ baseHrefRe = regexp.MustCompile(`<base[^>]+href="([^"]*)"`)
24 faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"}25 faviconPaths = []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"}
26+
27+ discoverClient = &http.Client{Timeout: 15 * time.Second}
25 )28 )
26 29
27 func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) {30 func Discover(ctx context.Context, siteURL string) (*DiscoveryResult, error) {
28- client := &http.Client{Timeout: 15 * time.Second}
29-
30 result := &DiscoveryResult{}31 result := &DiscoveryResult{}
31 32
32- favicon := discoverFavicon(ctx, client, siteURL)33+ favicon := discoverFavicon(ctx, discoverClient, siteURL)
33 if favicon != "" {34 if favicon != "" {
34 result.Favicon = favicon35 result.Favicon = favicon
35 }36 }
36 37
37- feeds := discoverFeedLinks(ctx, client, siteURL)38+ feeds := discoverFeedLinks(ctx, discoverClient, siteURL)
38 result.FeedURLs = feeds39 result.FeedURLs = feeds
39 40
40 return result, nil41 return result, nil
@@ -165,8 +166,7 @@ func tryDefaultFavicons(ctx context.Context, client *http.Client, siteURL string
165 }166 }
166 167
167 func resolveBaseURL(siteURL, html string) string {168 func resolveBaseURL(siteURL, html string) string {
168- baseRe := regexp.MustCompile(`<base[^>]+href="([^"]*)"`)169+ match := baseHrefRe.FindStringSubmatch(html)
169- match := baseRe.FindStringSubmatch(html)
170 if len(match) >= 2 && match[1] != "" {170 if len(match) >= 2 && match[1] != "" {
171 return resolveURL(siteURL, match[1])171 return resolveURL(siteURL, match[1])
172 }172 }
modified internal/feed/fetcher.go +14 -2
@@ -119,9 +119,21 @@ func (s *Scheduler) fetchAll(ctx context.Context) {
119119 s.logger.Error("failed to get feeds", "error", err)
120120 return
121121 }
122+
123+ sem := make(chan struct{}, 10)
124+ var wg sync.WaitGroup
122125 for _, f := range feeds {
123- s.FetchFeed(ctx, f)
124- }
126+ wg.Add(1)
127+ sem <- struct{}{}
128+ go func(feed *Feed) {
129+ defer func() {
130+ <-sem
131+ wg.Done()
132+ }()
133+ s.FetchFeed(ctx, feed)
134+ }(f)
135+ }
136+ wg.Wait()
125137 }
126138
127139 func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
@@ -119,9 +119,21 @@ func (s *Scheduler) fetchAll(ctx context.Context) {
119 s.logger.Error("failed to get feeds", "error", err)119 s.logger.Error("failed to get feeds", "error", err)
120 return120 return
121 }121 }
122+
123+ sem := make(chan struct{}, 10)
124+ var wg sync.WaitGroup
122 for _, f := range feeds {125 for _, f := range feeds {
123- s.FetchFeed(ctx, f)126+ wg.Add(1)
124- }127+ sem <- struct{}{}
128+ go func(feed *Feed) {
129+ defer func() {
130+ <-sem
131+ wg.Done()
132+ }()
133+ s.FetchFeed(ctx, feed)
134+ }(f)
135+ }
136+ wg.Wait()
125 }137 }
126 138
127 func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {139 func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) {
modified internal/feed/parser.go +1 -1
@@ -99,7 +99,7 @@ type jsonFeed struct {
9999 }
100100
101101 func Parse(r io.Reader, feedURL string) (*ParseResult, error) {
102- data, err := io.ReadAll(r)
102+ data, err := io.ReadAll(io.LimitReader(r, 10*1024*1024))
103103 if err != nil {
104104 return nil, fmt.Errorf("reading feed data: %w", err)
105105 }
@@ -99,7 +99,7 @@ type jsonFeed struct {
99 }99 }
100 100
101 func Parse(r io.Reader, feedURL string) (*ParseResult, error) {101 func Parse(r io.Reader, feedURL string) (*ParseResult, error) {
102- data, err := io.ReadAll(r)102+ data, err := io.ReadAll(io.LimitReader(r, 10*1024*1024))
103 if err != nil {103 if err != nil {
104 return nil, fmt.Errorf("reading feed data: %w", err)104 return nil, fmt.Errorf("reading feed data: %w", err)
105 }105 }
modified internal/server/feeds_handler.go +1 -8
@@ -29,14 +29,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
2929 peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
3030 deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7)
3131
32- seen := make(map[string]bool)
33- var categories []string
34- for _, sub := range allSubs {
35- if sub.Category.Valid && sub.Category.String != "" && !seen[sub.Category.String] {
36- seen[sub.Category.String] = true
37- categories = append(categories, sub.Category.String)
38- }
39- }
32+ categories, _ := s.db.GetCategories(r.Context(), user.DID)
4033
4134 s.render(w, r, "feeds.html", map[string]any{
4235 "User": user,
@@ -29,14 +29,7 @@ func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) {
29 peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)29 peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5)
30 deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7)30 deadFeeds, _ := s.db.ListDeadFeeds(r.Context(), user.DID, 7)
31 31
32- seen := make(map[string]bool)32+ categories, _ := s.db.GetCategories(r.Context(), user.DID)
33- var categories []string
34- for _, sub := range allSubs {
35- if sub.Category.Valid && sub.Category.String != "" && !seen[sub.Category.String] {
36- seen[sub.Category.String] = true
37- categories = append(categories, sub.Category.String)
38- }
39- }
40 33
41 s.render(w, r, "feeds.html", map[string]any{34 s.render(w, r, "feeds.html", map[string]any{
42 "User": user,35 "User": user,