this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(data): update Store interface for source-aware queries

Update method signatures for InsertIRCLink, InsertQuote, and
InsertImage to accept struct pointers. Add SourceFilter parameter
to GetRecentIRCLinks, GetRecentImages, GetRecentQuotes,
SearchIRCLinks, SearchQuotes, and GetIRCLinksByURL. Add
applySourceFilter helper to GormStore. Update all callers in
handlers, scheduler, and tests. Fix composite index names to be
unique per table (idx_link_source, idx_image_source,
idx_quote_source) to avoid SQLite global index collisions.
Add source_filter_test.go for SourceFilter.IsEmpty tests.

+271 -198
+54 -51
internal/data/gorm_store.go
··· 29 29 return s.db.AutoMigrate(&IRCLink{}, &Image{}, &Quote{}, &LinkPreview{}, &Tag{}, &ArchiveLookup{}) 30 30 } 31 31 32 - func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int) ([]IRCLink, error) { 32 + func applySourceFilter(query *gorm.DB, filter SourceFilter) *gorm.DB { 33 + if filter.SourceType != nil { 34 + query = query.Where("source_type = ?", *filter.SourceType) 35 + } 36 + if filter.SourceNetwork != nil { 37 + query = query.Where("source_network = ?", *filter.SourceNetwork) 38 + } 39 + if filter.SourceChannel != nil { 40 + query = query.Where("source_channel = ?", *filter.SourceChannel) 41 + } 42 + return query 43 + } 44 + 45 + func (s *GormStore) GetRecentIRCLinks(ctx context.Context, startDays int, endDays int, filter SourceFilter) ([]IRCLink, error) { 33 46 var links []IRCLink 34 47 // timestamp >= NOW() - startDays AND timestamp <= NOW() - endDays 35 48 // Note: startDays is "further back" (larger number), endDays is "closer" (smaller number) ··· 37 50 startDate := now.AddDate(0, 0, -startDays) 38 51 endDate := now.AddDate(0, 0, -endDays) 39 52 40 - err := s.db.WithContext(ctx). 41 - Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 42 - Order("timestamp DESC"). 53 + query := s.db.WithContext(ctx). 54 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 55 + query = applySourceFilter(query, filter) 56 + err := query.Order("timestamp DESC"). 43 57 Find(&links).Error 44 58 return links, err 45 59 } 46 60 47 - func (s *GormStore) GetRecentImages(ctx context.Context, startDays int, endDays int) ([]Image, error) { 61 + func (s *GormStore) GetRecentImages(ctx context.Context, startDays int, endDays int, filter SourceFilter) ([]Image, error) { 48 62 var images []Image 49 63 now := time.Now() 50 64 startDate := now.AddDate(0, 0, -startDays) 51 65 endDate := now.AddDate(0, 0, -endDays) 52 66 53 - err := s.db.WithContext(ctx). 54 - Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 55 - Order("timestamp DESC"). 67 + query := s.db.WithContext(ctx). 68 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 69 + query = applySourceFilter(query, filter) 70 + err := query.Order("timestamp DESC"). 56 71 Find(&images).Error 57 72 return images, err 58 73 } 59 74 60 - func (s *GormStore) InsertImage(ctx context.Context, title, link, url string) (int, error) { 61 - img := Image{ 62 - Title: title, 63 - Link: link, 64 - URL: url, 65 - Timestamp: time.Now(), 66 - } 67 - err := s.db.WithContext(ctx).Create(&img).Error 68 - return img.ID, err 75 + func (s *GormStore) InsertImage(ctx context.Context, image *Image) (int, error) { 76 + image.Timestamp = time.Now() 77 + err := s.db.WithContext(ctx).Create(image).Error 78 + return image.ID, err 69 79 } 70 80 71 81 func (s *GormStore) GetTodayImageByLink(ctx context.Context, link string) (*Image, error) { ··· 96 106 Delete(&Image{}).Error 97 107 } 98 108 99 - func (s *GormStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int) ([]Quote, error) { 109 + func (s *GormStore) GetRecentQuotes(ctx context.Context, startDays int, endDays int, filter SourceFilter) ([]Quote, error) { 100 110 var quotes []Quote 101 111 now := time.Now() 102 112 startDate := now.AddDate(0, 0, -startDays) 103 113 endDate := now.AddDate(0, 0, -endDays) 104 114 105 - err := s.db.WithContext(ctx). 106 - Where("timestamp >= ? AND timestamp <= ?", startDate, endDate). 107 - Order("timestamp DESC"). 115 + query := s.db.WithContext(ctx). 116 + Where("timestamp >= ? AND timestamp <= ?", startDate, endDate) 117 + query = applySourceFilter(query, filter) 118 + err := query.Order("timestamp DESC"). 108 119 Find(&quotes).Error 109 120 return quotes, err 110 121 } 111 122 112 - func (s *GormStore) SearchIRCLinks(ctx context.Context, query string) ([]IRCLink, error) { 123 + func (s *GormStore) SearchIRCLinks(ctx context.Context, query string, filter SourceFilter) ([]IRCLink, error) { 113 124 var links []IRCLink 114 125 // Simple LIKE search for cross-db compatibility 115 126 term := "%" + query + "%" ··· 126 137 recentCutoff := time.Now().Add(-24 * time.Hour) 127 138 oldCutoff := time.Now().Add(-60 * 24 * time.Hour) 128 139 linkAgeCutoff := time.Now().Add(-10 * 24 * time.Hour) 129 - err := s.db.WithContext(ctx). 140 + q := s.db.WithContext(ctx). 130 141 Where(`(title LIKE ? OR url LIKE ? OR ircLinkID IN (SELECT resource_id FROM tags WHERE resource_type = 'link' AND tag LIKE ?)) 131 142 AND url NOT IN ( 132 143 SELECT lp.url FROM link_previews lp ··· 136 147 OR 137 148 (NOT EXISTS (SELECT 1 FROM ircLink il WHERE il.url = lp.url AND il.timestamp > ?) AND lp.updated_at > ?) 138 149 ) 139 - )`, term, term, term, linkAgeCutoff, recentCutoff, linkAgeCutoff, oldCutoff). 140 - Order("clicks DESC"). 150 + )`, term, term, term, linkAgeCutoff, recentCutoff, linkAgeCutoff, oldCutoff) 151 + q = applySourceFilter(q, filter) 152 + err := q.Order("clicks DESC"). 141 153 Limit(50). 142 154 Find(&links).Error 143 155 return links, err 144 156 } 145 157 146 - func (s *GormStore) SearchQuotes(ctx context.Context, query string) ([]Quote, error) { 158 + func (s *GormStore) SearchQuotes(ctx context.Context, query string, filter SourceFilter) ([]Quote, error) { 147 159 var quotes []Quote 148 160 // Simple LIKE search for cross-db compatibility 149 161 term := "%" + query + "%" 150 - err := s.db.WithContext(ctx). 151 - Where("quote LIKE ? OR author LIKE ? OR quoteID IN (SELECT resource_id FROM tags WHERE resource_type = 'quote' AND tag LIKE ?)", term, term, term). 152 - Order("timestamp DESC"). 162 + q := s.db.WithContext(ctx). 163 + Where("quote LIKE ? OR author LIKE ? OR quoteID IN (SELECT resource_id FROM tags WHERE resource_type = 'quote' AND tag LIKE ?)", term, term, term) 164 + q = applySourceFilter(q, filter) 165 + err := q.Order("timestamp DESC"). 153 166 Limit(50). 154 167 Find(&quotes).Error 155 168 return quotes, err ··· 188 201 return link.URL, err 189 202 } 190 203 191 - func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string) ([]IRCLink, error) { 204 + func (s *GormStore) GetIRCLinksByURL(ctx context.Context, url string, filter SourceFilter) ([]IRCLink, error) { 192 205 var links []IRCLink 193 - err := s.db.WithContext(ctx). 194 - Where("url = ?", url). 195 - Order("timestamp DESC"). 206 + query := s.db.WithContext(ctx). 207 + Where("url = ?", url) 208 + query = applySourceFilter(query, filter) 209 + err := query.Order("timestamp DESC"). 196 210 Find(&links).Error 197 211 return links, err 198 212 } ··· 201 215 return s.db.WithContext(ctx).Model(&IRCLink{}).Where("ircLinkID = ?", id).UpdateColumn("clicks", gorm.Expr("clicks + ?", 1)).Error 202 216 } 203 217 204 - func (s *GormStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 205 - link := IRCLink{ 206 - User: user, 207 - Title: title, 208 - URL: url, 209 - ContentType: contentType, 210 - Timestamp: time.Now(), 211 - Clicks: 0, 212 - } 213 - err := s.db.WithContext(ctx).Create(&link).Error 218 + func (s *GormStore) InsertIRCLink(ctx context.Context, link *IRCLink) (int, error) { 219 + link.Timestamp = time.Now() 220 + link.Clicks = 0 221 + err := s.db.WithContext(ctx).Create(link).Error 214 222 return link.ID, err 215 223 } 216 224 ··· 225 233 return nil 226 234 } 227 235 228 - func (s *GormStore) InsertQuote(ctx context.Context, quoteText, author, poster string) (int, error) { 229 - quote := Quote{ 230 - Quote: quoteText, 231 - Author: author, 232 - Poster: poster, 233 - Timestamp: time.Now(), 234 - } 235 - err := s.db.WithContext(ctx).Create(&quote).Error 236 + func (s *GormStore) InsertQuote(ctx context.Context, quote *Quote) (int, error) { 237 + quote.Timestamp = time.Now() 238 + err := s.db.WithContext(ctx).Create(quote).Error 236 239 return quote.ID, err 237 240 } 238 241
+1 -1
internal/data/hot_test.go
··· 80 80 } 81 81 82 82 for _, f := range fixtures { 83 - _, err := store.InsertIRCLink(ctx, "tester", f.Title, "http://example.com/"+f.Title, "text") 83 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "tester", Title: f.Title, URL: "http://example.com/" + f.Title, ContentType: "text"}) 84 84 if err != nil { 85 85 t.Fatalf("Failed to insert link: %v", err) 86 86 }
+3 -3
internal/data/image_test.go
··· 20 20 } 21 21 22 22 // Insert an image 23 - id, err := store.InsertImage(context.Background(), "Daily Kitten", "cat AAS", "https://cataas.com/cat/abc123") 23 + id, err := store.InsertImage(context.Background(), &Image{Title: "Daily Kitten", Link: "cat AAS", URL: "https://cataas.com/cat/abc123"}) 24 24 if err != nil { 25 25 t.Fatalf("InsertImage failed: %v", err) 26 26 } ··· 30 30 } 31 31 32 32 // Verify it's in the database via GetRecentImages 33 - images, err := store.GetRecentImages(context.Background(), 1, 0) 33 + images, err := store.GetRecentImages(context.Background(), 1, 0, SourceFilter{}) 34 34 if err != nil { 35 35 t.Fatalf("GetRecentImages failed: %v", err) 36 36 } ··· 72 72 } 73 73 74 74 // Insert an image 75 - _, err = store.InsertImage(context.Background(), "Daily Kitten", "cat AAS", "https://cataas.com/cat/abc123") 75 + _, err = store.InsertImage(context.Background(), &Image{Title: "Daily Kitten", Link: "cat AAS", URL: "https://cataas.com/cat/abc123"}) 76 76 if err != nil { 77 77 t.Fatalf("InsertImage failed: %v", err) 78 78 }
+29 -29
internal/data/search_mysql_test.go
··· 50 50 store := newMySQLTestStore(t) 51 51 ctx := context.Background() 52 52 53 - _, err := store.InsertIRCLink(ctx, "alice", "Golang Tutorial", "http://example.com/go-mysql", "text/html") 53 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Golang Tutorial", URL: "http://example.com/go-mysql", ContentType: "text/html"}) 54 54 if err != nil { 55 55 t.Fatalf("InsertIRCLink failed: %v", err) 56 56 } 57 - _, err = store.InsertIRCLink(ctx, "bob", "Rust Guide", "http://example.com/rust-mysql", "text/html") 57 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Rust Guide", URL: "http://example.com/rust-mysql", ContentType: "text/html"}) 58 58 if err != nil { 59 59 t.Fatalf("InsertIRCLink failed: %v", err) 60 60 } 61 61 62 - links, err := store.SearchIRCLinks(ctx, "Golang") 62 + links, err := store.SearchIRCLinks(ctx, "Golang", SourceFilter{}) 63 63 if err != nil { 64 64 t.Fatalf("SearchIRCLinks failed: %v", err) 65 65 } ··· 75 75 store := newMySQLTestStore(t) 76 76 ctx := context.Background() 77 77 78 - _, err := store.InsertIRCLink(ctx, "alice", "Some Page", "http://example.com/unique-mysql-path", "text/html") 78 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Some Page", URL: "http://example.com/unique-mysql-path", ContentType: "text/html"}) 79 79 if err != nil { 80 80 t.Fatalf("InsertIRCLink failed: %v", err) 81 81 } 82 82 83 - links, err := store.SearchIRCLinks(ctx, "unique-mysql-path") 83 + links, err := store.SearchIRCLinks(ctx, "unique-mysql-path", SourceFilter{}) 84 84 if err != nil { 85 85 t.Fatalf("SearchIRCLinks failed: %v", err) 86 86 } ··· 96 96 store := newMySQLTestStore(t) 97 97 ctx := context.Background() 98 98 99 - id, err := store.InsertIRCLink(ctx, "alice", "Tagged Link", "http://example.com/tagged-mysql", "text/html") 99 + id, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Tagged Link", URL: "http://example.com/tagged-mysql", ContentType: "text/html"}) 100 100 if err != nil { 101 101 t.Fatalf("InsertIRCLink failed: %v", err) 102 102 } ··· 111 111 t.Fatalf("CreateTag failed: %v", err) 112 112 } 113 113 114 - links, err := store.SearchIRCLinks(ctx, "special-mysql-topic") 114 + links, err := store.SearchIRCLinks(ctx, "special-mysql-topic", SourceFilter{}) 115 115 if err != nil { 116 116 t.Fatalf("SearchIRCLinks failed: %v", err) 117 117 } ··· 127 127 store := newMySQLTestStore(t) 128 128 ctx := context.Background() 129 129 130 - _, err := store.InsertIRCLink(ctx, "alice", "Something", "http://example.com/a-mysql", "text/html") 130 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Something", URL: "http://example.com/a-mysql", ContentType: "text/html"}) 131 131 if err != nil { 132 132 t.Fatalf("InsertIRCLink failed: %v", err) 133 133 } 134 134 135 - links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy-mysql") 135 + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy-mysql", SourceFilter{}) 136 136 if err != nil { 137 137 t.Fatalf("SearchIRCLinks failed: %v", err) 138 138 } ··· 146 146 ctx := context.Background() 147 147 db := store.db 148 148 149 - _, err := store.InsertIRCLink(ctx, "alice", "SearchM Low", "http://example.com/searchm-low", "text/html") 149 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "SearchM Low", URL: "http://example.com/searchm-low", ContentType: "text/html"}) 150 150 if err != nil { 151 151 t.Fatalf("InsertIRCLink failed: %v", err) 152 152 } 153 - _, err = store.InsertIRCLink(ctx, "bob", "SearchM High", "http://example.com/searchm-high", "text/html") 153 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "SearchM High", URL: "http://example.com/searchm-high", ContentType: "text/html"}) 154 154 if err != nil { 155 155 t.Fatalf("InsertIRCLink failed: %v", err) 156 156 } ··· 158 158 db.Model(&IRCLink{}).Where("title = ?", "SearchM Low").Update("clicks", 5) 159 159 db.Model(&IRCLink{}).Where("title = ?", "SearchM High").Update("clicks", 50) 160 160 161 - links, err := store.SearchIRCLinks(ctx, "SearchM") 161 + links, err := store.SearchIRCLinks(ctx, "SearchM", SourceFilter{}) 162 162 if err != nil { 163 163 t.Fatalf("SearchIRCLinks failed: %v", err) 164 164 } ··· 177 177 store := newMySQLTestStore(t) 178 178 ctx := context.Background() 179 179 180 - _, err := store.InsertIRCLink(ctx, "alice", "Good Link", "http://example.com/good-mysql", "text/html") 180 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Good Link", URL: "http://example.com/good-mysql", ContentType: "text/html"}) 181 181 if err != nil { 182 182 t.Fatalf("InsertIRCLink failed: %v", err) 183 183 } 184 - _, err = store.InsertIRCLink(ctx, "bob", "Bad Link", "http://example.com/bad-mysql", "text/html") 184 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Bad Link", URL: "http://example.com/bad-mysql", ContentType: "text/html"}) 185 185 if err != nil { 186 186 t.Fatalf("InsertIRCLink failed: %v", err) 187 187 } ··· 191 191 t.Fatalf("InsertLinkPreview failed: %v", err) 192 192 } 193 193 194 - links, err := store.SearchIRCLinks(ctx, "Link") 194 + links, err := store.SearchIRCLinks(ctx, "Link", SourceFilter{}) 195 195 if err != nil { 196 196 t.Fatalf("SearchIRCLinks failed: %v", err) 197 197 } ··· 208 208 ctx := context.Background() 209 209 db := store.db 210 210 211 - _, err := store.InsertIRCLink(ctx, "alice", "Recoverable Link", "http://example.com/recover-mysql", "text/html") 211 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Recoverable Link", URL: "http://example.com/recover-mysql", ContentType: "text/html"}) 212 212 if err != nil { 213 213 t.Fatalf("InsertIRCLink failed: %v", err) 214 214 } ··· 220 220 twoDaysAgo := time.Now().Add(-48 * time.Hour) 221 221 db.Model(&LinkPreview{}).Where("url = ?", "http://example.com/recover-mysql").Update("updated_at", twoDaysAgo) 222 222 223 - links, err := store.SearchIRCLinks(ctx, "Recoverable") 223 + links, err := store.SearchIRCLinks(ctx, "Recoverable", SourceFilter{}) 224 224 if err != nil { 225 225 t.Fatalf("SearchIRCLinks failed: %v", err) 226 226 } ··· 238 238 store := newMySQLTestStore(t) 239 239 ctx := context.Background() 240 240 241 - _, err := store.InsertQuote(ctx, "To be or not to be", "Shakespeare", "alice") 241 + _, err := store.InsertQuote(ctx, &Quote{Quote: "To be or not to be", Author: "Shakespeare", Poster: "alice"}) 242 242 if err != nil { 243 243 t.Fatalf("InsertQuote failed: %v", err) 244 244 } 245 - _, err = store.InsertQuote(ctx, "I think therefore I am", "Descartes", "bob") 245 + _, err = store.InsertQuote(ctx, &Quote{Quote: "I think therefore I am", Author: "Descartes", Poster: "bob"}) 246 246 if err != nil { 247 247 t.Fatalf("InsertQuote failed: %v", err) 248 248 } 249 249 250 - quotes, err := store.SearchQuotes(ctx, "not to be") 250 + quotes, err := store.SearchQuotes(ctx, "not to be", SourceFilter{}) 251 251 if err != nil { 252 252 t.Fatalf("SearchQuotes failed: %v", err) 253 253 } ··· 263 263 store := newMySQLTestStore(t) 264 264 ctx := context.Background() 265 265 266 - _, err := store.InsertQuote(ctx, "Some quote", "UniqueAuthor42", "alice") 266 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Some quote", Author: "UniqueAuthor42", Poster: "alice"}) 267 267 if err != nil { 268 268 t.Fatalf("InsertQuote failed: %v", err) 269 269 } 270 270 271 - quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42") 271 + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42", SourceFilter{}) 272 272 if err != nil { 273 273 t.Fatalf("SearchQuotes failed: %v", err) 274 274 } ··· 284 284 store := newMySQLTestStore(t) 285 285 ctx := context.Background() 286 286 287 - id, err := store.InsertQuote(ctx, "A tagged quote", "someone", "alice") 287 + id, err := store.InsertQuote(ctx, &Quote{Quote: "A tagged quote", Author: "someone", Poster: "alice"}) 288 288 if err != nil { 289 289 t.Fatalf("InsertQuote failed: %v", err) 290 290 } ··· 299 299 t.Fatalf("CreateTag failed: %v", err) 300 300 } 301 301 302 - quotes, err := store.SearchQuotes(ctx, "philosophy") 302 + quotes, err := store.SearchQuotes(ctx, "philosophy", SourceFilter{}) 303 303 if err != nil { 304 304 t.Fatalf("SearchQuotes failed: %v", err) 305 305 } ··· 315 315 store := newMySQLTestStore(t) 316 316 ctx := context.Background() 317 317 318 - _, err := store.InsertQuote(ctx, "Hello world", "author1", "poster1") 318 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Hello world", Author: "author1", Poster: "poster1"}) 319 319 if err != nil { 320 320 t.Fatalf("InsertQuote failed: %v", err) 321 321 } 322 322 323 - quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy-mysql") 323 + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy-mysql", SourceFilter{}) 324 324 if err != nil { 325 325 t.Fatalf("SearchQuotes failed: %v", err) 326 326 } ··· 334 334 ctx := context.Background() 335 335 db := store.db 336 336 337 - _, err := store.InsertQuote(ctx, "SearchM older quote", "auth1", "poster") 337 + _, err := store.InsertQuote(ctx, &Quote{Quote: "SearchM older quote", Author: "auth1", Poster: "poster"}) 338 338 if err != nil { 339 339 t.Fatalf("InsertQuote failed: %v", err) 340 340 } 341 - _, err = store.InsertQuote(ctx, "SearchM newer quote", "auth2", "poster") 341 + _, err = store.InsertQuote(ctx, &Quote{Quote: "SearchM newer quote", Author: "auth2", Poster: "poster"}) 342 342 if err != nil { 343 343 t.Fatalf("InsertQuote failed: %v", err) 344 344 } ··· 347 347 db.Model(&Quote{}).Where("quote = ?", "SearchM older quote").Update("timestamp", now.Add(-48*time.Hour)) 348 348 db.Model(&Quote{}).Where("quote = ?", "SearchM newer quote").Update("timestamp", now.Add(-1*time.Hour)) 349 349 350 - quotes, err := store.SearchQuotes(ctx, "SearchM") 350 + quotes, err := store.SearchQuotes(ctx, "SearchM", SourceFilter{}) 351 351 if err != nil { 352 352 t.Fatalf("SearchQuotes failed: %v", err) 353 353 }
+29 -29
internal/data/search_test.go
··· 12 12 store := newTestStore(t) 13 13 ctx := context.Background() 14 14 15 - _, err := store.InsertIRCLink(ctx, "alice", "Golang Tutorial", "http://example.com/go", "text/html") 15 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Golang Tutorial", URL: "http://example.com/go", ContentType: "text/html"}) 16 16 if err != nil { 17 17 t.Fatalf("InsertIRCLink failed: %v", err) 18 18 } 19 - _, err = store.InsertIRCLink(ctx, "bob", "Rust Guide", "http://example.com/rust", "text/html") 19 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Rust Guide", URL: "http://example.com/rust", ContentType: "text/html"}) 20 20 if err != nil { 21 21 t.Fatalf("InsertIRCLink failed: %v", err) 22 22 } 23 23 24 - links, err := store.SearchIRCLinks(ctx, "Golang") 24 + links, err := store.SearchIRCLinks(ctx, "Golang", SourceFilter{}) 25 25 if err != nil { 26 26 t.Fatalf("SearchIRCLinks failed: %v", err) 27 27 } ··· 37 37 store := newTestStore(t) 38 38 ctx := context.Background() 39 39 40 - _, err := store.InsertIRCLink(ctx, "alice", "Some Page", "http://example.com/unique-path", "text/html") 40 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Some Page", URL: "http://example.com/unique-path", ContentType: "text/html"}) 41 41 if err != nil { 42 42 t.Fatalf("InsertIRCLink failed: %v", err) 43 43 } 44 44 45 - links, err := store.SearchIRCLinks(ctx, "unique-path") 45 + links, err := store.SearchIRCLinks(ctx, "unique-path", SourceFilter{}) 46 46 if err != nil { 47 47 t.Fatalf("SearchIRCLinks failed: %v", err) 48 48 } ··· 58 58 store := newTestStore(t) 59 59 ctx := context.Background() 60 60 61 - id, err := store.InsertIRCLink(ctx, "alice", "Tagged Link", "http://example.com/tagged", "text/html") 61 + id, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Tagged Link", URL: "http://example.com/tagged", ContentType: "text/html"}) 62 62 if err != nil { 63 63 t.Fatalf("InsertIRCLink failed: %v", err) 64 64 } ··· 73 73 t.Fatalf("CreateTag failed: %v", err) 74 74 } 75 75 76 - links, err := store.SearchIRCLinks(ctx, "special-topic") 76 + links, err := store.SearchIRCLinks(ctx, "special-topic", SourceFilter{}) 77 77 if err != nil { 78 78 t.Fatalf("SearchIRCLinks failed: %v", err) 79 79 } ··· 89 89 store := newTestStore(t) 90 90 ctx := context.Background() 91 91 92 - _, err := store.InsertIRCLink(ctx, "alice", "Something", "http://example.com/a", "text/html") 92 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Something", URL: "http://example.com/a", ContentType: "text/html"}) 93 93 if err != nil { 94 94 t.Fatalf("InsertIRCLink failed: %v", err) 95 95 } 96 96 97 - links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy") 97 + links, err := store.SearchIRCLinks(ctx, "nonexistent-xyzzy", SourceFilter{}) 98 98 if err != nil { 99 99 t.Fatalf("SearchIRCLinks failed: %v", err) 100 100 } ··· 108 108 ctx := context.Background() 109 109 db := store.db 110 110 111 - _, err := store.InsertIRCLink(ctx, "alice", "Search Low", "http://example.com/search-low", "text/html") 111 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Search Low", URL: "http://example.com/search-low", ContentType: "text/html"}) 112 112 if err != nil { 113 113 t.Fatalf("InsertIRCLink failed: %v", err) 114 114 } 115 - _, err = store.InsertIRCLink(ctx, "bob", "Search High", "http://example.com/search-high", "text/html") 115 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Search High", URL: "http://example.com/search-high", ContentType: "text/html"}) 116 116 if err != nil { 117 117 t.Fatalf("InsertIRCLink failed: %v", err) 118 118 } ··· 121 121 db.Model(&IRCLink{}).Where("title = ?", "Search Low").Update("clicks", 5) 122 122 db.Model(&IRCLink{}).Where("title = ?", "Search High").Update("clicks", 50) 123 123 124 - links, err := store.SearchIRCLinks(ctx, "Search") 124 + links, err := store.SearchIRCLinks(ctx, "Search", SourceFilter{}) 125 125 if err != nil { 126 126 t.Fatalf("SearchIRCLinks failed: %v", err) 127 127 } ··· 141 141 ctx := context.Background() 142 142 143 143 // Insert two links — one will have an error preview, one won't 144 - _, err := store.InsertIRCLink(ctx, "alice", "Good Link", "http://example.com/good", "text/html") 144 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Good Link", URL: "http://example.com/good", ContentType: "text/html"}) 145 145 if err != nil { 146 146 t.Fatalf("InsertIRCLink failed: %v", err) 147 147 } 148 - _, err = store.InsertIRCLink(ctx, "bob", "Bad Link", "http://example.com/bad", "text/html") 148 + _, err = store.InsertIRCLink(ctx, &IRCLink{User: "bob", Title: "Bad Link", URL: "http://example.com/bad", ContentType: "text/html"}) 149 149 if err != nil { 150 150 t.Fatalf("InsertIRCLink failed: %v", err) 151 151 } ··· 156 156 t.Fatalf("InsertLinkPreview failed: %v", err) 157 157 } 158 158 159 - links, err := store.SearchIRCLinks(ctx, "Link") 159 + links, err := store.SearchIRCLinks(ctx, "Link", SourceFilter{}) 160 160 if err != nil { 161 161 t.Fatalf("SearchIRCLinks failed: %v", err) 162 162 } ··· 174 174 db := store.db 175 175 176 176 // Insert link that is "recent" (< 10 days old) — its error cache TTL is 24h 177 - _, err := store.InsertIRCLink(ctx, "alice", "Recoverable Link", "http://example.com/recover", "text/html") 177 + _, err := store.InsertIRCLink(ctx, &IRCLink{User: "alice", Title: "Recoverable Link", URL: "http://example.com/recover", ContentType: "text/html"}) 178 178 if err != nil { 179 179 t.Fatalf("InsertIRCLink failed: %v", err) 180 180 } ··· 188 188 twoDaysAgo := time.Now().Add(-48 * time.Hour) 189 189 db.Model(&LinkPreview{}).Where("url = ?", "http://example.com/recover").Update("updated_at", twoDaysAgo) 190 190 191 - links, err := store.SearchIRCLinks(ctx, "Recoverable") 191 + links, err := store.SearchIRCLinks(ctx, "Recoverable", SourceFilter{}) 192 192 if err != nil { 193 193 t.Fatalf("SearchIRCLinks failed: %v", err) 194 194 } ··· 206 206 store := newTestStore(t) 207 207 ctx := context.Background() 208 208 209 - _, err := store.InsertQuote(ctx, "To be or not to be", "Shakespeare", "alice") 209 + _, err := store.InsertQuote(ctx, &Quote{Quote: "To be or not to be", Author: "Shakespeare", Poster: "alice"}) 210 210 if err != nil { 211 211 t.Fatalf("InsertQuote failed: %v", err) 212 212 } 213 - _, err = store.InsertQuote(ctx, "I think therefore I am", "Descartes", "bob") 213 + _, err = store.InsertQuote(ctx, &Quote{Quote: "I think therefore I am", Author: "Descartes", Poster: "bob"}) 214 214 if err != nil { 215 215 t.Fatalf("InsertQuote failed: %v", err) 216 216 } 217 217 218 - quotes, err := store.SearchQuotes(ctx, "not to be") 218 + quotes, err := store.SearchQuotes(ctx, "not to be", SourceFilter{}) 219 219 if err != nil { 220 220 t.Fatalf("SearchQuotes failed: %v", err) 221 221 } ··· 231 231 store := newTestStore(t) 232 232 ctx := context.Background() 233 233 234 - _, err := store.InsertQuote(ctx, "Some quote", "UniqueAuthor42", "alice") 234 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Some quote", Author: "UniqueAuthor42", Poster: "alice"}) 235 235 if err != nil { 236 236 t.Fatalf("InsertQuote failed: %v", err) 237 237 } 238 238 239 - quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42") 239 + quotes, err := store.SearchQuotes(ctx, "UniqueAuthor42", SourceFilter{}) 240 240 if err != nil { 241 241 t.Fatalf("SearchQuotes failed: %v", err) 242 242 } ··· 252 252 store := newTestStore(t) 253 253 ctx := context.Background() 254 254 255 - id, err := store.InsertQuote(ctx, "A tagged quote", "someone", "alice") 255 + id, err := store.InsertQuote(ctx, &Quote{Quote: "A tagged quote", Author: "someone", Poster: "alice"}) 256 256 if err != nil { 257 257 t.Fatalf("InsertQuote failed: %v", err) 258 258 } ··· 267 267 t.Fatalf("CreateTag failed: %v", err) 268 268 } 269 269 270 - quotes, err := store.SearchQuotes(ctx, "philosophy") 270 + quotes, err := store.SearchQuotes(ctx, "philosophy", SourceFilter{}) 271 271 if err != nil { 272 272 t.Fatalf("SearchQuotes failed: %v", err) 273 273 } ··· 283 283 store := newTestStore(t) 284 284 ctx := context.Background() 285 285 286 - _, err := store.InsertQuote(ctx, "Hello world", "author1", "poster1") 286 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Hello world", Author: "author1", Poster: "poster1"}) 287 287 if err != nil { 288 288 t.Fatalf("InsertQuote failed: %v", err) 289 289 } 290 290 291 - quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy") 291 + quotes, err := store.SearchQuotes(ctx, "nonexistent-xyzzy", SourceFilter{}) 292 292 if err != nil { 293 293 t.Fatalf("SearchQuotes failed: %v", err) 294 294 } ··· 302 302 ctx := context.Background() 303 303 db := store.db 304 304 305 - _, err := store.InsertQuote(ctx, "Search older quote", "auth1", "poster") 305 + _, err := store.InsertQuote(ctx, &Quote{Quote: "Search older quote", Author: "auth1", Poster: "poster"}) 306 306 if err != nil { 307 307 t.Fatalf("InsertQuote failed: %v", err) 308 308 } 309 - _, err = store.InsertQuote(ctx, "Search newer quote", "auth2", "poster") 309 + _, err = store.InsertQuote(ctx, &Quote{Quote: "Search newer quote", Author: "auth2", Poster: "poster"}) 310 310 if err != nil { 311 311 t.Fatalf("InsertQuote failed: %v", err) 312 312 } ··· 316 316 db.Model(&Quote{}).Where("quote = ?", "Search older quote").Update("timestamp", now.Add(-48*time.Hour)) 317 317 db.Model(&Quote{}).Where("quote = ?", "Search newer quote").Update("timestamp", now.Add(-1*time.Hour)) 318 318 319 - quotes, err := store.SearchQuotes(ctx, "Search") 319 + quotes, err := store.SearchQuotes(ctx, "Search", SourceFilter{}) 320 320 if err != nil { 321 321 t.Fatalf("SearchQuotes failed: %v", err) 322 322 }
+55
internal/data/source_filter_test.go
··· 1 + package data 2 + 3 + import "testing" 4 + 5 + func TestSourceFilter_IsEmpty(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + filter SourceFilter 9 + expected bool 10 + }{ 11 + { 12 + name: "all nil fields", 13 + filter: SourceFilter{}, 14 + expected: true, 15 + }, 16 + { 17 + name: "only SourceType set", 18 + filter: SourceFilter{SourceType: strPtr("irc")}, 19 + expected: false, 20 + }, 21 + { 22 + name: "only SourceNetwork set", 23 + filter: SourceFilter{SourceNetwork: strPtr("libera")}, 24 + expected: false, 25 + }, 26 + { 27 + name: "only SourceChannel set", 28 + filter: SourceFilter{SourceChannel: strPtr("#general")}, 29 + expected: false, 30 + }, 31 + { 32 + name: "all fields set", 33 + filter: SourceFilter{SourceType: strPtr("irc"), SourceNetwork: strPtr("libera"), SourceChannel: strPtr("#general")}, 34 + expected: false, 35 + }, 36 + { 37 + name: "two fields set", 38 + filter: SourceFilter{SourceType: strPtr("slack"), SourceNetwork: strPtr("workspace1")}, 39 + expected: false, 40 + }, 41 + } 42 + 43 + for _, tt := range tests { 44 + t.Run(tt.name, func(t *testing.T) { 45 + got := tt.filter.IsEmpty() 46 + if got != tt.expected { 47 + t.Errorf("SourceFilter.IsEmpty() = %v, want %v", got, tt.expected) 48 + } 49 + }) 50 + } 51 + } 52 + 53 + func strPtr(s string) *string { 54 + return &s 55 + }
+18 -18
internal/data/store.go
··· 13 13 URL string `json:"url" gorm:"column:url"` 14 14 Clicks int `json:"clicks" gorm:"column:clicks;default:0"` 15 15 ContentType string `json:"content_type" gorm:"column:content_type"` 16 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_source,priority:1"` 17 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_source,priority:2"` 18 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_source,priority:3"` 16 + SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_link_source,priority:1"` 17 + SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_link_source,priority:2"` 18 + SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_link_source,priority:3"` 19 19 SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 20 20 SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 21 21 } ··· 32 32 Link string `json:"link" gorm:"column:link"` 33 33 URL string `json:"url" gorm:"column:url"` 34 34 MD5Sum string `json:"md5sum" gorm:"column:md5sum"` 35 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_source,priority:1"` 36 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_source,priority:2"` 37 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_source,priority:3"` 35 + SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_image_source,priority:1"` 36 + SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_image_source,priority:2"` 37 + SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_image_source,priority:3"` 38 38 SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 39 39 SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 40 40 } ··· 50 50 Quote string `json:"quote" gorm:"column:quote"` 51 51 Author string `json:"author" gorm:"column:author;type:varchar(255);index"` 52 52 Poster string `json:"poster,omitempty" gorm:"column:poster;type:varchar(255);index"` 53 - SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_source,priority:1"` 54 - SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_source,priority:2"` 55 - SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_source,priority:3"` 53 + SourceType *string `json:"source_type,omitempty" gorm:"column:source_type;type:varchar(50);index:idx_quote_source,priority:1"` 54 + SourceNetwork *string `json:"source_network,omitempty" gorm:"column:source_network;type:varchar(255);index:idx_quote_source,priority:2"` 55 + SourceChannel *string `json:"source_channel,omitempty" gorm:"column:source_channel;type:varchar(255);index:idx_quote_source,priority:3"` 56 56 SourceUserID *string `json:"source_user_id,omitempty" gorm:"column:source_user_id;type:varchar(255)"` 57 57 SourceUserName *string `json:"source_user_name,omitempty" gorm:"column:source_user_name;type:varchar(255)"` 58 58 } ··· 134 134 } 135 135 136 136 type Store interface { 137 - GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]IRCLink, error) 138 - GetRecentImages(ctx context.Context, days int, offsetDays int) ([]Image, error) 139 - GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]Quote, error) 137 + GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]IRCLink, error) 138 + GetRecentImages(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]Image, error) 139 + GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter SourceFilter) ([]Quote, error) 140 140 141 - SearchIRCLinks(ctx context.Context, query string) ([]IRCLink, error) 142 - SearchQuotes(ctx context.Context, query string) ([]Quote, error) 141 + SearchIRCLinks(ctx context.Context, query string, filter SourceFilter) ([]IRCLink, error) 142 + SearchQuotes(ctx context.Context, query string, filter SourceFilter) ([]Quote, error) 143 143 GetTopIRCLinks(ctx context.Context, startDays int, endDays int, limit int) ([]IRCLink, error) 144 144 GetIRCLinkByID(ctx context.Context, id int) (*IRCLink, error) 145 145 GetIRCLinkURL(ctx context.Context, id int) (string, error) 146 - GetIRCLinksByURL(ctx context.Context, url string) ([]IRCLink, error) 146 + GetIRCLinksByURL(ctx context.Context, url string, filter SourceFilter) ([]IRCLink, error) 147 147 IncrementClicks(ctx context.Context, id int) error 148 - InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) 148 + InsertIRCLink(ctx context.Context, link *IRCLink) (int, error) 149 149 DeleteIRCLink(ctx context.Context, id int) error 150 - InsertQuote(ctx context.Context, quote, author, poster string) (int, error) 150 + InsertQuote(ctx context.Context, quote *Quote) (int, error) 151 151 GetRandomQuote(ctx context.Context) (*Quote, error) 152 152 GetQuoteByID(ctx context.Context, id int) (*Quote, error) 153 153 DeleteQuote(ctx context.Context, id int) error ··· 179 179 DeleteTagsByResource(ctx context.Context, resourceType string, resourceID int) error 180 180 181 181 // Image operations 182 - InsertImage(ctx context.Context, title, link, url string) (int, error) 182 + InsertImage(ctx context.Context, image *Image) (int, error) 183 183 GetTodayImageByLink(ctx context.Context, link string) (*Image, error) 184 184 DeleteTodayImageByLink(ctx context.Context, link string) error 185 185
+7 -7
internal/handler/api_v1_integration_test.go
··· 38 38 err error 39 39 } 40 40 41 - func (m *integrationMockStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]data.IRCLink, error) { 41 + func (m *integrationMockStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.IRCLink, error) { 42 42 if m.err != nil { 43 43 return nil, m.err 44 44 } ··· 58 58 return m.linkByID, nil 59 59 } 60 60 61 - func (m *integrationMockStore) GetIRCLinksByURL(ctx context.Context, url string) ([]data.IRCLink, error) { 61 + func (m *integrationMockStore) GetIRCLinksByURL(ctx context.Context, url string, filter data.SourceFilter) ([]data.IRCLink, error) { 62 62 if m.err != nil { 63 63 return nil, m.err 64 64 } 65 65 return nil, nil // No duplicates by default 66 66 } 67 67 68 - func (m *integrationMockStore) InsertIRCLink(ctx context.Context, user, title, url, contentType string) (int, error) { 68 + func (m *integrationMockStore) InsertIRCLink(ctx context.Context, link *data.IRCLink) (int, error) { 69 69 if m.err != nil { 70 70 return 0, m.err 71 71 } ··· 76 76 return m.err 77 77 } 78 78 79 - func (m *integrationMockStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 79 + func (m *integrationMockStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.Quote, error) { 80 80 if m.err != nil { 81 81 return nil, m.err 82 82 } ··· 96 96 return m.quoteByID, nil 97 97 } 98 98 99 - func (m *integrationMockStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 99 + func (m *integrationMockStore) InsertQuote(ctx context.Context, quote *data.Quote) (int, error) { 100 100 if m.err != nil { 101 101 return 0, m.err 102 102 } ··· 114 114 return m.userStats, nil 115 115 } 116 116 117 - func (m *integrationMockStore) SearchIRCLinks(ctx context.Context, query string) ([]data.IRCLink, error) { 117 + func (m *integrationMockStore) SearchIRCLinks(ctx context.Context, query string, filter data.SourceFilter) ([]data.IRCLink, error) { 118 118 if m.err != nil { 119 119 return nil, m.err 120 120 } 121 121 return m.searchLinks, nil 122 122 } 123 123 124 - func (m *integrationMockStore) SearchQuotes(ctx context.Context, query string) ([]data.Quote, error) { 124 + func (m *integrationMockStore) SearchQuotes(ctx context.Context, query string, filter data.SourceFilter) ([]data.Quote, error) { 125 125 if m.err != nil { 126 126 return nil, m.err 127 127 }
+4 -4
internal/handler/api_v1_kittens_test.go
··· 17 17 type mockKittenStore struct { 18 18 data.Store 19 19 getTodayImageByLinkFn func(ctx context.Context, link string) (*data.Image, error) 20 - insertImageFn func(ctx context.Context, title, link, url string) (int, error) 20 + insertImageFn func(ctx context.Context, image *data.Image) (int, error) 21 21 deleteTodayImageByLinkFn func(ctx context.Context, link string) error 22 22 } 23 23 ··· 28 28 return nil, nil 29 29 } 30 30 31 - func (m *mockKittenStore) InsertImage(ctx context.Context, title, link, url string) (int, error) { 31 + func (m *mockKittenStore) InsertImage(ctx context.Context, image *data.Image) (int, error) { 32 32 if m.insertImageFn != nil { 33 - return m.insertImageFn(ctx, title, link, url) 33 + return m.insertImageFn(ctx, image) 34 34 } 35 35 return 1, nil 36 36 } ··· 158 158 apiKey string 159 159 adminSecret string 160 160 getTodayFn func(ctx context.Context, link string) (*data.Image, error) 161 - insertImageFn func(ctx context.Context, title, link, url string) (int, error) 161 + insertImageFn func(ctx context.Context, image *data.Image) (int, error) 162 162 expectedStatus int 163 163 checkBody func(t *testing.T, body []byte) 164 164 }{
+5 -3
internal/handler/api_v1_links.go
··· 7 7 "strconv" 8 8 "strings" 9 9 "time" 10 + 11 + "tumble/internal/data" 10 12 ) 11 13 12 14 // APIv1LinksHandler routes requests to /api/v1/links endpoints. ··· 70 72 71 73 // Fetch all links from the last year 72 74 // We fetch more than needed so we can paginate in-memory 73 - links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0) 75 + links, err := h.Store.GetRecentIRCLinks(ctx, 365, 0, data.SourceFilter{}) 74 76 if err != nil { 75 77 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch links") 76 78 return ··· 151 153 } 152 154 153 155 // Check for duplicates 154 - existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL) 156 + existingLinks, err := h.Store.GetIRCLinksByURL(ctx, req.URL, data.SourceFilter{}) 155 157 if err != nil { 156 158 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to check for duplicates") 157 159 return 158 160 } 159 161 160 162 // Insert the link (use URL as title for now; existing code fetches title async) 161 - linkID, err := h.Store.InsertIRCLink(ctx, req.User, req.URL, req.URL, "") 163 + linkID, err := h.Store.InsertIRCLink(ctx, &data.IRCLink{User: req.User, Title: req.URL, URL: req.URL, ContentType: ""}) 162 164 if err != nil { 163 165 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create link") 164 166 return
+4 -2
internal/handler/api_v1_quotes.go
··· 7 7 "strconv" 8 8 "strings" 9 9 "time" 10 + 11 + "tumble/internal/data" 10 12 ) 11 13 12 14 // APIv1QuotesHandler routes requests to /api/v1/quotes endpoints. ··· 70 72 71 73 // Fetch all quotes from the last year 72 74 // We fetch more than needed so we can paginate in-memory 73 - quotes, err := h.Store.GetRecentQuotes(ctx, 365, 0) 75 + quotes, err := h.Store.GetRecentQuotes(ctx, 365, 0, data.SourceFilter{}) 74 76 if err != nil { 75 77 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 76 78 return ··· 146 148 } 147 149 148 150 // Insert the quote 149 - quoteID, err := h.Store.InsertQuote(ctx, req.Quote, req.Author, req.Poster) 151 + quoteID, err := h.Store.InsertQuote(ctx, &data.Quote{Quote: req.Quote, Author: req.Author, Poster: req.Poster}) 150 152 if err != nil { 151 153 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to create quote") 152 154 return
+5 -5
internal/handler/api_v1_quotes_test.go
··· 20 20 quoteByID *data.Quote 21 21 quoteByIDFn func(id int) (*data.Quote, error) 22 22 insertedQuoteID int 23 - insertQuoteFn func(quote, author, poster string) (int, error) 23 + insertQuoteFn func(quote *data.Quote) (int, error) 24 24 deleteQuoteFn func(id int) error 25 25 err error 26 26 } 27 27 28 - func (m *mockQuoteStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 28 + func (m *mockQuoteStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.Quote, error) { 29 29 if m.err != nil { 30 30 return nil, m.err 31 31 } ··· 42 42 return m.quoteByID, nil 43 43 } 44 44 45 - func (m *mockQuoteStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 45 + func (m *mockQuoteStore) InsertQuote(ctx context.Context, quote *data.Quote) (int, error) { 46 46 if m.insertQuoteFn != nil { 47 - return m.insertQuoteFn(quote, author, poster) 47 + return m.insertQuoteFn(quote) 48 48 } 49 49 if m.err != nil { 50 50 return 0, m.err ··· 759 759 760 760 func TestAPIv1_CreateQuote_StoreError(t *testing.T) { 761 761 store := &mockQuoteStore{ 762 - insertQuoteFn: func(quote, author, poster string) (int, error) { 762 + insertQuoteFn: func(quote *data.Quote) (int, error) { 763 763 return 0, context.DeadlineExceeded 764 764 }, 765 765 }
+4 -2
internal/handler/api_v1_search.go
··· 3 3 import ( 4 4 "net/http" 5 5 "strings" 6 + 7 + "tumble/internal/data" 6 8 ) 7 9 8 10 // APIv1SearchHandler handles GET /api/v1/search ··· 78 80 79 81 // Search links if requested 80 82 if searchLinks { 81 - links, err := h.Store.SearchIRCLinks(ctx, query) 83 + links, err := h.Store.SearchIRCLinks(ctx, query, data.SourceFilter{}) 82 84 if err != nil { 83 85 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to search links") 84 86 return ··· 114 116 115 117 // Search quotes if requested 116 118 if searchQuotes { 117 - quotes, err := h.Store.SearchQuotes(ctx, query) 119 + quotes, err := h.Store.SearchQuotes(ctx, query, data.SourceFilter{}) 118 120 if err != nil { 119 121 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to search quotes") 120 122 return
+2 -2
internal/handler/api_v1_search_test.go
··· 22 22 err error 23 23 } 24 24 25 - func (m *mockSearchStore) SearchIRCLinks(ctx context.Context, query string) ([]data.IRCLink, error) { 25 + func (m *mockSearchStore) SearchIRCLinks(ctx context.Context, query string, filter data.SourceFilter) ([]data.IRCLink, error) { 26 26 if m.searchLinksFn != nil { 27 27 return m.searchLinksFn(query) 28 28 } ··· 32 32 return m.links, nil 33 33 } 34 34 35 - func (m *mockSearchStore) SearchQuotes(ctx context.Context, query string) ([]data.Quote, error) { 35 + func (m *mockSearchStore) SearchQuotes(ctx context.Context, query string, filter data.SourceFilter) ([]data.Quote, error) { 36 36 if m.searchQuotesFn != nil { 37 37 return m.searchQuotesFn(query) 38 38 }
+4 -2
internal/handler/api_v1_stats.go
··· 4 4 "fmt" 5 5 "net/http" 6 6 "strings" 7 + 8 + "tumble/internal/data" 7 9 ) 8 10 9 11 // APIv1StatsHandler routes requests to /api/v1/stats endpoint. ··· 60 62 } 61 63 62 64 // Get total links and quotes for site stats 63 - links, err := h.Store.GetRecentIRCLinks(ctx, 36500, 0) // ~100 years to get all 65 + links, err := h.Store.GetRecentIRCLinks(ctx, 36500, 0, data.SourceFilter{}) // ~100 years to get all 64 66 if err != nil { 65 67 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch links") 66 68 return 67 69 } 68 70 69 - quotes, err := h.Store.GetRecentQuotes(ctx, 36500, 0) // ~100 years to get all 71 + quotes, err := h.Store.GetRecentQuotes(ctx, 36500, 0, data.SourceFilter{}) // ~100 years to get all 70 72 if err != nil { 71 73 writeAPIError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch quotes") 72 74 return
+2 -2
internal/handler/api_v1_stats_test.go
··· 31 31 return m.userStats, nil 32 32 } 33 33 34 - func (m *mockStatsStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int) ([]data.IRCLink, error) { 34 + func (m *mockStatsStore) GetRecentIRCLinks(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.IRCLink, error) { 35 35 if m.err != nil { 36 36 return nil, m.err 37 37 } 38 38 return m.links, nil 39 39 } 40 40 41 - func (m *mockStatsStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int) ([]data.Quote, error) { 41 + func (m *mockStatsStore) GetRecentQuotes(ctx context.Context, days int, offsetDays int, filter data.SourceFilter) ([]data.Quote, error) { 42 42 if m.err != nil { 43 43 return nil, m.err 44 44 }
+13 -4
internal/handler/handlers.go
··· 258 258 } else { 259 259 // Standard View 260 260 wg.Add(3) 261 - go func() { defer wg.Done(); ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays) }() 262 - go func() { defer wg.Done(); images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays) }() 263 - go func() { defer wg.Done(); quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays) }() 261 + go func() { 262 + defer wg.Done() 263 + ircLinks, errIrc = h.Store.GetRecentIRCLinks(ctx, startDays, endDays, data.SourceFilter{}) 264 + }() 265 + go func() { 266 + defer wg.Done() 267 + images, errImg = h.Store.GetRecentImages(ctx, startDays, endDays, data.SourceFilter{}) 268 + }() 269 + go func() { 270 + defer wg.Done() 271 + quotes, errQuote = h.Store.GetRecentQuotes(ctx, startDays, endDays, data.SourceFilter{}) 272 + }() 264 273 wg.Wait() 265 274 } 266 275 ··· 538 547 return 539 548 } 540 549 541 - links, err := h.Store.SearchIRCLinks(ctx, query) 550 + links, err := h.Store.SearchIRCLinks(ctx, query, data.SourceFilter{}) 542 551 if err != nil { 543 552 h.ServerError(w, r, err) 544 553 return
+4 -2
internal/handler/irclink.go
··· 14 14 "strings" 15 15 "time" 16 16 17 + "tumble/internal/data" 18 + 17 19 "github.com/doyensec/safeurl" 18 20 ) 19 21 ··· 95 97 96 98 // Handle link posting 97 99 // Check for existing submissions first 98 - existingLinks, err := h.Store.GetIRCLinksByURL(ctx, url) 100 + existingLinks, err := h.Store.GetIRCLinksByURL(ctx, url, data.SourceFilter{}) 99 101 if err != nil { 100 102 h.ServerError(w, r, err) 101 103 return ··· 137 139 } 138 140 139 141 // Insert the link (always insert, even if duplicate) 140 - id, err := h.Store.InsertIRCLink(ctx, user, title, url, contentType) 142 + id, err := h.Store.InsertIRCLink(ctx, &data.IRCLink{User: user, Title: title, URL: url, ContentType: contentType}) 141 143 if err != nil { 142 144 h.ServerError(w, r, err) 143 145 return
+7 -7
internal/handler/preview.go
··· 44 44 45 45 // OEmbedResponse represents standard OEmbed keys 46 46 type OEmbedResponse struct { 47 - Type string `json:"type"` 47 + Type string `json:"type"` 48 48 Version interface{} `json:"version"` 49 - Title string `json:"title"` 50 - AuthorName string `json:"author_name"` 51 - AuthorURL string `json:"author_url"` 52 - ProviderName string `json:"provider_name"` 53 - ProviderURL string `json:"provider_url"` 49 + Title string `json:"title"` 50 + AuthorName string `json:"author_name"` 51 + AuthorURL string `json:"author_url"` 52 + ProviderName string `json:"provider_name"` 53 + ProviderURL string `json:"provider_url"` 54 54 55 55 // CacheAge int64 `json:"cache_age"` 56 56 ThumbnailURL string `json:"thumbnail_url"` ··· 97 97 // For error entries, look up the link's timestamp to determine TTL tier 98 98 var linkTimestamp time.Time 99 99 if strings.Contains(string(cached.Data), `"error":`) { 100 - if links, err := h.Store.GetIRCLinksByURL(r.Context(), urlParam); err == nil && len(links) > 0 { 100 + if links, err := h.Store.GetIRCLinksByURL(r.Context(), urlParam, data.SourceFilter{}); err == nil && len(links) > 0 { 101 101 linkTimestamp = links[len(links)-1].Timestamp // oldest link (results ordered DESC) 102 102 } 103 103 }
+3 -1
internal/handler/quote.go
··· 8 8 "net/http" 9 9 "strconv" 10 10 "strings" 11 + 12 + "tumble/internal/data" 11 13 ) 12 14 13 15 // QuoteHandler handles /quote/ submissions and /quote/{id} permalinks ··· 115 117 } 116 118 117 119 // Quote provided -> Insert Quote (author is optional) 118 - id, err := h.Store.InsertQuote(ctx, quote, author, poster) 120 + id, err := h.Store.InsertQuote(ctx, &data.Quote{Quote: quote, Author: author, Poster: poster}) 119 121 if err != nil { 120 122 http.Error(w, "Database Error", http.StatusInternalServerError) 121 123 return
+4 -4
internal/handler/quote_test.go
··· 20 20 NextID int 21 21 } 22 22 23 - func (m *MockStore) InsertQuote(ctx context.Context, quote, author, poster string) (int, error) { 24 - m.LastQuote = quote 25 - m.LastAuthor = author 26 - m.LastPoster = poster 23 + func (m *MockStore) InsertQuote(ctx context.Context, quote *data.Quote) (int, error) { 24 + m.LastQuote = quote.Quote 25 + m.LastAuthor = quote.Author 26 + m.LastPoster = quote.Poster 27 27 if m.NextID == 0 { 28 28 m.NextID = 1 29 29 }
+2 -2
internal/scheduler/dailycat.go
··· 77 77 } 78 78 79 79 // Store the image 80 - id, err := store.InsertImage(ctx, kittenTitle, catAASUser, catURL) 80 + id, err := store.InsertImage(ctx, &data.Image{Title: kittenTitle, Link: catAASUser, URL: catURL}) 81 81 if err != nil { 82 82 return false, fmt.Errorf("failed to insert cat image: %w", err) 83 83 } ··· 101 101 } 102 102 103 103 // Store the image 104 - id, err := store.InsertImage(ctx, kittenTitle, catAASUser, catURL) 104 + id, err := store.InsertImage(ctx, &data.Image{Title: kittenTitle, Link: catAASUser, URL: catURL}) 105 105 if err != nil { 106 106 return "", fmt.Errorf("failed to insert cat image: %w", err) 107 107 }