nandi/gleanpublic⑂ Fork 0
⑂ 34e8a2d
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 full-text search for articles with SQLite FTS5Unverified

Julien Robert committed 2026-04-21T13:34:24+02:00 Browse files
34e8a2d parent: ef3d40a
modified Makefile +3 -3
@@ -19,11 +19,11 @@ lex-parse:
1919
2020 .PHONY: dev
2121 dev: css
22- @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run .
22+ @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run -tags fts5 .
2323
2424 .PHONY: build
2525 build: css
26- go build -o glean .
26+ go build -tags fts5 -o glean .
2727
2828 .PHONY: css
2929 css:
@@ -35,7 +35,7 @@ css-watch:
3535
3636 .PHONY: test
3737 test:
38- go test ./...
38+ go test -tags fts5 ./...
3939
4040 .PHONY: clean
4141 clean:
@@ -19,11 +19,11 @@ lex-parse:
19 19
20 .PHONY: dev20 .PHONY: dev
21 dev: css21 dev: css
22- @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run .22+ @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run -tags fts5 .
23 23
24 .PHONY: build24 .PHONY: build
25 build: css25 build: css
26- go build -o glean .26+ go build -tags fts5 -o glean .
27 27
28 .PHONY: css28 .PHONY: css
29 css:29 css:
@@ -35,7 +35,7 @@ css-watch:
35 35
36 .PHONY: test36 .PHONY: test
37 test:37 test:
38- go test ./...38+ go test -tags fts5 ./...
39 39
40 .PHONY: clean40 .PHONY: clean
41 clean:41 clean:
modified internal/db/article.go +37 -0
@@ -3,6 +3,7 @@ package db
33 import (
44 "context"
55 "database/sql"
6+ "strings"
67 "time"
78 )
89
@@ -293,3 +294,39 @@ func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.T
293294 `, userDID, since).Scan(&count)
294295 return count, err
295296 }
297+
298+func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) {
299+ if strings.TrimSpace(query) == "" {
300+ return nil, nil
301+ }
302+
303+ rows, err := db.QueryContext(ctx, `
304+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content,
305+ a.published, a.updated, a.fetched_at,
306+ COALESCE(r.is_read, 0)
307+ FROM articles_fts ft
308+ JOIN articles a ON a.id = ft.rowid
309+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
310+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
311+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
312+ WHERE articles_fts MATCH ?
313+ ORDER BY ft.rank
314+ LIMIT ? OFFSET ?
315+ `, userDID, userDID, query, limit, offset)
316+ if err != nil {
317+ return nil, err
318+ }
319+ defer rows.Close()
320+
321+ var articles []*Article
322+ for rows.Next() {
323+ a := &Article{}
324+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.FeedFaviconURL, &a.GUID, &a.Title, &a.URL, &a.Author,
325+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt,
326+ &a.IsRead); err != nil {
327+ return nil, err
328+ }
329+ articles = append(articles, a)
330+ }
331+ return articles, rows.Err()
332+}
@@ -3,6 +3,7 @@ package db
3 import (3 import (
4 "context"4 "context"
5 "database/sql"5 "database/sql"
6+ "strings"
6 "time"7 "time"
7 )8 )
8 9
@@ -293,3 +294,39 @@ func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.T
293 `, userDID, since).Scan(&count)294 `, userDID, since).Scan(&count)
294 return count, err295 return count, err
295 }296 }
297+
298+func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) {
299+ if strings.TrimSpace(query) == "" {
300+ return nil, nil
301+ }
302+
303+ rows, err := db.QueryContext(ctx, `
304+ SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content,
305+ a.published, a.updated, a.fetched_at,
306+ COALESCE(r.is_read, 0)
307+ FROM articles_fts ft
308+ JOIN articles a ON a.id = ft.rowid
309+ JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
310+ LEFT JOIN feeds f ON a.feed_url = f.feed_url
311+ LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id
312+ WHERE articles_fts MATCH ?
313+ ORDER BY ft.rank
314+ LIMIT ? OFFSET ?
315+ `, userDID, userDID, query, limit, offset)
316+ if err != nil {
317+ return nil, err
318+ }
319+ defer rows.Close()
320+
321+ var articles []*Article
322+ for rows.Next() {
323+ a := &Article{}
324+ if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.FeedFaviconURL, &a.GUID, &a.Title, &a.URL, &a.Author,
325+ &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt,
326+ &a.IsRead); err != nil {
327+ return nil, err
328+ }
329+ articles = append(articles, a)
330+ }
331+ return articles, rows.Err()
332+}
modified internal/db/article_test.go +139 -0
@@ -185,3 +185,142 @@ func TestGetArticle_IncludesFullContent(t *testing.T) {
185185 assert.NilError(t, err)
186186 assert.Assert(t, !article.FullContent.Valid)
187187 }
188+
189+func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, feedURL string) {
190+ t.Helper()
191+
192+ userDID = "did:test:searcher"
193+ feedURL = "https://search.example.com/feed.xml"
194+
195+ _, err := database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "searcher")
196+ assert.NilError(t, err)
197+
198+ _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Tech Blog")
199+ assert.NilError(t, err)
200+
201+ _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL)
202+ assert.NilError(t, err)
203+
204+ articles := []struct {
205+ guid, title, summary, content string
206+ }{
207+ {"g1", "Go Programming Basics", "Learn Go fundamentals", "Go is a statically typed compiled language"},
208+ {"g2", "Rust Memory Safety", "Understanding ownership in Rust", "Rust provides memory safety without garbage collection"},
209+ {"g3", "Python Data Science", "NumPy and Pandas tutorial", "Python is popular for data analysis"},
210+ }
211+ for _, a := range articles {
212+ _, err := database.ExecContext(ctx, `
213+ INSERT INTO articles (feed_url, guid, title, summary, content) VALUES (?, ?, ?, ?, ?)
214+ `, feedURL, a.guid, a.title, a.summary, a.content)
215+ assert.NilError(t, err)
216+ }
217+
218+ return userDID, feedURL
219+}
220+
221+func TestSearchArticles_FindsByTitle(t *testing.T) {
222+ ctx := context.Background()
223+ db := setupTestDB(t)
224+ userDID, _ := seedSearchData(t, ctx, db)
225+
226+ results, err := db.SearchArticles(ctx, userDID, "Go Programming", 10, 0)
227+ assert.NilError(t, err)
228+ assert.Equal(t, len(results), 1)
229+ assert.Equal(t, results[0].Title, "Go Programming Basics")
230+}
231+
232+func TestSearchArticles_FindsBySummary(t *testing.T) {
233+ ctx := context.Background()
234+ db := setupTestDB(t)
235+ userDID, _ := seedSearchData(t, ctx, db)
236+
237+ results, err := db.SearchArticles(ctx, userDID, "ownership", 10, 0)
238+ assert.NilError(t, err)
239+ assert.Equal(t, len(results), 1)
240+ assert.Equal(t, results[0].Title, "Rust Memory Safety")
241+}
242+
243+func TestSearchArticles_FindsByContent(t *testing.T) {
244+ ctx := context.Background()
245+ db := setupTestDB(t)
246+ userDID, _ := seedSearchData(t, ctx, db)
247+
248+ results, err := db.SearchArticles(ctx, userDID, "garbage collection", 10, 0)
249+ assert.NilError(t, err)
250+ assert.Equal(t, len(results), 1)
251+ assert.Equal(t, results[0].Title, "Rust Memory Safety")
252+}
253+
254+func TestSearchArticles_NoResults(t *testing.T) {
255+ ctx := context.Background()
256+ db := setupTestDB(t)
257+ userDID, _ := seedSearchData(t, ctx, db)
258+
259+ results, err := db.SearchArticles(ctx, userDID, "nonexistent_xyz", 10, 0)
260+ assert.NilError(t, err)
261+ assert.Equal(t, len(results), 0)
262+}
263+
264+func TestSearchArticles_MultipleMatches(t *testing.T) {
265+ ctx := context.Background()
266+ db := setupTestDB(t)
267+ userDID, _ := seedSearchData(t, ctx, db)
268+
269+ results, err := db.SearchArticles(ctx, userDID, "Python", 10, 0)
270+ assert.NilError(t, err)
271+ assert.Assert(t, len(results) >= 1)
272+
273+ var found bool
274+ for _, a := range results {
275+ if a.Title == "Python Data Science" {
276+ found = true
277+ break
278+ }
279+ }
280+ assert.Assert(t, found, "expected to find Python Data Science article")
281+}
282+
283+func TestSearchArticles_ScopedToSubscriptions(t *testing.T) {
284+ ctx := context.Background()
285+ db := setupTestDB(t)
286+ userDID, feedURL := seedSearchData(t, ctx, db)
287+
288+ otherFeed := "https://other.example.com/feed.xml"
289+ _, err := db.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, otherFeed, "Other Feed")
290+ assert.NilError(t, err)
291+
292+ _, err = db.ExecContext(ctx, `
293+ INSERT INTO articles (feed_url, guid, title) VALUES (?, ?, ?)
294+ `, otherFeed, "other-1", "Go Concurrency Tips")
295+ assert.NilError(t, err)
296+
297+ results, err := db.SearchArticles(ctx, userDID, "Go", 10, 0)
298+ assert.NilError(t, err)
299+ for _, a := range results {
300+ assert.Equal(t, a.FeedURL, feedURL)
301+ }
302+}
303+
304+func TestSearchArticles_Pagination(t *testing.T) {
305+ ctx := context.Background()
306+ db := setupTestDB(t)
307+ userDID, _ := seedSearchData(t, ctx, db)
308+
309+ results, err := db.SearchArticles(ctx, userDID, "Go", 1, 0)
310+ assert.NilError(t, err)
311+ assert.Equal(t, len(results), 1)
312+
313+ results2, err := db.SearchArticles(ctx, userDID, "Go", 1, 1)
314+ assert.NilError(t, err)
315+ assert.Assert(t, len(results2) == 0 || results2[0].ID != results[0].ID)
316+}
317+
318+func TestSearchArticles_EmptyQuery(t *testing.T) {
319+ ctx := context.Background()
320+ db := setupTestDB(t)
321+ userDID, _ := seedSearchData(t, ctx, db)
322+
323+ results, err := db.SearchArticles(ctx, userDID, "", 10, 0)
324+ assert.NilError(t, err)
325+ assert.Equal(t, len(results), 0)
326+}
@@ -185,3 +185,142 @@ func TestGetArticle_IncludesFullContent(t *testing.T) {
185 assert.NilError(t, err)185 assert.NilError(t, err)
186 assert.Assert(t, !article.FullContent.Valid)186 assert.Assert(t, !article.FullContent.Valid)
187 }187 }
188+
189+func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, feedURL string) {
190+ t.Helper()
191+
192+ userDID = "did:test:searcher"
193+ feedURL = "https://search.example.com/feed.xml"
194+
195+ _, err := database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "searcher")
196+ assert.NilError(t, err)
197+
198+ _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Tech Blog")
199+ assert.NilError(t, err)
200+
201+ _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL)
202+ assert.NilError(t, err)
203+
204+ articles := []struct {
205+ guid, title, summary, content string
206+ }{
207+ {"g1", "Go Programming Basics", "Learn Go fundamentals", "Go is a statically typed compiled language"},
208+ {"g2", "Rust Memory Safety", "Understanding ownership in Rust", "Rust provides memory safety without garbage collection"},
209+ {"g3", "Python Data Science", "NumPy and Pandas tutorial", "Python is popular for data analysis"},
210+ }
211+ for _, a := range articles {
212+ _, err := database.ExecContext(ctx, `
213+ INSERT INTO articles (feed_url, guid, title, summary, content) VALUES (?, ?, ?, ?, ?)
214+ `, feedURL, a.guid, a.title, a.summary, a.content)
215+ assert.NilError(t, err)
216+ }
217+
218+ return userDID, feedURL
219+}
220+
221+func TestSearchArticles_FindsByTitle(t *testing.T) {
222+ ctx := context.Background()
223+ db := setupTestDB(t)
224+ userDID, _ := seedSearchData(t, ctx, db)
225+
226+ results, err := db.SearchArticles(ctx, userDID, "Go Programming", 10, 0)
227+ assert.NilError(t, err)
228+ assert.Equal(t, len(results), 1)
229+ assert.Equal(t, results[0].Title, "Go Programming Basics")
230+}
231+
232+func TestSearchArticles_FindsBySummary(t *testing.T) {
233+ ctx := context.Background()
234+ db := setupTestDB(t)
235+ userDID, _ := seedSearchData(t, ctx, db)
236+
237+ results, err := db.SearchArticles(ctx, userDID, "ownership", 10, 0)
238+ assert.NilError(t, err)
239+ assert.Equal(t, len(results), 1)
240+ assert.Equal(t, results[0].Title, "Rust Memory Safety")
241+}
242+
243+func TestSearchArticles_FindsByContent(t *testing.T) {
244+ ctx := context.Background()
245+ db := setupTestDB(t)
246+ userDID, _ := seedSearchData(t, ctx, db)
247+
248+ results, err := db.SearchArticles(ctx, userDID, "garbage collection", 10, 0)
249+ assert.NilError(t, err)
250+ assert.Equal(t, len(results), 1)
251+ assert.Equal(t, results[0].Title, "Rust Memory Safety")
252+}
253+
254+func TestSearchArticles_NoResults(t *testing.T) {
255+ ctx := context.Background()
256+ db := setupTestDB(t)
257+ userDID, _ := seedSearchData(t, ctx, db)
258+
259+ results, err := db.SearchArticles(ctx, userDID, "nonexistent_xyz", 10, 0)
260+ assert.NilError(t, err)
261+ assert.Equal(t, len(results), 0)
262+}
263+
264+func TestSearchArticles_MultipleMatches(t *testing.T) {
265+ ctx := context.Background()
266+ db := setupTestDB(t)
267+ userDID, _ := seedSearchData(t, ctx, db)
268+
269+ results, err := db.SearchArticles(ctx, userDID, "Python", 10, 0)
270+ assert.NilError(t, err)
271+ assert.Assert(t, len(results) >= 1)
272+
273+ var found bool
274+ for _, a := range results {
275+ if a.Title == "Python Data Science" {
276+ found = true
277+ break
278+ }
279+ }
280+ assert.Assert(t, found, "expected to find Python Data Science article")
281+}
282+
283+func TestSearchArticles_ScopedToSubscriptions(t *testing.T) {
284+ ctx := context.Background()
285+ db := setupTestDB(t)
286+ userDID, feedURL := seedSearchData(t, ctx, db)
287+
288+ otherFeed := "https://other.example.com/feed.xml"
289+ _, err := db.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, otherFeed, "Other Feed")
290+ assert.NilError(t, err)
291+
292+ _, err = db.ExecContext(ctx, `
293+ INSERT INTO articles (feed_url, guid, title) VALUES (?, ?, ?)
294+ `, otherFeed, "other-1", "Go Concurrency Tips")
295+ assert.NilError(t, err)
296+
297+ results, err := db.SearchArticles(ctx, userDID, "Go", 10, 0)
298+ assert.NilError(t, err)
299+ for _, a := range results {
300+ assert.Equal(t, a.FeedURL, feedURL)
301+ }
302+}
303+
304+func TestSearchArticles_Pagination(t *testing.T) {
305+ ctx := context.Background()
306+ db := setupTestDB(t)
307+ userDID, _ := seedSearchData(t, ctx, db)
308+
309+ results, err := db.SearchArticles(ctx, userDID, "Go", 1, 0)
310+ assert.NilError(t, err)
311+ assert.Equal(t, len(results), 1)
312+
313+ results2, err := db.SearchArticles(ctx, userDID, "Go", 1, 1)
314+ assert.NilError(t, err)
315+ assert.Assert(t, len(results2) == 0 || results2[0].ID != results[0].ID)
316+}
317+
318+func TestSearchArticles_EmptyQuery(t *testing.T) {
319+ ctx := context.Background()
320+ db := setupTestDB(t)
321+ userDID, _ := seedSearchData(t, ctx, db)
322+
323+ results, err := db.SearchArticles(ctx, userDID, "", 10, 0)
324+ assert.NilError(t, err)
325+ assert.Equal(t, len(results), 0)
326+}
modified internal/db/db.go +11 -0
@@ -206,4 +206,15 @@ var schema = []string{
206206 `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
207207 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
208208 `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`,
209+ `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,
210+ `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
211+ INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
212+ END`,
213+ `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
214+ INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
215+ END`,
216+ `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
217+ INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
218+ INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
219+ END`,
209220 }
@@ -206,4 +206,15 @@ var schema = []string{
206 `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)`,207 `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
208 `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`,208 `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`,
209+ `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,
210+ `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
211+ INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
212+ END`,
213+ `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
214+ INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
215+ END`,
216+ `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
217+ INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
218+ INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
219+ END`,
209 }220 }
modified internal/server/annotations_handler.go +6 -0
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "net/http"
77 "strconv"
8+ "strings"
89 "time"
910
1011 "github.com/go-chi/chi/v5"
@@ -81,12 +82,17 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request)
8182 }
8283
8384 if client := s.pdsClientForUser(r); client != nil {
85+ var tags []string
86+ if a.Tags.Valid && a.Tags.String != "" {
87+ tags = strings.Split(a.Tags.String, ",")
88+ }
8489 record := atproto.AnnotationRecord{
8590 CreatedAt: time.Now().Format(time.RFC3339),
8691 FeedURL: a.FeedURL,
8792 ArticleURL: a.ArticleURL,
8893 Quote: a.Quote.String,
8994 Note: a.Note.String,
95+ Tags: tags,
9096 Rating: int(a.Rating.Int64),
9197 }
9298 uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionAnnotation, record)
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "net/http"6 "net/http"
7 "strconv"7 "strconv"
8+ "strings"
8 "time"9 "time"
9 10
10 "github.com/go-chi/chi/v5"11 "github.com/go-chi/chi/v5"
@@ -81,12 +82,17 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request)
81 }82 }
82 83
83 if client := s.pdsClientForUser(r); client != nil {84 if client := s.pdsClientForUser(r); client != nil {
85+ var tags []string
86+ if a.Tags.Valid && a.Tags.String != "" {
87+ tags = strings.Split(a.Tags.String, ",")
88+ }
84 record := atproto.AnnotationRecord{89 record := atproto.AnnotationRecord{
85 CreatedAt: time.Now().Format(time.RFC3339),90 CreatedAt: time.Now().Format(time.RFC3339),
86 FeedURL: a.FeedURL,91 FeedURL: a.FeedURL,
87 ArticleURL: a.ArticleURL,92 ArticleURL: a.ArticleURL,
88 Quote: a.Quote.String,93 Quote: a.Quote.String,
89 Note: a.Note.String,94 Note: a.Note.String,
95+ Tags: tags,
90 Rating: int(a.Rating.Int64),96 Rating: int(a.Rating.Int64),
91 }97 }
92 uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionAnnotation, record)98 uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionAnnotation, record)
modified internal/server/articles_handler.go +23 -10
@@ -30,19 +30,24 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
3030 user := currentUser(r)
3131 feedURL := r.URL.Query().Get("feed")
3232 status := r.URL.Query().Get("status")
33+ searchQuery := r.URL.Query().Get("q")
3334
3435 page := pageFromRequest(r, 50)
3536
3637 var articles []*db.Article
3738 var err error
3839
39- switch status {
40- case "unread":
41- articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
42- case "read":
43- articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
44- default:
45- articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
40+ if searchQuery != "" {
41+ articles, err = s.db.SearchArticles(r.Context(), user.DID, searchQuery, page.Limit()+1, page.Offset())
42+ } else {
43+ switch status {
44+ case "unread":
45+ articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
46+ case "read":
47+ articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
48+ default:
49+ articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
50+ }
4651 }
4752
4853 if err != nil {
@@ -57,16 +62,24 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
5762 articles = articles[:page.PageSize]
5863 }
5964
60- s.render(w, r, "articles.html", map[string]any{
65+ data := map[string]any{
6166 "User": user,
6267 "Articles": articles,
6368 "FeedURL": feedURL,
6469 "Status": status,
70+ "SearchQuery": searchQuery,
6571 "Page": page,
6672 "BaseURL": "/articles",
67- "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}),
73+ "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}),
6874 "Now": time.Now(),
69- })
75+ }
76+
77+ if r.Header.Get("HX-Request") == "true" {
78+ s.render(w, r, "articles-content.html", data)
79+ return
80+ }
81+
82+ s.render(w, r, "articles.html", data)
7083 }
7184
7285 func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {
@@ -30,19 +30,24 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
30 user := currentUser(r)30 user := currentUser(r)
31 feedURL := r.URL.Query().Get("feed")31 feedURL := r.URL.Query().Get("feed")
32 status := r.URL.Query().Get("status")32 status := r.URL.Query().Get("status")
33+ searchQuery := r.URL.Query().Get("q")
33 34
34 page := pageFromRequest(r, 50)35 page := pageFromRequest(r, 50)
35 36
36 var articles []*db.Article37 var articles []*db.Article
37 var err error38 var err error
38 39
39- switch status {40+ if searchQuery != "" {
40- case "unread":41+ articles, err = s.db.SearchArticles(r.Context(), user.DID, searchQuery, page.Limit()+1, page.Offset())
41- articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())42+ } else {
42- case "read":43+ switch status {
43- articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())44+ case "unread":
44- default:45+ articles, err = s.db.ListUnreadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
45- articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())46+ case "read":
47+ articles, err = s.db.ListReadArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
48+ default:
49+ articles, err = s.db.ListArticles(r.Context(), user.DID, feedURL, page.Limit()+1, page.Offset())
50+ }
46 }51 }
47 52
48 if err != nil {53 if err != nil {
@@ -57,16 +62,24 @@ func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) {
57 articles = articles[:page.PageSize]62 articles = articles[:page.PageSize]
58 }63 }
59 64
60- s.render(w, r, "articles.html", map[string]any{65+ data := map[string]any{
61 "User": user,66 "User": user,
62 "Articles": articles,67 "Articles": articles,
63 "FeedURL": feedURL,68 "FeedURL": feedURL,
64 "Status": status,69 "Status": status,
70+ "SearchQuery": searchQuery,
65 "Page": page,71 "Page": page,
66 "BaseURL": "/articles",72 "BaseURL": "/articles",
67- "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status}),73+ "QueryParams": buildQueryParams(map[string]string{"feed": feedURL, "status": status, "q": searchQuery}),
68 "Now": time.Now(),74 "Now": time.Now(),
69- })75+ }
76+
77+ if r.Header.Get("HX-Request") == "true" {
78+ s.render(w, r, "articles-content.html", data)
79+ return
80+ }
81+
82+ s.render(w, r, "articles.html", data)
70 }83 }
71 84
72 func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {85 func (s *Server) handleNewArticleCount(w http.ResponseWriter, r *http.Request) {
modified internal/tmpl/articles.html +14 -13
@@ -10,6 +10,19 @@
1010 </div>
1111 <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p>
1212
13+ <div class="flex items-center gap-2 mb-6">
14+ <form method="GET" action="/articles" class="flex-1 max-w-md" id="search-form">
15+ <div class="relative">
16+ <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
17+ class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
18+ hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
19+ <svg class="w-4 h-4 text-spot-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
20+ {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}}
21+ {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
22+ </div>
23+ </form>
24+ </div>
25+
1326 <div class="flex items-center gap-2 mb-6">
1427 <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
1528 class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
@@ -29,19 +42,7 @@
2942 </div>
3043
3144 <div id="article-list" class="space-y-3">
32- {{range .Articles}}
33- {{template "article-card.html" .}}
34- {{else}}
35- <div class="bg-spot-surface rounded-xl py-12 px-6 text-center">
36- <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">
37- <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
38- </div>
39- <p class="text-sm text-spot-secondary mb-1">No articles found</p>
40- {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p>
41- {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p>
42- {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
43- </div>
44- {{end}}
45+ {{template "articles-content.html" .}}
4546 </div>
4647
4748 {{if or .Page.HasPrev .Page.HasNext}}
@@ -10,6 +10,19 @@
10 </div>10 </div>
11 <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p>11 <p class="text-sm text-spot-secondary mb-6">All your subscribed articles, newest first.</p>
12 12
13+ <div class="flex items-center gap-2 mb-6">
14+ <form method="GET" action="/articles" class="flex-1 max-w-md" id="search-form">
15+ <div class="relative">
16+ <input type="text" name="q" value="{{.SearchQuery}}" placeholder="Search articles..."
17+ class="w-full bg-spot-hover text-spot-text rounded-pill pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
18+ hx-get="/articles" hx-trigger="keyup changed delay:300ms, search" hx-target="#article-list" hx-swap="innerHTML" hx-include="[name='q']">
19+ <svg class="w-4 h-4 text-spot-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
20+ {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}}
21+ {{if .Status}}<input type="hidden" name="status" value="{{.Status}}">{{end}}
22+ </div>
23+ </form>
24+ </div>
25+
13 <div class="flex items-center gap-2 mb-6">26 <div class="flex items-center gap-2 mb-6">
14 <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"27 <a href="/articles?status=all{{if .FeedURL}}&feed={{.FeedURL}}{{end}}"
15 class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition28 class="px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-button transition
@@ -29,19 +42,7 @@
29 </div>42 </div>
30 43
31 <div id="article-list" class="space-y-3">44 <div id="article-list" class="space-y-3">
32- {{range .Articles}}45+ {{template "articles-content.html" .}}
33- {{template "article-card.html" .}}
34- {{else}}
35- <div class="bg-spot-surface rounded-xl py-12 px-6 text-center">
36- <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">
37- <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
38- </div>
39- <p class="text-sm text-spot-secondary mb-1">No articles found</p>
40- {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p>
41- {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p>
42- {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
43- </div>
44- {{end}}
45 </div>46 </div>
46 47
47 {{if or .Page.HasPrev .Page.HasNext}}48 {{if or .Page.HasPrev .Page.HasNext}}
added internal/tmpl/partials/articles-content.html +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+{{define "articles-content.html"}}
2+{{range .Articles}}
3+{{template "article-card.html" .}}
4+{{else}}
5+<div class="bg-spot-surface rounded-xl py-12 px-6 text-center">
6+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">
7+ <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
8+ </div>
9+ <p class="text-sm text-spot-secondary mb-1">No articles found</p>
10+ {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p>
11+ {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p>
12+ {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
13+</div>
14+{{end}}
15+{{end}}
new file mode 100644
@@ -0,0 +1,15 @@
1+{{define "articles-content.html"}}
2+{{range .Articles}}
3+{{template "article-card.html" .}}
4+{{else}}
5+<div class="bg-spot-surface rounded-xl py-12 px-6 text-center">
6+ <div class="w-14 h-14 rounded-full bg-spot-hover flex items-center justify-center mb-4 mx-auto">
7+ <svg class="w-7 h-7 text-spot-muted" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg>
8+ </div>
9+ <p class="text-sm text-spot-secondary mb-1">No articles found</p>
10+ {{if eq .Status "unread"}}<p class="text-xs text-spot-muted">You've read everything. Nice work.</p>
11+ {{else if eq .Status "read"}}<p class="text-xs text-spot-muted">Nothing marked as read yet.</p>
12+ {{else}}<p class="text-xs text-spot-muted">Subscribe to feeds to see articles here.</p>{{end}}
13+</div>
14+{{end}}
15+{{end}}