Skip to main content

max / balanced_breakfast

UX audit fixes: unread filter, mark-all-read, discoverability, learnability Separate unread toggle, mark-all-read, source gear always visible, renamed query feeds, OPML import link, keyboard shortcut tooltips, recommended badge in plugin picker, auto-refresh after first feed, sync encryption clarity, renamed Reading List to Saved Articles, and fuzz fixes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-07 03:26 UTC
Commit: f4684ca65913669078fbd0f677a26ee777c7602b
Parent: de079cd
23 files changed, +422 insertions, -276 deletions
M CONTRIBUTING.md +1 -14
@@ -79,20 +79,7 @@ Config field types: `Text`, `TextArea`, `Secret`, `Url`, `Number`, `Toggle`, `Se
79 79
80 80 ### Sandbox Limits
81 81
82 - Each plugin runs in an isolated Rhai engine with strict limits:
83 -
84 - | Limit | Value | Purpose |
85 - |-------|-------|---------|
86 - | Max operations | 100,000 | Catches infinite loops |
87 - | Max expression depth | 128 | Prevents stack overflow |
88 - | Max recursion | 32 | Limits call depth |
89 - | HTTP timeout | 15s per request | Prevents hanging |
90 - | Response size | 2 MB per response | Prevents memory exhaustion |
91 - | Max requests | 100 per fetch | Catches runaway fetchers |
92 - | Aggregate timeout | 60s per fetch | Hard ceiling on total fetch time |
93 - | URL restrictions | Block localhost, private IPs | Prevents SSRF |
94 -
95 - Each plugin gets its own engine instance with isolated counters (request count via `Arc<AtomicUsize>`, deadline via `Arc<AtomicU64>`).
82 + Each plugin runs in an isolated Rhai engine with strict limits (operations, recursion, HTTP timeout/count, response size, SSRF blocking). Full limit table and isolation details in `docs/architecture.md` § Sandboxing.
96 83
97 84 ### Plugin Style
98 85
M Cargo.lock +30 -1
@@ -3693,6 +3693,16 @@ dependencies = [
3693 3693 ]
3694 3694
3695 3695 [[package]]
3696 + name = "rand"
3697 + version = "0.9.4"
3698 + source = "registry+https://github.com/rust-lang/crates.io-index"
3699 + checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
3700 + dependencies = [
3701 + "rand_chacha 0.9.0",
3702 + "rand_core 0.9.5",
3703 + ]
3704 +
3705 + [[package]]
3696 3706 name = "rand_chacha"
3697 3707 version = "0.2.2"
3698 3708 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3713,6 +3723,16 @@ dependencies = [
3713 3723 ]
3714 3724
3715 3725 [[package]]
3726 + name = "rand_chacha"
3727 + version = "0.9.0"
3728 + source = "registry+https://github.com/rust-lang/crates.io-index"
3729 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
3730 + dependencies = [
3731 + "ppv-lite86",
3732 + "rand_core 0.9.5",
3733 + ]
3734 +
3735 + [[package]]
3716 3736 name = "rand_core"
3717 3737 version = "0.5.1"
3718 3738 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3731,6 +3751,15 @@ dependencies = [
3731 3751 ]
3732 3752
3733 3753 [[package]]
3754 + name = "rand_core"
3755 + version = "0.9.5"
3756 + source = "registry+https://github.com/rust-lang/crates.io-index"
3757 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
3758 + dependencies = [
3759 + "getrandom 0.3.4",
3760 + ]
3761 +
3762 + [[package]]
3734 3763 name = "rand_hc"
3735 3764 version = "0.2.0"
3736 3765 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5052,7 +5081,7 @@ dependencies = [
5052 5081 "chrono",
5053 5082 "keyring",
5054 5083 "parking_lot",
5055 - "rand 0.8.5",
5084 + "rand 0.9.4",
5056 5085 "reqwest 0.12.28",
5057 5086 "serde",
5058 5087 "serde_json",
M Cargo.toml +1 -1
@@ -11,7 +11,7 @@ default-members = ["src-tauri"]
11 11
12 12 [workspace.package]
13 13 version = "0.3.1"
14 - edition = "2021"
14 + edition = "2024"
15 15 authors = ["BalancedBreakfast Contributors"]
16 16 license-file = "LICENSE"
17 17
@@ -653,6 +653,32 @@ impl ItemsRepository {
653 653 Ok(())
654 654 }
655 655
656 + /// Mark all unread items as read, optionally filtered to a specific source.
657 + #[tracing::instrument(skip_all)]
658 + pub async fn mark_all_read(&self, busser_id: Option<&str>) -> Result<u64, sqlx::Error> {
659 + let now = Utc::now().format(TIMESTAMP_FMT).to_string();
660 + let result = match busser_id {
661 + Some(id) => {
662 + sqlx::query(
663 + "UPDATE feed_items SET is_read = 1, updated_at = ?1 WHERE is_read = 0 AND busser_id = ?2",
664 + )
665 + .bind(&now)
666 + .bind(id)
667 + .execute(&self.pool)
668 + .await?
669 + }
670 + None => {
671 + sqlx::query(
672 + "UPDATE feed_items SET is_read = 1, updated_at = ?1 WHERE is_read = 0",
673 + )
674 + .bind(&now)
675 + .execute(&self.pool)
676 + .await?
677 + }
678 + };
679 + Ok(result.rows_affected())
680 + }
681 +
656 682 /// Count all feed items.
657 683 #[tracing::instrument(skip_all)]
658 684 pub async fn count_all(&self) -> Result<i64, sqlx::Error> {
@@ -43,8 +43,8 @@ mod tests {
43 43 let feed = seed_feed(&db, "rss", "Feed").await;
44 44 seed_item(&db, &feed, "rss:mr", 0).await;
45 45
46 - let gen = FeedGenerator::new(db.clone());
47 - gen.mark_read("rss:mr", true).await.unwrap();
46 + let fg = FeedGenerator::new(db.clone());
47 + fg.mark_read("rss:mr", true).await.unwrap();
48 48
49 49 let item = db.items().get_by_external_id("rss:mr").await.unwrap().unwrap();
50 50 assert!(item.is_read);
@@ -56,8 +56,8 @@ mod tests {
56 56 let feed = seed_feed(&db, "rss", "Feed").await;
57 57 seed_item(&db, &feed, "rss:ms", 0).await;
58 58
59 - let gen = FeedGenerator::new(db.clone());
60 - gen.mark_starred("rss:ms", true).await.unwrap();
59 + let fg = FeedGenerator::new(db.clone());
60 + fg.mark_starred("rss:ms", true).await.unwrap();
61 61
62 62 let item = db.items().get_by_external_id("rss:ms").await.unwrap().unwrap();
63 63 assert!(item.is_starred);
@@ -66,9 +66,9 @@ mod tests {
66 66 #[tokio::test]
67 67 async fn mark_read_nonexistent_is_noop() {
68 68 let db = test_db().await;
69 - let gen = FeedGenerator::new(db);
69 + let fg = FeedGenerator::new(db);
70 70 // Should not error on missing item
71 - gen.mark_read("nonexistent:1", true).await.unwrap();
71 + fg.mark_read("nonexistent:1", true).await.unwrap();
72 72 }
73 73
74 74 // ── mark_starred edge case ──────────────────────────────────
@@ -76,8 +76,8 @@ mod tests {
76 76 #[tokio::test]
77 77 async fn mark_starred_nonexistent_is_noop() {
78 78 let db = test_db().await;
79 - let gen = FeedGenerator::new(db);
80 - gen.mark_starred("nonexistent:1", true).await.unwrap();
79 + let fg = FeedGenerator::new(db);
80 + fg.mark_starred("nonexistent:1", true).await.unwrap();
81 81 }
82 82
83 83 #[tokio::test]
@@ -86,12 +86,12 @@ mod tests {
86 86 let feed = seed_feed(&db, "rss", "Feed").await;
87 87 seed_item(&db, &feed, "rss:toggle", 0).await;
88 88
89 - let gen = FeedGenerator::new(db.clone());
90 - gen.mark_read("rss:toggle", true).await.unwrap();
89 + let fg = FeedGenerator::new(db.clone());
90 + fg.mark_read("rss:toggle", true).await.unwrap();
91 91 let item = db.items().get_by_external_id("rss:toggle").await.unwrap().unwrap();
92 92 assert!(item.is_read);
93 93
94 - gen.mark_read("rss:toggle", false).await.unwrap();
94 + fg.mark_read("rss:toggle", false).await.unwrap();
95 95 let item = db.items().get_by_external_id("rss:toggle").await.unwrap().unwrap();
96 96 assert!(!item.is_read);
97 97 }
@@ -102,12 +102,12 @@ mod tests {
102 102 let feed = seed_feed(&db, "rss", "Feed").await;
103 103 seed_item(&db, &feed, "rss:toggle", 0).await;
104 104
105 - let gen = FeedGenerator::new(db.clone());
106 - gen.mark_starred("rss:toggle", true).await.unwrap();
105 + let fg = FeedGenerator::new(db.clone());
106 + fg.mark_starred("rss:toggle", true).await.unwrap();
107 107 let item = db.items().get_by_external_id("rss:toggle").await.unwrap().unwrap();
108 108 assert!(item.is_starred);
109 109
110 - gen.mark_starred("rss:toggle", false).await.unwrap();
110 + fg.mark_starred("rss:toggle", false).await.unwrap();
111 111 let item = db.items().get_by_external_id("rss:toggle").await.unwrap().unwrap();
112 112 assert!(!item.is_starred);
113 113 }
@@ -227,41 +227,41 @@ mod tests {
227 227 #[tokio::test]
228 228 async fn new_has_default_order_and_filter() {
229 229 let db = test_db().await;
230 - let gen = FeedGenerator::new(db);
231 - assert_eq!(gen.order(), OrderBy::default());
232 - assert_eq!(gen.filter().source, None);
233 - assert!(!gen.filter().unread_only);
234 - assert!(!gen.filter().starred_only);
230 + let fg = FeedGenerator::new(db);
231 + assert_eq!(fg.order(), OrderBy::default());
232 + assert_eq!(fg.filter().source, None);
233 + assert!(!fg.filter().unread_only);
234 + assert!(!fg.filter().starred_only);
235 235 }
236 236
237 237 #[tokio::test]
238 238 async fn with_order_changes_order() {
239 239 let db = test_db().await;
240 - let gen = FeedGenerator::new(db).with_order(OrderBy::Score);
241 - assert_eq!(gen.order(), OrderBy::Score);
240 + let fg = FeedGenerator::new(db).with_order(OrderBy::Score);
241 + assert_eq!(fg.order(), OrderBy::Score);
242 242 }
243 243
244 244 #[tokio::test]
245 245 async fn with_filter_changes_filter() {
246 246 let db = test_db().await;
247 247 let filter = FeedFilter::new().source("rss");
248 - let gen = FeedGenerator::new(db).with_filter(filter);
249 - assert_eq!(gen.filter().source.as_deref(), Some("rss"));
248 + let fg = FeedGenerator::new(db).with_filter(filter);
249 + assert_eq!(fg.filter().source.as_deref(), Some("rss"));
250 250 }
251 251
252 252 #[tokio::test]
253 253 async fn set_order_mutates() {
254 254 let db = test_db().await;
255 - let mut gen = FeedGenerator::new(db);
256 - gen.set_order(OrderBy::Score);
257 - assert_eq!(gen.order(), OrderBy::Score);
255 + let mut fg = FeedGenerator::new(db);
256 + fg.set_order(OrderBy::Score);
257 + assert_eq!(fg.order(), OrderBy::Score);
258 258 }
259 259
260 260 #[tokio::test]
261 261 async fn set_filter_mutates() {
262 262 let db = test_db().await;
263 - let mut gen = FeedGenerator::new(db);
264 - gen.set_filter(FeedFilter::new().unread_only());
265 - assert!(gen.filter().unread_only);
263 + let mut fg = FeedGenerator::new(db);
264 + fg.set_filter(FeedFilter::new().unread_only());
265 + assert!(fg.filter().unread_only);
266 266 }
267 267 }
@@ -242,8 +242,8 @@ mod tests {
242 242 #[tokio::test]
243 243 async fn get_items_empty_db_returns_empty() {
244 244 let db = test_db().await;
245 - let gen = FeedGenerator::new(db);
246 - let result = gen.get_items(0).await.unwrap();
245 + let fg = FeedGenerator::new(db);
246 + let result = fg.get_items(0).await.unwrap();
247 247 assert!(result.items.is_empty());
248 248 assert!(!result.has_more);
249 249 }
@@ -255,8 +255,8 @@ mod tests {
255 255 seed_item(&db, &feed, "rss:1", 2).await;
256 256 seed_item(&db, &feed, "rss:2", 1).await;
257 257
258 - let gen = FeedGenerator::new(db);
259 - let result = gen.get_items(0).await.unwrap();
258 + let fg = FeedGenerator::new(db);
259 + let result = fg.get_items(0).await.unwrap();
260 260 assert_eq!(result.items.len(), 2);
261 261 assert!(!result.has_more);
262 262 }
@@ -269,8 +269,8 @@ mod tests {
269 269 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
270 270 }
271 271
272 - let gen = FeedGenerator::new(db).with_page_size(3);
273 - let result = gen.get_items(0).await.unwrap();
272 + let fg = FeedGenerator::new(db).with_page_size(3);
273 + let result = fg.get_items(0).await.unwrap();
274 274 assert_eq!(result.items.len(), 3);
275 275 assert!(result.has_more);
276 276 }
@@ -285,8 +285,8 @@ mod tests {
285 285 seed_item(&db, &feed, "rss:2", 0).await;
286 286 seed_item(&db, &feed, "rss:3", 0).await;
287 287
288 - let gen = FeedGenerator::new(db);
289 - assert_eq!(gen.count().await.unwrap(), 3);
288 + let fg = FeedGenerator::new(db);
289 + assert_eq!(fg.count().await.unwrap(), 3);
290 290 }
291 291
292 292 #[tokio::test]
@@ -298,8 +298,8 @@ mod tests {
298 298 seed_item(&db, &feed_b, "hn:1", 0).await;
299 299 seed_item(&db, &feed_b, "hn:2", 0).await;
300 300
301 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn"));
302 - assert_eq!(gen.count().await.unwrap(), 2);
301 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn"));
302 + assert_eq!(fg.count().await.unwrap(), 2);
303 303 }
304 304
305 305 // ── get_sources ──────────────────────────────────────────────
@@ -313,8 +313,8 @@ mod tests {
313 313 seed_item(&db, &feed_b, "hn:1", 0).await;
314 314 seed_item(&db, &feed_b, "hn:2", 0).await;
315 315
316 - let gen = FeedGenerator::new(db);
317 - let sources = gen.get_sources().await.unwrap();
316 + let fg = FeedGenerator::new(db);
317 + let sources = fg.get_sources().await.unwrap();
318 318 assert_eq!(sources.len(), 2);
319 319
320 320 let hn_source = sources.iter().find(|s| s.id == "hn").unwrap();
@@ -332,8 +332,8 @@ mod tests {
332 332 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
333 333 }
334 334
335 - let gen = FeedGenerator::new(db).with_page_size(3);
336 - let result = gen.get_items(0).await.unwrap();
335 + let fg = FeedGenerator::new(db).with_page_size(3);
336 + let result = fg.get_items(0).await.unwrap();
337 337 assert_eq!(result.items.len(), 3);
338 338 assert!(!result.has_more);
339 339 }
@@ -347,8 +347,8 @@ mod tests {
347 347 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
348 348 }
349 349
350 - let gen = FeedGenerator::new(db).with_page_size(3);
351 - let result = gen.get_items(0).await.unwrap();
350 + let fg = FeedGenerator::new(db).with_page_size(3);
351 + let result = fg.get_items(0).await.unwrap();
352 352 assert_eq!(result.items.len(), 3);
353 353 assert!(result.has_more);
354 354 }
@@ -361,8 +361,8 @@ mod tests {
361 361 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
362 362 }
363 363
364 - let gen = FeedGenerator::new(db).with_page_size(3);
365 - let page1 = gen.get_items(1).await.unwrap();
364 + let fg = FeedGenerator::new(db).with_page_size(3);
365 + let page1 = fg.get_items(1).await.unwrap();
366 366 assert_eq!(page1.items.len(), 2);
367 367 assert!(!page1.has_more);
368 368 }
@@ -373,8 +373,8 @@ mod tests {
373 373 let feed = seed_feed(&db, "rss", "Feed").await;
374 374 seed_item(&db, &feed, "rss:1", 0).await;
375 375
376 - let gen = FeedGenerator::new(db).with_page_size(3);
377 - let result = gen.get_items(5).await.unwrap();
376 + let fg = FeedGenerator::new(db).with_page_size(3);
377 + let result = fg.get_items(5).await.unwrap();
378 378 assert!(result.items.is_empty());
379 379 assert!(!result.has_more);
380 380 }
@@ -395,9 +395,9 @@ mod tests {
395 395 .await
396 396 .unwrap();
397 397
398 - let gen = FeedGenerator::new(db)
398 + let fg = FeedGenerator::new(db)
399 399 .with_filter(FeedFilter::new().with_feed_tag("tech"));
400 - let result = gen.get_items(0).await.unwrap();
400 + let result = fg.get_items(0).await.unwrap();
401 401 assert_eq!(result.items.len(), 1);
402 402 assert_eq!(result.items[0].id.source, "rss");
403 403 }
@@ -408,9 +408,9 @@ mod tests {
408 408 let feed = seed_feed(&db, "rss", "Feed").await;
409 409 seed_item(&db, &feed, "rss:1", 0).await;
410 410
411 - let gen = FeedGenerator::new(db)
411 + let fg = FeedGenerator::new(db)
412 412 .with_filter(FeedFilter::new().with_feed_tag("nonexistent"));
413 - let result = gen.get_items(0).await.unwrap();
413 + let result = fg.get_items(0).await.unwrap();
414 414 assert!(result.items.is_empty());
415 415 }
416 416
@@ -434,9 +434,9 @@ mod tests {
434 434 .await
435 435 .unwrap();
436 436
437 - let gen = FeedGenerator::new(db)
437 + let fg = FeedGenerator::new(db)
438 438 .with_filter(FeedFilter::new().with_feed_tag("tech"));
439 - let result = gen.get_items(0).await.unwrap();
439 + let result = fg.get_items(0).await.unwrap();
440 440 assert_eq!(result.items.len(), 2);
441 441 }
442 442
@@ -451,9 +451,9 @@ mod tests {
451 451
452 452 db.items().mark_read(item1.id, true).await.unwrap();
453 453
454 - let gen = FeedGenerator::new(db)
454 + let fg = FeedGenerator::new(db)
455 455 .with_filter(FeedFilter::new().unread_only());
456 - let result = gen.get_items(0).await.unwrap();
456 + let result = fg.get_items(0).await.unwrap();
457 457 assert_eq!(result.items.len(), 1);
458 458 }
459 459
@@ -466,9 +466,9 @@ mod tests {
466 466
467 467 db.items().mark_starred(item2.id, true).await.unwrap();
468 468
469 - let gen = FeedGenerator::new(db)
469 + let fg = FeedGenerator::new(db)
470 470 .with_filter(FeedFilter::new().starred_only());
471 - let result = gen.get_items(0).await.unwrap();
471 + let result = fg.get_items(0).await.unwrap();
472 472 assert_eq!(result.items.len(), 1);
473 473 assert!(result.items[0].is_starred);
474 474 }
@@ -483,8 +483,8 @@ mod tests {
483 483 seed_item(&db, &feed, &format!("rss:{i}"), i).await;
484 484 }
485 485
486 - let gen = FeedGenerator::new(db).with_page_size(2); // page_size irrelevant for get_all
487 - let result = gen.get_all_items().await.unwrap();
486 + let fg = FeedGenerator::new(db).with_page_size(2); // page_size irrelevant for get_all
487 + let result = fg.get_all_items().await.unwrap();
488 488 assert_eq!(result.items.len(), 5);
489 489 assert!(!result.has_more);
490 490 }
@@ -497,9 +497,9 @@ mod tests {
497 497 seed_item(&db, &feed_a, "rss:1", 0).await;
498 498 seed_item(&db, &feed_b, "hn:1", 0).await;
499 499
500 - let gen = FeedGenerator::new(db)
500 + let fg = FeedGenerator::new(db)
501 501 .with_filter(FeedFilter::new().source("rss"));
502 - let result = gen.get_all_items().await.unwrap();
502 + let result = fg.get_all_items().await.unwrap();
503 503 assert_eq!(result.items.len(), 1);
504 504 assert_eq!(result.items[0].id.source, "rss");
505 505 }
@@ -511,8 +511,8 @@ mod tests {
511 511 seed_item(&db, &feed, "rss:old", 10).await;
512 512 seed_item(&db, &feed, "rss:new", 1).await;
513 513
514 - let gen = FeedGenerator::new(db).with_order(OrderBy::Chronological);
515 - let result = gen.get_all_items().await.unwrap();
514 + let fg = FeedGenerator::new(db).with_order(OrderBy::Chronological);
515 + let result = fg.get_all_items().await.unwrap();
516 516 assert_eq!(result.items[0].id.item_id, "rss:new");
517 517 assert_eq!(result.items[1].id.item_id, "rss:old");
518 518 }
@@ -526,8 +526,8 @@ mod tests {
526 526 seed_item(&db, &feed, "rss:1", 0).await;
527 527 seed_item(&db, &feed, "rss:2", 0).await;
528 528
529 - let gen = FeedGenerator::new(db);
530 - assert_eq!(gen.unread_count().await.unwrap(), 2);
529 + let fg = FeedGenerator::new(db);
530 + assert_eq!(fg.unread_count().await.unwrap(), 2);
531 531 }
532 532
533 533 #[tokio::test]
@@ -539,8 +539,8 @@ mod tests {
539 539
540 540 db.items().mark_read(item.id, true).await.unwrap();
541 541
542 - let gen = FeedGenerator::new(db);
543 - assert_eq!(gen.unread_count().await.unwrap(), 1);
542 + let fg = FeedGenerator::new(db);
543 + assert_eq!(fg.unread_count().await.unwrap(), 1);
544 544 }
545 545
546 546 // ── count with unread filter ────────────────────────────────
@@ -555,9 +555,9 @@ mod tests {
555 555
556 556 db.items().mark_read(item.id, true).await.unwrap();
557 557
558 - let gen = FeedGenerator::new(db)
558 + let fg = FeedGenerator::new(db)
559 559 .with_filter(FeedFilter::new().unread_only());
560 - assert_eq!(gen.count().await.unwrap(), 2);
560 + assert_eq!(fg.count().await.unwrap(), 2);
561 561 }
562 562
563 563 // ── get_sources details ─────────────────────────────────────
@@ -572,8 +572,8 @@ mod tests {
572 572 .await
573 573 .unwrap();
574 574
575 - let gen = FeedGenerator::new(db);
576 - let sources = gen.get_sources().await.unwrap();
575 + let fg = FeedGenerator::new(db);
576 + let sources = fg.get_sources().await.unwrap();
577 577 assert_eq!(sources.len(), 1);
578 578 assert!(sources[0].tags.contains(&"tech".to_string()));
579 579 assert!(sources[0].tags.contains(&"rust".to_string()));
@@ -588,8 +588,8 @@ mod tests {
588 588
589 589 db.items().mark_read(item.id, true).await.unwrap();
590 590
591 - let gen = FeedGenerator::new(db);
592 - let sources = gen.get_sources().await.unwrap();
591 + let fg = FeedGenerator::new(db);
592 + let sources = fg.get_sources().await.unwrap();
593 593 assert_eq!(sources[0].total_count, 2);
594 594 assert_eq!(sources[0].unread_count, 1);
595 595 }
@@ -597,8 +597,8 @@ mod tests {
597 597 #[tokio::test]
598 598 async fn get_sources_empty_db() {
599 599 let db = test_db().await;
600 - let gen = FeedGenerator::new(db);
601 - let sources = gen.get_sources().await.unwrap();
600 + let fg = FeedGenerator::new(db);
601 + let sources = fg.get_sources().await.unwrap();
602 602 assert!(sources.is_empty());
603 603 }
604 604
@@ -614,8 +614,8 @@ mod tests {
614 614 seed_item(&db, &feed, "rss:mid", 5).await;
615 615 seed_item(&db, &feed, "rss:new", 1).await;
616 616
617 - let gen = FeedGenerator::new(db).with_order(OrderBy::Chronological);
618 - let result = gen.get_items(0).await.unwrap();
617 + let fg = FeedGenerator::new(db).with_order(OrderBy::Chronological);
618 + let result = fg.get_items(0).await.unwrap();
619 619 assert_eq!(result.items[0].id.item_id, "rss:new");
620 620 assert_eq!(result.items[1].id.item_id, "rss:mid");
621 621 assert_eq!(result.items[2].id.item_id, "rss:old");
@@ -629,8 +629,8 @@ mod tests {
629 629 seed_scored_item(&db, &feed, "rss:high", 2, Some(100)).await;
630 630 seed_scored_item(&db, &feed, "rss:none", 3, None).await;
631 631
632 - let gen = FeedGenerator::new(db).with_order(OrderBy::Score);
633 - let result = gen.get_items(0).await.unwrap();
632 + let fg = FeedGenerator::new(db).with_order(OrderBy::Score);
633 + let result = fg.get_items(0).await.unwrap();
634 634 assert_eq!(result.items[0].id.item_id, "rss:high");
635 635 assert_eq!(result.items[1].id.item_id, "rss:low");
636 636 assert_eq!(result.items[2].id.item_id, "rss:none");
@@ -643,8 +643,8 @@ mod tests {
643 643 seed_scored_item(&db, &feed, "rss:old_50", 10, Some(50)).await;
644 644 seed_scored_item(&db, &feed, "rss:new_50", 1, Some(50)).await;
645 645
646 - let gen = FeedGenerator::new(db).with_order(OrderBy::Score);
647 - let result = gen.get_items(0).await.unwrap();
646 + let fg = FeedGenerator::new(db).with_order(OrderBy::Score);
647 + let result = fg.get_items(0).await.unwrap();
648 648 // Same score, newer item should come first
649 649 assert_eq!(result.items[0].id.item_id, "rss:new_50");
650 650 assert_eq!(result.items[1].id.item_id, "rss:old_50");
@@ -660,8 +660,8 @@ mod tests {
660 660
661 661 db.items().mark_read(read_item.id, true).await.unwrap();
662 662
663 - let gen = FeedGenerator::new(db).with_order(OrderBy::UnreadFirst);
664 - let result = gen.get_items(0).await.unwrap();
663 + let fg = FeedGenerator::new(db).with_order(OrderBy::UnreadFirst);
664 + let result = fg.get_items(0).await.unwrap();
665 665 assert_eq!(result.items.len(), 3);
666 666 // Items should be grouped by read status: the sort uses
667 667 // b.is_read.cmp(&a.is_read) which groups items by read state,
@@ -685,8 +685,8 @@ mod tests {
685 685
686 686 db.items().mark_starred(starred_item.id, true).await.unwrap();
687 687
688 - let gen = FeedGenerator::new(db).with_order(OrderBy::StarredFirst);
689 - let result = gen.get_items(0).await.unwrap();
688 + let fg = FeedGenerator::new(db).with_order(OrderBy::StarredFirst);
689 + let result = fg.get_items(0).await.unwrap();
690 690 assert!(result.items[0].is_starred, "first item should be starred");
691 691 assert!(!result.items[1].is_starred, "second item should not be starred");
692 692 }
@@ -701,9 +701,9 @@ mod tests {
701 701 seed_tagged_item(&db, &feed, "rss:python", 2, vec!["python".into()]).await;
702 702 seed_tagged_item(&db, &feed, "rss:both", 3, vec!["rust".into(), "go".into()]).await;
703 703
704 - let gen = FeedGenerator::new(db)
704 + let fg = FeedGenerator::new(db)
705 705 .with_filter(FeedFilter::new().with_tag("rust"));
706 - let result = gen.get_items(0).await.unwrap();
706 + let result = fg.get_items(0).await.unwrap();
707 707 assert_eq!(result.items.len(), 2);
708 708 let ids: Vec<&str> = result.items.iter().map(|i| i.id.item_id.as_str()).collect();
709 709 assert!(ids.contains(&"rss:rust"));
@@ -716,9 +716,9 @@ mod tests {
716 716 let feed = seed_feed(&db, "rss", "Feed").await;
717 717 seed_tagged_item(&db, &feed, "rss:1", 1, vec!["python".into()]).await;
718 718
719 - let gen = FeedGenerator::new(db)
719 + let fg = FeedGenerator::new(db)
720 720 .with_filter(FeedFilter::new().with_tag("rust"));
721 - let result = gen.get_items(0).await.unwrap();
721 + let result = fg.get_items(0).await.unwrap();
722 722 assert!(result.items.is_empty());
723 723 }
724 724
@@ -731,9 +731,9 @@ mod tests {
731 731 seed_tagged_item(&db, &feed, "rss:p", 3, vec!["python".into()]).await;
732 732
733 733 // Filter with two tags -- OR logic: items matching either tag pass
734 - let gen = FeedGenerator::new(db)
734 + let fg = FeedGenerator::new(db)
735 735 .with_filter(FeedFilter::new().with_tag("rust").with_tag("go"));
736 - let result = gen.get_items(0).await.unwrap();
736 + let result = fg.get_items(0).await.unwrap();
737 737 assert_eq!(result.items.len(), 2);
738 738 }
739 739
@@ -744,9 +744,9 @@ mod tests {
744 744 seed_item(&db, &feed, "rss:notagged", 1).await; // No tags
745 745 seed_tagged_item(&db, &feed, "rss:tagged", 2, vec!["rust".into()]).await;
746 746
747 - let gen = FeedGenerator::new(db)
747 + let fg = FeedGenerator::new(db)
748 748 .with_filter(FeedFilter::new().with_tag("rust"));
749 - let result = gen.get_items(0).await.unwrap();
749 + let result = fg.get_items(0).await.unwrap();
750 750 assert_eq!(result.items.len(), 1);
751 751 assert_eq!(result.items[0].id.item_id, "rss:tagged");
752 752 }
@@ -767,10 +767,10 @@ mod tests {
767 767 seed_tagged_item(&db, &feed, &format!("rss:no_{i}"), i as i64, vec!["python".into()]).await;
768 768 }
769 769
770 - let gen = FeedGenerator::new(db)
770 + let fg = FeedGenerator::new(db)
771 771 .with_page_size(3)
772 772 .with_filter(FeedFilter::new().with_tag("rust"));
773 - let result = gen.get_items(0).await.unwrap();
773 + let result = fg.get_items(0).await.unwrap();
774 774 assert_eq!(result.items.len(), 1);
775 775 // SQL returned 4 items (> page_size=3), so sql_had_more=true.
776 776 // After in-memory tag filtering, only 1 item remains (< page_size).
@@ -787,8 +787,8 @@ mod tests {
787 787 seed_item(&db, &feed, "rss:1", 0).await;
788 788 seed_item(&db, &feed, "rss:2", 1).await;
789 789
790 - let gen = FeedGenerator::new(db).with_page_size(3);
791 - let result = gen.get_items(0).await.unwrap();
790 + let fg = FeedGenerator::new(db).with_page_size(3);
791 + let result = fg.get_items(0).await.unwrap();
792 792 assert_eq!(result.items.len(), 2);
793 793 assert!(!result.has_more);
794 794 }
@@ -802,10 +802,10 @@ mod tests {
802 802 seed_tagged_item(&db, &feed, "rss:1", 0, vec!["rust".into()]).await;
803 803 seed_tagged_item(&db, &feed, "rss:2", 1, vec!["rust".into()]).await;
804 804
805 - let gen = FeedGenerator::new(db)
805 + let fg = FeedGenerator::new(db)
806 806 .with_page_size(3)
807 807 .with_filter(FeedFilter::new().with_tag("rust"));
808 - let result = gen.get_items(0).await.unwrap();
808 + let result = fg.get_items(0).await.unwrap();
809 809 assert_eq!(result.items.len(), 2);
810 810 assert!(!result.has_more);
811 811 }
@@ -821,9 +821,9 @@ mod tests {
821 821 seed_item(&db, &feed_a, "rss:2", 2).await;
822 822 seed_item(&db, &feed_b, "hn:1", 3).await;
823 823
824 - let gen = FeedGenerator::new(db)
824 + let fg = FeedGenerator::new(db)
825 825 .with_filter(FeedFilter::new().source("rss"));
826 - let result = gen.get_items(0).await.unwrap();
826 + let result = fg.get_items(0).await.unwrap();
827 827 assert_eq!(result.items.len(), 2);
828 828 assert!(result.items.iter().all(|i| i.id.source == "rss"));
829 829 }
@@ -837,9 +837,9 @@ mod tests {
837 837 seed_item(&db, &feed, "rss:1", 1).await; // Title: "Title rss:1"
838 838 seed_item(&db, &feed, "rss:2", 2).await; // Title: "Title rss:2"
839 839
840 - let gen = FeedGenerator::new(db)
840 + let fg = FeedGenerator::new(db)
841 841 .with_filter(FeedFilter::new().search("Title rss:1"));
842 - let result = gen.get_items(0).await.unwrap();
842 + let result = fg.get_items(0).await.unwrap();
843 843 assert_eq!(result.items.len(), 1);
844 844 assert_eq!(result.items[0].id.item_id, "rss:1");
845 845 }
@@ -850,9 +850,9 @@ mod tests {
850 850 let feed = seed_feed(&db, "rss", "Feed").await;
851 851 seed_item(&db, &feed, "rss:1", 1).await;
852 852
853 - let gen = FeedGenerator::new(db)
853 + let fg = FeedGenerator::new(db)
854 854 .with_filter(FeedFilter::new().search("zzz_nonexistent_zzz"));
855 - let result = gen.get_items(0).await.unwrap();
855 + let result = fg.get_items(0).await.unwrap();
856 856 assert!(result.items.is_empty());
857 857 }
858 858
@@ -867,9 +867,9 @@ mod tests {
867 867 seed_item(&db, &feed_b, "hn:match", 2).await;
868 868
869 869 // Search that matches both, but restricted to rss source
870 - let gen = FeedGenerator::new(db)
870 + let fg = FeedGenerator::new(db)
871 871 .with_filter(FeedFilter::new().search("Item").source("rss"));
872 - let result = gen.get_items(0).await.unwrap();
872 + let result = fg.get_items(0).await.unwrap();
873 873 assert_eq!(result.items.len(), 1);
874 874 assert_eq!(result.items[0].id.source, "rss");
875 875 }
@@ -884,9 +884,9 @@ mod tests {
884 884 db.items().mark_read(item1.id, true).await.unwrap();
885 885
886 886 // Search matches both items, but only unread should appear
887 - let gen = FeedGenerator::new(db)
887 + let fg = FeedGenerator::new(db)
888 888 .with_filter(FeedFilter::new().search("Item").unread_only());
889 - let result = gen.get_items(0).await.unwrap();
889 + let result = fg.get_items(0).await.unwrap();
890 890 assert_eq!(result.items.len(), 1);
891 891 assert!(!result.items[0].is_read);
892 892 }
@@ -900,9 +900,9 @@ mod tests {
900 900
901 901 db.items().mark_starred(item2.id, true).await.unwrap();
902 902
903 - let gen = FeedGenerator::new(db)
903 + let fg = FeedGenerator::new(db)
904 904 .with_filter(FeedFilter::new().search("Item").starred_only());
905 - let result = gen.get_items(0).await.unwrap();
905 + let result = fg.get_items(0).await.unwrap();
906 906 assert_eq!(result.items.len(), 1);
907 907 assert!(result.items[0].is_starred);
908 908 }
@@ -912,8 +912,8 @@ mod tests {
912 912 #[tokio::test]
913 913 async fn get_all_items_empty_db() {
914 914 let db = test_db().await;
915 - let gen = FeedGenerator::new(db);
916 - let result = gen.get_all_items().await.unwrap();
915 + let fg = FeedGenerator::new(db);
916 + let result = fg.get_all_items().await.unwrap();
917 917 assert!(result.items.is_empty());
918 918 assert!(!result.has_more);
919 919 }
@@ -928,9 +928,9 @@ mod tests {
928 928
929 929 db.items().mark_read(item1.id, true).await.unwrap();
930 930
931 - let gen = FeedGenerator::new(db)
931 + let fg = FeedGenerator::new(db)
932 932 .with_filter(FeedFilter::new().unread_only());
933 - let result = gen.get_all_items().await.unwrap();
933 + let result = fg.get_all_items().await.unwrap();
934 934 assert_eq!(result.items.len(), 2);
935 935 assert!(result.items.iter().all(|i| !i.is_read));
936 936 }
@@ -944,9 +944,9 @@ mod tests {
944 944
945 945 db.items().mark_starred(item2.id, true).await.unwrap();
946 946
947 - let gen = FeedGenerator::new(db)
947 + let fg = FeedGenerator::new(db)
948 948 .with_filter(FeedFilter::new().starred_only());
949 - let result = gen.get_all_items().await.unwrap();
949 + let result = fg.get_all_items().await.unwrap();
950 950 assert_eq!(result.items.len(), 1);
951 951 assert!(result.items[0].is_starred);
952 952 }
@@ -958,9 +958,9 @@ mod tests {
958 958 seed_tagged_item(&db, &feed, "rss:tagged", 1, vec!["rust".into()]).await;
959 959 seed_item(&db, &feed, "rss:plain", 2).await;
960 960
961 - let gen = FeedGenerator::new(db)
961 + let fg = FeedGenerator::new(db)
962 962 .with_filter(FeedFilter::new().with_tag("rust"));
963 - let result = gen.get_all_items().await.unwrap();
963 + let result = fg.get_all_items().await.unwrap();
964 964 assert_eq!(result.items.len(), 1);
965 965 assert_eq!(result.items[0].id.item_id, "rss:tagged");
966 966 }
@@ -972,9 +972,9 @@ mod tests {
972 972 seed_item(&db, &feed, "rss:1", 1).await;
973 973 seed_item(&db, &feed, "rss:2", 2).await;
974 974
975 - let gen = FeedGenerator::new(db)
975 + let fg = FeedGenerator::new(db)
976 976 .with_filter(FeedFilter::new().search("Item rss:1"));
977 - let result = gen.get_all_items().await.unwrap();
977 + let result = fg.get_all_items().await.unwrap();
978 978 assert_eq!(result.items.len(), 1);
979 979 }
980 980
@@ -989,9 +989,9 @@ mod tests {
989 989
990 990 db.items().mark_read(item_a1.id, true).await.unwrap();
991 991
992 - let gen = FeedGenerator::new(db)
992 + let fg = FeedGenerator::new(db)
Lines truncated
@@ -1,7 +1,7 @@
1 1 [package]
2 2 name = "balanced-breakfast-desktop"
3 3 version = "0.3.0"
4 - edition = "2021"
4 + edition = "2024"
5 5
6 6 [[bin]]
7 7 name = "balanced-breakfast-desktop"
@@ -149,6 +149,7 @@ body {
149 149 }
150 150 .btn-success:hover { background-color: var(--accent-yellow-light); border-color: var(--accent-yellow-light); }
151 151 .btn-small { padding: 0.2rem 0.5rem; font-size: 0.8rem; }
152 + .btn-small.active { background: var(--accent-yellow); color: var(--text-on-accent); border-color: var(--accent-yellow); }
152 153
153 154 /* Main layout */
154 155 .main {
@@ -865,7 +866,7 @@ body {
865 866
866 867 /* Source edit button (gear icon) */
867 868 .source-edit {
868 - display: none;
869 + display: inline;
869 870 background: none;
870 871 border: none;
871 872 color: var(--text-muted);
@@ -873,9 +874,9 @@ body {
873 874 font-size: 0.8rem;
874 875 padding: 0 0.2rem;
875 876 line-height: 1;
877 + opacity: 0.4;
876 878 }
877 - .source-edit:hover { color: var(--accent-yellow); }
878 - .source-item:hover .source-edit { display: inline; }
879 + .source-edit:hover { color: var(--accent-yellow); opacity: 1; }
879 880
880 881 /* Query feeds — sidebar divider */
881 882 .source-divider {
@@ -16,14 +16,17 @@
16 16 <input type="text" id="search-input" placeholder="Search... (/) | ? for help" class="search-input">
17 17 <span id="search-spinner" class="search-spinner" aria-hidden="true"></span>
18 18 <label for="sort-select" class="sr-only">Sort order</label>
19 - <select id="sort-select" class="sort-select" title="Sort order">
19 + <button id="unread-toggle" class="btn btn-small" title="Show unread only (U)" aria-pressed="false">Unread</button>
20 + <select id="sort-select" class="sort-select" title="Sort order (S)">
20 21 <option value="chronological">Newest First</option>
21 22 <option value="score">By Score</option>
22 23 <option value="unread">Unread First</option>
23 24 <option value="starred">Starred First</option>
24 25 </select>
25 - <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds">Refresh</button>
26 + <button id="mark-all-read-btn" class="btn btn-small" title="Mark all as read (Shift+A)">Mark All Read</button>
27 + <button id="refresh-btn" class="btn btn-primary" title="Refresh all feeds (R)">Refresh</button>
26 28 <button id="add-feed-btn" class="btn btn-success" title="Add a new feed source">+ Add Feed</button>
29 + <button id="help-btn" class="btn btn-small" title="Keyboard shortcuts (?)" aria-label="Help">?</button>
27 30 <button id="settings-btn" class="btn btn-small" title="Settings" aria-label="Settings">&#9881;</button>
28 31 </div>
29 32 </header>
@@ -41,8 +44,8 @@
41 44 </li>
42 45 </ul>
43 46 </nav>
44 - <div id="saved-articles-btn" class="sidebar-saved" title="Reading List">
45 - <span class="source-name">Reading List</span>
47 + <div id="saved-articles-btn" class="sidebar-saved" title="Saved Articles">
48 + <span class="source-name">Saved Articles</span>
46 49 <span id="saved-count" class="source-count">0</span>
47 50 </div>
48 51 <div class="sidebar-footer">
@@ -84,6 +84,9 @@
84 84 document.getElementById('load-more-btn').addEventListener('click', BB.items.loadMore);
85 85 document.getElementById('settings-btn').addEventListener('click', showSettings);
86 86 document.getElementById('sync-settings-btn').addEventListener('click', BB.sync.openSettings);
87 + document.getElementById('help-btn').addEventListener('click', showHelp);
88 + document.getElementById('unread-toggle').addEventListener('click', toggleUnreadFilter);
89 + document.getElementById('mark-all-read-btn').addEventListener('click', markAllReadGlobal);
87 90
88 91 // Modal close on overlay click
89 92 document.getElementById('modal-overlay').addEventListener('click', (e) => {
@@ -106,6 +109,39 @@
106 109 }
107 110
108 111 /**
112 + * Toggle the "unread only" filter on/off.
113 + */
114 + function toggleUnreadFilter() {
115 + const btn = document.getElementById('unread-toggle');
116 + const active = btn.getAttribute('aria-pressed') === 'true';
117 + btn.setAttribute('aria-pressed', !active);
118 + btn.classList.toggle('active', !active);
119 + BB.state.set('unreadOnly', !active);
120 + BB.state.resetPagination(true);
121 + BB.items.load();
122 + }
123 +
124 + /**
125 + * Mark all items as read (global or per-source).
126 + */
127 + async function markAllReadGlobal() {
128 + const source = BB.state.currentSource || null;
129 + const label = source
130 + ? (BB.state.sources.find(s => s.id === source) || {}).name || 'this source'
131 + : 'all sources';
132 + const ok = await BB.ui.confirmAction('Mark all items in ' + label + ' as read?');
133 + if (!ok) return;
134 + try {
135 + await BB.api.items.markAllRead(source);
136 + BB.ui.showToast('Marked all as read');
137 + await BB.sources.load();
138 + await BB.items.load();
139 + } catch (err) {
140 + BB.ui.showToast('Failed: ' + BB.utils.getErrorMessage(err), 'error');
141 + }
142 + }
143 +
144 + /**
109 145 * Global keyboard shortcut handler. Skipped when focus is in a form input.
110 146 * Key choices follow common reader conventions: j/k from vim, s/r from
111 147 * Google Reader, /=search from vim, ?=help from many CLI tools.
@@ -166,6 +202,18 @@
166 202 }
167 203 break;
168 204
205 + case 'u': // Toggle unread only
206 + e.preventDefault();
207 + toggleUnreadFilter();
208 + break;
209 +
210 + case 'A': // Mark all as read (Shift+A)
211 + if (e.shiftKey) {
212 + e.preventDefault();
213 + markAllReadGlobal();
214 + }
215 + break;
216 +
169 217 case '/': // Focus search
170 218 e.preventDefault();
171 219 document.getElementById('search-input').focus();
@@ -407,6 +455,8 @@
407 455 <h3>Actions</h3>
408 456 <div class="help-row"><kbd>s</kbd><span>Star / unstar</span></div>
409 457 <div class="help-row"><kbd>r</kbd><span>Toggle read / unread</span></div>
458 + <div class="help-row"><kbd>u</kbd><span>Toggle unread-only filter</span></div>
459 + <div class="help-row"><kbd>Shift</kbd> <kbd>A</kbd><span>Mark all as read</span></div>
410 460 <div class="help-row"><kbd>?</kbd><span>Show this help</span></div>
411 461 </div>
412 462 <div class="help-section">
@@ -413,7 +413,8 @@
413 413 * @param {string} id - Bookmark ID.
414 414 */
415 415 async function deleteBookmark(id) {
416 - if (!confirm('Remove this bookmark?')) return;
416 + const ok = await BB.ui.confirmAction('Remove this bookmark?');
417 + if (!ok) return;
417 418 try {
418 419 await BB.api.bookmarks.delete(id);
419 420 BB.ui.showToast('Bookmark removed');
@@ -83,16 +83,27 @@
83 83 const body = document.getElementById('modal-body');
84 84
85 85 title.textContent = 'Add Feed';
86 - body.innerHTML = '<p style="margin-bottom: 1rem; color: var(--text-secondary);">Select a plugin:</p>';
86 + body.innerHTML = '<p style="margin-bottom: 1rem; color: var(--text-secondary);">Select a source type:</p>';
87 87
88 88 const list = document.createElement('ul');
89 89 list.className = 'plugin-list';
90 90
91 - plugins.forEach(plugin => {
91 + // Sort recommended plugins first
92 + const recommended = new Set(['rss', 'mastodon', 'reddit']);
93 + const sorted = [...plugins].sort((a, b) => {
94 + const aRec = recommended.has(a.id) ? 0 : 1;
95 + const bRec = recommended.has(b.id) ? 0 : 1;
96 + return aRec - bRec || a.name.localeCompare(b.name);
97 + });
98 +
99 + sorted.forEach(plugin => {
92 100 const li = document.createElement('li');
93 101 li.className = 'plugin-item';
102 + const badge = recommended.has(plugin.id)
103 + ? '<span style="font-size:0.7rem; padding:0.1rem 0.4rem; background:var(--accent-yellow); color:var(--text-on-accent); border-radius:3px; margin-left:0.5rem; vertical-align:middle;">Recommended</span>'
104 + : '';
94 105 li.innerHTML = `
95 - <div class="plugin-name">${BB.utils.escapeHtml(plugin.name)}</div>
106 + <div class="plugin-name">${BB.utils.escapeHtml(plugin.name)}${badge}</div>
96 107 ${plugin.description ? `<div class="plugin-desc">${BB.utils.escapeHtml(plugin.description)}</div>` : ''}
97 108 `;
98 109 li.onclick = () => selectPlugin(plugin.id);
@@ -158,8 +169,9 @@
158 169 config: data,
159 170 });
160 171
161 - BB.ui.showToast('Feed created!');
162 - BB.sources.load();
172 + BB.ui.showToast('Feed created! Fetching articles...');
173 + await BB.sources.load();
174 + refresh();
163 175 },
164 176 });
165 177 }
@@ -18,9 +18,11 @@
18 18 * @returns {Object} Filter object for the list_items API call.
19 19 */
20 20 function buildFilter(page) {
21 + const unreadFromSort = BB.state.currentSource === '' && BB.state.currentOrder === 'unread';
22 + const unreadToggle = BB.state.unreadOnly;
21 23 return {
22 24 source: BB.state.currentSource || undefined,
23 - unread: BB.state.currentSource === '' && BB.state.currentOrder === 'unread' ? true : undefined,
25 + unread: (unreadFromSort || unreadToggle) ? true : undefined,
24 26 starred: BB.state.currentSource === '' && BB.state.currentOrder === 'starred' ? true : undefined,
25 27 search: BB.state.currentSearch || undefined,
26 28 order: BB.state.currentOrder || undefined,
@@ -96,9 +98,17 @@
96 98
97 99 if (items.length === 0) {
98 100 const hasFeeds = BB.state.sources && BB.state.sources.length > 0;
99 - const message = hasFeeds
100 - ? '<div class="empty-icon">&#x1F50D;</div>No items match the current filter.<br>Try switching to "All" or a different source.'
101 - : '<div class="empty-icon">&#x1F373;</div>No feeds yet.<br>Click <strong>+ Add Feed</strong> to get started, or import an OPML file.<br><kbd>?</kbd> for keyboard shortcuts.';
101 + const totalItems = hasFeeds ? BB.state.sources.reduce((n, s) => n + s.totalCount, 0) : 0;
102 + let message;
103 + if (!hasFeeds) {
104 + message = '<div class="empty-icon">&#x1F373;</div>No feeds yet.<br>Click <strong>+ Add Feed</strong> to get started, or import an OPML file.<br><kbd>?</kbd> for keyboard shortcuts.';
105 + } else if (totalItems === 0) {
106 + message = '<div class="empty-icon">&#x1F504;</div>Feeds added but no articles yet.<br>Click <strong>Refresh</strong> to fetch posts.';
107 + } else if (BB.state.unreadOnly) {
108 + message = '<div class="empty-icon">&#x2713;</div>All caught up! No unread items.';
109 + } else {
110 + message = '<div class="empty-icon">&#x1F50D;</div>No items match the current filter.<br>Try switching to "All" or a different source.';
111 + }
102 112 list.innerHTML = '<li class="item empty-state">' + message + '</li>';
103 113 document.getElementById('load-more').style.display = 'none';
104 114 return;
@@ -77,7 +77,7 @@
77 77 const emptyLi = document.createElement('li');
78 78 emptyLi.className = 'source-item';
79 79 emptyLi.style.cssText = 'cursor:default; flex-direction:column; align-items:center; text-align:center; padding:1.5rem 1rem; color:var(--text-muted); font-size:0.8rem; line-height:1.5;';
80 - emptyLi.innerHTML = '<div style="font-size:1.5rem; margin-bottom:0.5rem; opacity:0.5;">\uD83C\uDF73</div>Add your first feed to get started.<br>Click <strong style="color:var(--yolk)">+ Add Feed</strong> above.';
80 + emptyLi.innerHTML = '<div style="font-size:1.5rem; margin-bottom:0.5rem; opacity:0.5;">\uD83C\uDF73</div>Add your first feed to get started.<br>Click <strong style="color:var(--yolk)">+ Add Feed</strong> above,<br>or <a href="#" onclick="event.preventDefault(); BB.feeds.importOpml();" style="color:var(--accent-yellow);">import an OPML file</a>.';
81 81 frag.appendChild(emptyLi);
82 82 }
83 83
@@ -177,10 +177,10 @@
177 177 });
178 178 }
179 179
180 - // "+ Query Feed" button
180 + // "+ Saved Filter" button
181 181 const addQfBtn = document.createElement('li');
182 182 addQfBtn.className = 'source-item add-query-feed-btn';
183 - addQfBtn.innerHTML = '<span class="source-name" style="color:var(--text-muted)">+ Query Feed</span>';
183 + addQfBtn.innerHTML = '<span class="source-name" style="color:var(--text-muted)" title="Create a dynamic feed from search filters">+ Saved Filter</span>';
184 184 addQfBtn.onclick = () => BB.queryFeeds.openBuilder(null);
185 185 frag.appendChild(addQfBtn);
186 186
@@ -245,6 +245,7 @@
245 245
246 246 // Action buttons
247 247 const actions = `<div class="hp-actions">
248 + <button class="hp-action" data-action="mark-read">Mark All Read</button>
248 249 <button class="hp-action" data-action="edit">Edit Feed</button>
249 250 <button class="hp-action" data-action="tags">Edit Tags</button>
250 251 <button class="hp-action hp-action-danger" data-action="delete">Delete</button>
@@ -263,6 +264,17 @@
263 264 pop.remove();
264 265 document.removeEventListener('click', outsideClick, true);
265 266 }
267 + pop.querySelector('[data-action="mark-read"]').onclick = async () => {
268 + dismiss();
269 + try {
270 + await BB.api.items.markAllRead(source.id);
271 + BB.ui.showToast('Marked all read in ' + source.name);
272 + await BB.sources.load();
273 + await BB.items.load();
274 + } catch (err) {
275 + BB.ui.showToast('Failed: ' + getErrorMessage(err), 'error');
276 + }
277 + };
266 278 pop.querySelector('[data-action="edit"]').onclick = () => { dismiss(); editFeed(source); };
267 279 pop.querySelector('[data-action="tags"]').onclick = () => { dismiss(); editTags(source); };
268 280 pop.querySelector('[data-action="delete"]').onclick = () => { dismiss(); deleteFeed(source); };
@@ -353,6 +353,21 @@ pub async fn unstar_item(
353 353 Ok(state.orchestrator.database().items().mark_starred(item_id, false).await?)
354 354 }
355 355
356 + /// Mark all unread items as read, optionally for a specific source.
357 + #[tauri::command]
358 + #[instrument(skip_all)]
359 + pub async fn mark_all_read(
360 + state: State<'_, Arc<AppState>>,
361 + source_id: Option<String>,
362 + ) -> Result<u64, ApiError> {
363 + Ok(state
364 + .orchestrator
365 + .database()
366 + .items()
367 + .mark_all_read(source_id.as_deref())
368 + .await?)
369 + }
370 +
356 371 /// Get the total count of unread items.
357 372 #[tauri::command]
358 373 #[instrument(skip_all)]
@@ -17,22 +17,22 @@ async fn generator_mark_read_updates_unread_count() {
17 17 common::insert_item(&db, &feed, "rss:mr2", "Article 2", 2).await;
18 18 common::insert_item(&db, &feed, "rss:mr3", "Article 3", 3).await;
19 19
20 - let gen = FeedGenerator::new(db.clone());
20 + let fg = FeedGenerator::new(db.clone());
21 21
22 22 // All 3 unread initially
23 - assert_eq!(gen.unread_count().await.unwrap(), 3);
23 + assert_eq!(fg.unread_count().await.unwrap(), 3);
24 24
25 25 // Mark one read via generator (uses external_id lookup)
26 - gen.mark_read("rss:mr1", true).await.unwrap();
27 - assert_eq!(gen.unread_count().await.unwrap(), 2);
26 + fg.mark_read("rss:mr1", true).await.unwrap();
27 + assert_eq!(fg.unread_count().await.unwrap(), 2);
28 28
29 29 // Mark another read
30 - gen.mark_read("rss:mr2", true).await.unwrap();
31 - assert_eq!(gen.unread_count().await.unwrap(), 1);
30 + fg.mark_read("rss:mr2", true).await.unwrap();
31 + assert_eq!(fg.unread_count().await.unwrap(), 1);
32 32
33 33 // Mark one back to unread
34 - gen.mark_read("rss:mr1", false).await.unwrap();
35 - assert_eq!(gen.unread_count().await.unwrap(), 2);
34 + fg.mark_read("rss:mr1", false).await.unwrap();
35 + assert_eq!(fg.unread_count().await.unwrap(), 2);
36 36 }
37 37
38 38 #[tokio::test]
@@ -42,15 +42,15 @@ async fn generator_mark_starred_does_not_affect_unread_count() {
42 42 common::insert_item(&db, &feed, "rss:ms1", "Article 1", 1).await;
43 43 common::insert_item(&db, &feed, "rss:ms2", "Article 2", 2).await;
44 44
45 - let gen = FeedGenerator::new(db.clone());
46 - assert_eq!(gen.unread_count().await.unwrap(), 2);
45 + let fg = FeedGenerator::new(db.clone());
46 + assert_eq!(fg.unread_count().await.unwrap(), 2);
47 47
48 48 // Starring should not change unread count
49 - gen.mark_starred("rss:ms1", true).await.unwrap();
50 - assert_eq!(gen.unread_count().await.unwrap(), 2);
49 + fg.mark_starred("rss:ms1", true).await.unwrap();
50 + assert_eq!(fg.unread_count().await.unwrap(), 2);
51 51
52 - gen.mark_starred("rss:ms1", false).await.unwrap();
53 - assert_eq!(gen.unread_count().await.unwrap(), 2);
52 + fg.mark_starred("rss:ms1", false).await.unwrap();
53 + assert_eq!(fg.unread_count().await.unwrap(), 2);
54 54 }
55 55
56 56 #[tokio::test]
@@ -59,11 +59,11 @@ async fn generator_mark_read_nonexistent_preserves_count() {
59 59 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
60 60 common::insert_item(&db, &feed, "rss:1", "Article", 1).await;
61 61
62 - let gen = FeedGenerator::new(db.clone());
62 + let fg = FeedGenerator::new(db.clone());
63 63 // Should not panic or error
64 - gen.mark_read("nonexistent:999", true).await.unwrap();
64 + fg.mark_read("nonexistent:999", true).await.unwrap();
65 65 // Original item should remain unread
66 - assert_eq!(gen.unread_count().await.unwrap(), 1);
66 + assert_eq!(fg.unread_count().await.unwrap(), 1);
67 67 }
68 68
69 69 // ── Ordering ─────────────────────────────────────────────────────────
@@ -102,8 +102,8 @@ async fn list_items_order_by_score() {
102 102 .unwrap();
103 103 }
104 104
105 - let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::Score);
106 - let result = gen.get_items(0).await.unwrap();
105 + let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::Score);
106 + let result = fg.get_items(0).await.unwrap();
107 107 assert_eq!(result.items.len(), 3);
108 108 assert_eq!(result.items[0].meta.score, Some(100));
109 109 assert_eq!(result.items[1].meta.score, Some(10));
@@ -119,8 +119,8 @@ async fn list_items_order_starred_first() {
119 119
120 120 db.items().mark_starred(item2.id, true).await.unwrap();
121 121
122 - let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::StarredFirst);
123 - let result = gen.get_items(0).await.unwrap();
122 + let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::StarredFirst);
123 + let result = fg.get_items(0).await.unwrap();
124 124 assert!(result.items[0].is_starred, "starred item should sort first");
125 125 assert!(!result.items[1].is_starred);
126 126 // item1 should not have id leakage
@@ -136,8 +136,8 @@ async fn list_items_order_unread_first() {
136 136
137 137 db.items().mark_read(item1.id, true).await.unwrap();
138 138
139 - let gen = FeedGenerator::new(db).with_order(bb_feed::OrderBy::UnreadFirst);
140 - let result = gen.get_items(0).await.unwrap();
139 + let fg = FeedGenerator::new(db).with_order(bb_feed::OrderBy::UnreadFirst);
140 + let result = fg.get_items(0).await.unwrap();
141 141 // UnreadFirst groups items by read status — verify they're grouped
142 142 let first_read = result.items[0].is_read;
143 143 let last_read = result.items[1].is_read;
@@ -154,8 +154,8 @@ async fn generator_count_all() {
154 154 common::insert_item(&db, &feed, "rss:2", "B", 2).await;
155 155 common::insert_item(&db, &feed, "rss:3", "C", 3).await;
156 156
157 - let gen = FeedGenerator::new(db);
158 - assert_eq!(gen.count().await.unwrap(), 3);
157 + let fg = FeedGenerator::new(db);
158 + assert_eq!(fg.count().await.unwrap(), 3);
159 159 }
160 160
161 161 #[tokio::test]
@@ -167,8 +167,8 @@ async fn generator_count_with_source_filter() {
167 167 common::insert_item(&db, &feed_hn, "hn:1", "B", 1).await;
168 168 common::insert_item(&db, &feed_hn, "hn:2", "C", 2).await;
169 169
170 - let gen = FeedGenerator::new(db.clone()).with_filter(FeedFilter::new().source("hn"));
171 - assert_eq!(gen.count().await.unwrap(), 2);
170 + let fg = FeedGenerator::new(db.clone()).with_filter(FeedFilter::new().source("hn"));
171 + assert_eq!(fg.count().await.unwrap(), 2);
172 172
173 173 // Source that doesn't exist
174 174 let gen2 = FeedGenerator::new(db).with_filter(FeedFilter::new().source("nonexistent"));
@@ -185,6 +185,6 @@ async fn generator_count_with_unread_filter() {
185 185
186 186 db.items().mark_read(item1.id, true).await.unwrap();
187 187
188 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
189 - assert_eq!(gen.count().await.unwrap(), 2);
188 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
189 + assert_eq!(fg.count().await.unwrap(), 2);
190 190 }
@@ -296,10 +296,10 @@ async fn source_listing_unread_counts_update_after_mark_read() {
296 296 common::insert_item(&db, &feed_rss, "rss:2", "RSS 2", 2).await;
297 297 common::insert_item(&db, &feed_hn, "hn:1", "HN 1", 1).await;
298 298
299 - let gen = FeedGenerator::new(db.clone());
299 + let fg = FeedGenerator::new(db.clone());
300 300
301 301 // Initial state: RSS has 2 unread, HN has 1 unread
302 - let sources = gen.get_sources().await.unwrap();
302 + let sources = fg.get_sources().await.unwrap();
303 303 let rss = sources.iter().find(|s| s.id == "rss").unwrap();
304 304 assert_eq!(rss.unread_count, 2);
305 305 let hn = sources.iter().find(|s| s.id == "hn").unwrap();
@@ -308,7 +308,7 @@ async fn source_listing_unread_counts_update_after_mark_read() {
308 308 // Mark one RSS item read
309 309 db.items().mark_read(rss1.id, true).await.unwrap();
310 310
311 - let sources = gen.get_sources().await.unwrap();
311 + let sources = fg.get_sources().await.unwrap();
312 312 let rss = sources.iter().find(|s| s.id == "rss").unwrap();
313 313 assert_eq!(rss.unread_count, 1);
314 314 assert_eq!(rss.total_count, 2); // total unchanged
@@ -337,8 +337,8 @@ async fn source_health_circuit_broken_overrides_failure_count() {
337 337 assert!(refreshed.circuit_broken);
338 338
339 339 // Replicate the health mapping from sources.rs
340 - let gen = FeedGenerator::new(db.clone());
341 - let sources = gen.get_sources().await.unwrap();
340 + let fg = FeedGenerator::new(db.clone());
341 + let sources = fg.get_sources().await.unwrap();
342 342 let source = &sources[0];
343 343
344 344 // The sources.rs command checks circuit_broken first, before failure count
@@ -372,8 +372,8 @@ async fn source_health_circuit_broken_resets_to_green() {
372 372 // Reset via the repository method (mirrors reset_circuit_breaker command)
373 373 db.feeds().reset_circuit_breaker(feed.id).await.unwrap();
374 374
375 - let gen = FeedGenerator::new(db.clone());
376 - let sources = gen.get_sources().await.unwrap();
375 + let fg = FeedGenerator::new(db.clone());
376 + let sources = fg.get_sources().await.unwrap();
377 377 let source = &sources[0];
378 378
379 379 assert!(!source.circuit_broken);
@@ -409,8 +409,8 @@ async fn source_listing_includes_feed_tags() {
409 409 .await
410 410 .unwrap();
411 411
412 - let gen = FeedGenerator::new(db);
413 - let sources = gen.get_sources().await.unwrap();
412 + let fg = FeedGenerator::new(db);
413 + let sources = fg.get_sources().await.unwrap();
414 414
415 415 let rss = sources.iter().find(|s| s.id == "rss").unwrap();
416 416 assert!(rss.tags.contains(&"tech".to_string()));
@@ -428,8 +428,8 @@ async fn source_listing_feed_with_no_items() {
428 428 common::create_rss_feed(&db, "Empty RSS", "https://example.com/rss").await;
429 429 common::create_other_feed(&db, "hn", "Empty HN").await;
430 430
431 - let gen = FeedGenerator::new(db);
432 - let sources = gen.get_sources().await.unwrap();
431 + let fg = FeedGenerator::new(db);
432 + let sources = fg.get_sources().await.unwrap();
433 433 assert_eq!(sources.len(), 2);
434 434
435 435 for source in &sources {
@@ -115,16 +115,16 @@ async fn list_items_pagination() {
115 115 common::insert_item(&db, &feed, &format!("rss:p{i}"), &format!("Item {i}"), i).await;
116 116 }
117 117
118 - let gen = FeedGenerator::new(db).with_page_size(3);
119 - let page0 = gen.get_items(0).await.unwrap();
118 + let fg = FeedGenerator::new(db).with_page_size(3);
119 + let page0 = fg.get_items(0).await.unwrap();
120 120 assert_eq!(page0.items.len(), 3);
121 121 assert!(page0.has_more);
122 122
123 - let page1 = gen.get_items(1).await.unwrap();
123 + let page1 = fg.get_items(1).await.unwrap();
124 124 assert_eq!(page1.items.len(), 3);
125 125 assert!(page1.has_more);
126 126
127 - let page2 = gen.get_items(2).await.unwrap();
127 + let page2 = fg.get_items(2).await.unwrap();
128 128 assert_eq!(page2.items.len(), 1);
129 129 assert!(!page2.has_more);
130 130 }
@@ -138,8 +138,8 @@ async fn list_items_with_source_filter() {
138 138 common::insert_item(&db, &feed_hn, "hn:1", "HN Article", 1).await;
139 139 common::insert_item(&db, &feed_hn, "hn:2", "HN Article 2", 2).await;
140 140
141 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn"));
142 - let result = gen.get_items(0).await.unwrap();
141 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().source("hn"));
142 + let result = fg.get_items(0).await.unwrap();
143 143 assert_eq!(result.items.len(), 2);
144 144 for item in &result.items {
145 145 assert_eq!(item.id.source, "hn");
@@ -155,8 +155,8 @@ async fn list_items_unread_only() {
155 155
156 156 db.items().mark_read(item1.id, true).await.unwrap();
157 157
158 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
159 - let result = gen.get_items(0).await.unwrap();
158 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().unread_only());
159 + let result = fg.get_items(0).await.unwrap();
160 160 assert_eq!(result.items.len(), 1);
161 161 assert!(!result.items[0].is_read);
162 162 }
@@ -170,8 +170,8 @@ async fn list_items_starred_only() {
170 170
171 171 db.items().mark_starred(item2.id, true).await.unwrap();
172 172
173 - let gen = FeedGenerator::new(db).with_filter(FeedFilter::new().starred_only());
174 - let result = gen.get_items(0).await.unwrap();
173 + let fg = FeedGenerator::new(db).with_filter(FeedFilter::new().starred_only());
174 + let result = fg.get_items(0).await.unwrap();
175 175 assert_eq!(result.items.len(), 1);
176 176 assert!(result.items[0].is_starred);
177 177 }
@@ -307,25 +307,25 @@ async fn list_items_combined_source_unread_starred() {
307 307
308 308 // Filter: source=rss — gets both RSS items (source branch doesn't also
309 309 // filter by unread; only list_search combines source+unread)
310 - let gen = FeedGenerator::new(db.clone())
310 + let fg = FeedGenerator::new(db.clone())
311 311 .with_filter(FeedFilter::new().source("rss"));
312 - let result = gen.get_items(0).await.unwrap();
312 + let result = fg.get_items(0).await.unwrap();
313 313 assert_eq!(result.items.len(), 2);
314 314 for item in &result.items {
315 315 assert_eq!(item.id.source, "rss");
316 316 }
317 317
318 318 // Filter: starred only — should get rss:2 only
319 - let gen2 = FeedGenerator::new(db.clone())
319 + let fg2 = FeedGenerator::new(db.clone())
320 320 .with_filter(FeedFilter::new().starred_only());
321 - let result2 = gen2.get_items(0).await.unwrap();
321 + let result2 = fg2.get_items(0).await.unwrap();
322 322 assert_eq!(result2.items.len(), 1);
323 323 assert!(result2.items[0].is_starred);
324 324
325 325 // Search + source + unread combines all filters in SQL
326 - let gen3 = FeedGenerator::new(db)
326 + let fg3 = FeedGenerator::new(db)
327 327 .with_filter(FeedFilter::new().search("RSS").source("rss").unread_only());
328 - let result3 = gen3.get_items(0).await.unwrap();
328 + let result3 = fg3.get_items(0).await.unwrap();
329 329 assert_eq!(result3.items.len(), 1);
330 330 assert!(!result3.items[0].is_read);
331 331 }
@@ -335,8 +335,8 @@ async fn list_items_combined_source_unread_starred() {
335 335 #[tokio::test]
336 336 async fn list_items_empty_database() {
337 337 let db = common::test_db().await;
338 - let gen = FeedGenerator::new(db);
339 - let result = gen.get_items(0).await.unwrap();
338 + let fg = FeedGenerator::new(db);
339 + let result = fg.get_items(0).await.unwrap();
340 340 assert!(result.items.is_empty());
341 341 assert!(!result.has_more);
342 342 }
@@ -344,8 +344,8 @@ async fn list_items_empty_database() {
344 344 #[tokio::test]
345 345 async fn list_sources_empty_database() {
346 346 let db = common::test_db().await;
347 - let gen = FeedGenerator::new(db);
348 - let sources = gen.get_sources().await.unwrap();
347 + let fg = FeedGenerator::new(db);
348 + let sources = fg.get_sources().await.unwrap();
349 349 assert!(sources.is_empty());
350 350 }
351 351
@@ -354,15 +354,15 @@ async fn unread_count_empty_database() {
354 354 let db = common::test_db().await;
355 355 assert_eq!(db.items().count_unread().await.unwrap(), 0);
356 356
357 - let gen = FeedGenerator::new(db);
358 - assert_eq!(gen.unread_count().await.unwrap(), 0);
357 + let fg = FeedGenerator::new(db);
358 + assert_eq!(fg.unread_count().await.unwrap(), 0);
359 359 }
360 360
361 361 #[tokio::test]
362 362 async fn count_empty_database() {
363 363 let db = common::test_db().await;
364 - let gen = FeedGenerator::new(db);
365 - assert_eq!(gen.count().await.unwrap(), 0);
364 + let fg = FeedGenerator::new(db);
365 + assert_eq!(fg.count().await.unwrap(), 0);
366 366 }
367 367
368 368 // ── Mark Read/Star: Verify Persisted State in Filtered Queries ───────
@@ -375,19 +375,19 @@ async fn mark_read_excludes_from_unread_filter() {
375 375 common::insert_item(&db, &feed, "rss:ex2", "Article 2", 2).await;
376 376 common::insert_item(&db, &feed, "rss:ex3", "Article 3", 3).await;
377 377
378 - let gen = FeedGenerator::new(db.clone())
378 + let fg = FeedGenerator::new(db.clone())
379 379 .with_filter(FeedFilter::new().unread_only());
380 380
381 381 // All 3 visible
382 - assert_eq!(gen.get_items(0).await.unwrap().items.len(), 3);
382 + assert_eq!(fg.get_items(0).await.unwrap().items.len(), 3);
383 383
384 384 // Mark one read
385 385 db.items().mark_read(item1.id, true).await.unwrap();
386 386
387 387 // Only 2 visible now
388 - assert_eq!(gen.get_items(0).await.unwrap().items.len(), 2);
388 + assert_eq!(fg.get_items(0).await.unwrap().items.len(), 2);
389 389 // None of the returned items should be the read one
390 - let ids: Vec<String> = gen
390 + let ids: Vec<String> = fg
391 391 .get_items(0)
392 392 .await
393 393 .unwrap()
@@ -406,23 +406,23 @@ async fn star_item_appears_in_starred_filter() {
406 406 let item2 = common::insert_item(&db, &feed, "rss:st2", "Article 2", 2).await;
407 407 common::insert_item(&db, &feed, "rss:st3", "Article 3", 3).await;
408 408
409 - let gen_starred = FeedGenerator::new(db.clone())
409 + let fg_starred = FeedGenerator::new(db.clone())
410 410 .with_filter(FeedFilter::new().starred_only());
411 411
412 412 // Nothing starred yet
413 - assert!(gen_starred.get_items(0).await.unwrap().items.is_empty());
413 + assert!(fg_starred.get_items(0).await.unwrap().items.is_empty());
414 414
415 415 // Star one item
416 416 db.items().mark_starred(item2.id, true).await.unwrap();
417 417
418 - let result = gen_starred.get_items(0).await.unwrap();
418 + let result = fg_starred.get_items(0).await.unwrap();
419 419 assert_eq!(result.items.len(), 1);
420 420 assert_eq!(result.items[0].id.item_id, "rss:st2");
421 421 assert!(result.items[0].is_starred);
422 422
423 423 // Unstar it
424 424 db.items().mark_starred(item2.id, false).await.unwrap();
425 - assert!(gen_starred.get_items(0).await.unwrap().items.is_empty());
425 + assert!(fg_starred.get_items(0).await.unwrap().items.is_empty());
426 426 }
427 427
428 428 // ── Upsert Preserves User State ──────────────────────────────────────
@@ -92,8 +92,8 @@ async fn orchestrator_source_listing() {
92 92 common::insert_item(db, &feed_hn, "hn:s2", "HN Item 2", 2).await;
93 93
94 94 // Replicate list_sources command logic
95 - let gen = FeedGenerator::new(db.clone());
96 - let sources = gen.get_sources().await.unwrap();
95 + let fg = FeedGenerator::new(db.clone());
96 + let sources = fg.get_sources().await.unwrap();
97 97 assert_eq!(sources.len(), 2);
98 98
99 99 let hn = sources.iter().find(|s| s.id == "hn").unwrap();
@@ -113,8 +113,8 @@ async fn source_health_green_on_no_failures() {
113 113 let feed = common::create_rss_feed(&db, "Feed", "https://example.com/rss").await;
114 114 common::insert_item(&db, &feed, "rss:1", "Item", 1).await;
115 115
116 - let gen = FeedGenerator::new(db);
117 - let sources = gen.get_sources().await.unwrap();
116 + let fg = FeedGenerator::new(db);
117 + let sources = fg.get_sources().await.unwrap();
118 118 assert_eq!(sources[0].consecutive_failures, 0);
119 119
120 120 // Replicate health mapping from sources.rs command
@@ -136,8 +136,8 @@ async fn source_health_yellow_on_few_failures() {
136 136 .await
137 137 .unwrap();
138 138
139 - let gen = FeedGenerator::new(db);
140 - let sources = gen.get_sources().await.unwrap();
139 + let fg = FeedGenerator::new(db);
140 + let sources = fg.get_sources().await.unwrap();
141 141 let health = match sources[0].consecutive_failures {
142 142 0 => "green",
143 143 1..=2 => "yellow",
@@ -158,8 +158,8 @@ async fn source_health_red_on_many_failures() {
158 158 .unwrap();
159 159 }
160 160
161 - let gen = FeedGenerator::new(db);
162 - let sources = gen.get_sources().await.unwrap();
161 + let fg = FeedGenerator::new(db);
162 + let sources = fg.get_sources().await.unwrap();
163 163 let health = match sources[0].consecutive_failures {
164 164 0 => "green",
165 165 1..=2 => "yellow",
@@ -191,8 +191,8 @@ async fn source_health_resets_after_success() {
191 191 .await
192 192 .unwrap();
193 193
194 - let gen = FeedGenerator::new(db);
195 - let sources = gen.get_sources().await.unwrap();
194 + let fg = FeedGenerator::new(db);
195 + let sources = fg.get_sources().await.unwrap();
196 196 assert_eq!(sources[0].consecutive_failures, 0);
197 197 assert!(sources[0].last_error.is_none());
198 198 }