Homebrew RSS reader server
0
fork

Configure Feed

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

at main 683 lines 22 kB view raw
1use anyhow::{Context, Result}; 2use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; 3use sqlx::SqlitePool; 4use std::collections::HashMap; 5use std::str::FromStr; 6 7use crate::config::Config; 8 9pub async fn init_pool(db_path: &str) -> Result<SqlitePool> { 10 let options = SqliteConnectOptions::from_str(&format!("sqlite:{db_path}"))? 11 .create_if_missing(true) 12 .journal_mode(SqliteJournalMode::Wal) 13 .foreign_keys(true); 14 15 let pool = SqlitePoolOptions::new() 16 .max_connections(5) 17 .connect_with(options) 18 .await 19 .context("connecting to database")?; 20 21 sqlx::migrate!("./migrations") 22 .run(&pool) 23 .await 24 .context("running migrations")?; 25 26 Ok(pool) 27} 28 29/// One-time legacy bootstrap: if the DB has zero categories/feeds and the config 30/// contains legacy [[groups]]/[[feeds]] sections, import them. Never deletes. 31pub async fn bootstrap_from_legacy_config_if_empty(pool: &SqlitePool, config: &Config) -> Result<()> { 32 if config.groups.is_empty() && config.feeds.is_empty() { 33 return Ok(()); 34 } 35 36 let (group_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM groups") 37 .fetch_one(pool) 38 .await?; 39 let (feed_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM feeds") 40 .fetch_one(pool) 41 .await?; 42 43 if group_count > 0 || feed_count > 0 { 44 tracing::info!("DB already has data, skipping legacy config bootstrap"); 45 return Ok(()); 46 } 47 48 tracing::info!("DB empty, bootstrapping from legacy config sections"); 49 50 for group in &config.groups { 51 sqlx::query("INSERT OR IGNORE INTO groups (name) VALUES (?)") 52 .bind(&group.name) 53 .execute(pool) 54 .await?; 55 } 56 57 for feed in &config.feeds { 58 let group_id: (i64,) = sqlx::query_as("SELECT id FROM groups WHERE name = ?") 59 .bind(&feed.group) 60 .fetch_one(pool) 61 .await?; 62 63 sqlx::query("INSERT OR IGNORE INTO feeds (url, group_id) VALUES (?, ?)") 64 .bind(&feed.url) 65 .bind(group_id.0) 66 .execute(pool) 67 .await?; 68 } 69 70 Ok(()) 71} 72 73// --------------------------------------------------------------------------- 74// Categories (groups table) 75// --------------------------------------------------------------------------- 76 77#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] 78pub struct Category { 79 pub id: i64, 80 #[sqlx(rename = "name")] 81 pub title: String, 82} 83 84#[derive(Debug, serde::Serialize)] 85pub struct CategoryWithCounts { 86 pub id: i64, 87 pub title: String, 88 pub feed_count: i64, 89 pub total_unread: i64, 90} 91 92pub async fn get_categories(pool: &SqlitePool) -> Result<Vec<Category>> { 93 let rows = sqlx::query_as::<_, Category>("SELECT id, name FROM groups ORDER BY name") 94 .fetch_all(pool) 95 .await?; 96 Ok(rows) 97} 98 99pub async fn get_categories_with_counts(pool: &SqlitePool) -> Result<Vec<CategoryWithCounts>> { 100 let rows: Vec<(i64, String, i64, i64)> = sqlx::query_as( 101 "SELECT g.id, g.name, \ 102 (SELECT COUNT(*) FROM feeds WHERE group_id = g.id) AS feed_count, \ 103 (SELECT COUNT(*) FROM items i JOIN feeds f ON i.feed_id = f.id WHERE f.group_id = g.id AND i.is_read = 0) AS total_unread \ 104 FROM groups g ORDER BY g.name", 105 ) 106 .fetch_all(pool) 107 .await?; 108 Ok(rows 109 .into_iter() 110 .map(|(id, title, feed_count, total_unread)| CategoryWithCounts { 111 id, 112 title, 113 feed_count, 114 total_unread, 115 }) 116 .collect()) 117} 118 119pub async fn get_category(pool: &SqlitePool, id: i64) -> Result<Option<Category>> { 120 let row = sqlx::query_as::<_, Category>("SELECT id, name FROM groups WHERE id = ?") 121 .bind(id) 122 .fetch_optional(pool) 123 .await?; 124 Ok(row) 125} 126 127pub async fn create_category(pool: &SqlitePool, title: &str) -> Result<Category> { 128 let result = sqlx::query("INSERT INTO groups (name) VALUES (?)") 129 .bind(title) 130 .execute(pool) 131 .await?; 132 Ok(Category { 133 id: result.last_insert_rowid(), 134 title: title.to_string(), 135 }) 136} 137 138pub async fn update_category(pool: &SqlitePool, id: i64, title: &str) -> Result<bool> { 139 let result = sqlx::query("UPDATE groups SET name = ? WHERE id = ?") 140 .bind(title) 141 .bind(id) 142 .execute(pool) 143 .await?; 144 Ok(result.rows_affected() > 0) 145} 146 147pub async fn delete_category(pool: &SqlitePool, id: i64) -> Result<bool> { 148 let result = sqlx::query("DELETE FROM groups WHERE id = ?") 149 .bind(id) 150 .execute(pool) 151 .await?; 152 Ok(result.rows_affected() > 0) 153} 154 155pub async fn mark_all_category_entries_read(pool: &SqlitePool, category_id: i64) -> Result<()> { 156 sqlx::query( 157 "UPDATE items SET is_read = 1, changed_at = unixepoch() \ 158 WHERE is_read = 0 AND feed_id IN (SELECT id FROM feeds WHERE group_id = ?)", 159 ) 160 .bind(category_id) 161 .execute(pool) 162 .await?; 163 Ok(()) 164} 165 166// --------------------------------------------------------------------------- 167// Feeds 168// --------------------------------------------------------------------------- 169 170#[allow(dead_code)] 171#[derive(Debug, Clone, sqlx::FromRow)] 172pub struct FeedRow { 173 pub id: i64, 174 pub url: String, 175 pub title: String, 176 pub site_url: String, 177 pub favicon_id: Option<i64>, 178 pub group_id: i64, 179 pub last_fetched_at: Option<i64>, 180} 181 182/// Full feed info for API responses. 183#[allow(dead_code)] 184#[derive(Debug, Clone, sqlx::FromRow)] 185pub struct FeedWithCategory { 186 pub id: i64, 187 pub url: String, 188 pub title: String, 189 pub site_url: String, 190 pub favicon_id: Option<i64>, 191 pub group_id: i64, 192 pub last_fetched_at: Option<i64>, 193 pub category_id: i64, 194 pub category_title: String, 195} 196 197pub async fn get_feeds_with_categories(pool: &SqlitePool) -> Result<Vec<FeedWithCategory>> { 198 let rows = sqlx::query_as::<_, FeedWithCategory>( 199 "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \ 200 g.id AS category_id, g.name AS category_title \ 201 FROM feeds f JOIN groups g ON f.group_id = g.id ORDER BY f.title", 202 ) 203 .fetch_all(pool) 204 .await?; 205 Ok(rows) 206} 207 208pub async fn get_feed_with_category(pool: &SqlitePool, id: i64) -> Result<Option<FeedWithCategory>> { 209 let row = sqlx::query_as::<_, FeedWithCategory>( 210 "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \ 211 g.id AS category_id, g.name AS category_title \ 212 FROM feeds f JOIN groups g ON f.group_id = g.id WHERE f.id = ?", 213 ) 214 .bind(id) 215 .fetch_optional(pool) 216 .await?; 217 Ok(row) 218} 219 220pub async fn get_category_feeds(pool: &SqlitePool, category_id: i64) -> Result<Vec<FeedWithCategory>> { 221 let rows = sqlx::query_as::<_, FeedWithCategory>( 222 "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \ 223 g.id AS category_id, g.name AS category_title \ 224 FROM feeds f JOIN groups g ON f.group_id = g.id WHERE f.group_id = ? ORDER BY f.title", 225 ) 226 .bind(category_id) 227 .fetch_all(pool) 228 .await?; 229 Ok(rows) 230} 231 232pub async fn create_feed(pool: &SqlitePool, feed_url: &str, category_id: i64) -> Result<i64> { 233 let result = sqlx::query("INSERT INTO feeds (url, group_id) VALUES (?, ?)") 234 .bind(feed_url) 235 .bind(category_id) 236 .execute(pool) 237 .await?; 238 Ok(result.last_insert_rowid()) 239} 240 241pub async fn update_feed( 242 pool: &SqlitePool, 243 id: i64, 244 title: Option<&str>, 245 feed_url: Option<&str>, 246 site_url: Option<&str>, 247 category_id: Option<i64>, 248) -> Result<bool> { 249 // Build SET clauses dynamically 250 let mut sets = Vec::new(); 251 if title.is_some() { 252 sets.push("title = ?"); 253 } 254 if feed_url.is_some() { 255 sets.push("url = ?"); 256 } 257 if site_url.is_some() { 258 sets.push("site_url = ?"); 259 } 260 if category_id.is_some() { 261 sets.push("group_id = ?"); 262 } 263 if sets.is_empty() { 264 return Ok(false); 265 } 266 let sql = format!("UPDATE feeds SET {} WHERE id = ?", sets.join(", ")); 267 let mut query = sqlx::query(&sql); 268 if let Some(v) = title { 269 query = query.bind(v); 270 } 271 if let Some(v) = feed_url { 272 query = query.bind(v); 273 } 274 if let Some(v) = site_url { 275 query = query.bind(v); 276 } 277 if let Some(v) = category_id { 278 query = query.bind(v); 279 } 280 query = query.bind(id); 281 let result = query.execute(pool).await?; 282 Ok(result.rows_affected() > 0) 283} 284 285pub async fn delete_feed(pool: &SqlitePool, id: i64) -> Result<bool> { 286 let result = sqlx::query("DELETE FROM feeds WHERE id = ?") 287 .bind(id) 288 .execute(pool) 289 .await?; 290 Ok(result.rows_affected() > 0) 291} 292 293pub async fn mark_all_feed_entries_read(pool: &SqlitePool, feed_id: i64) -> Result<()> { 294 sqlx::query( 295 "UPDATE items SET is_read = 1, changed_at = unixepoch() WHERE is_read = 0 AND feed_id = ?", 296 ) 297 .bind(feed_id) 298 .execute(pool) 299 .await?; 300 Ok(()) 301} 302 303pub async fn get_feed_counters(pool: &SqlitePool) -> Result<(HashMap<i64, i64>, HashMap<i64, i64>)> { 304 let rows: Vec<(i64, i64, i64)> = sqlx::query_as( 305 "SELECT f.id, \ 306 COALESCE(SUM(CASE WHEN i.is_read = 1 THEN 1 ELSE 0 END), 0), \ 307 COALESCE(SUM(CASE WHEN i.is_read = 0 THEN 1 ELSE 0 END), 0) \ 308 FROM feeds f LEFT JOIN items i ON i.feed_id = f.id GROUP BY f.id", 309 ) 310 .fetch_all(pool) 311 .await?; 312 313 let mut reads = HashMap::new(); 314 let mut unreads = HashMap::new(); 315 for (feed_id, read_count, unread_count) in rows { 316 reads.insert(feed_id, read_count); 317 unreads.insert(feed_id, unread_count); 318 } 319 Ok((reads, unreads)) 320} 321 322// --------------------------------------------------------------------------- 323// Entries (items table) 324// --------------------------------------------------------------------------- 325 326#[derive(Debug, Clone, sqlx::FromRow)] 327pub struct EntryRow { 328 pub id: i64, 329 pub feed_id: i64, 330 pub title: String, 331 pub author: String, 332 pub content: String, 333 pub url: String, 334 pub is_read: i32, 335 pub is_starred: i32, 336 pub published_at: Option<i64>, 337 pub created_at: i64, 338 pub changed_at: i64, 339 // join fields 340 pub feed_title: String, 341 pub feed_url: String, 342 pub feed_site_url: String, 343 pub category_id: i64, 344 pub category_title: String, 345} 346 347/// Filter/pagination parameters for entry queries. 348#[derive(Debug, Default)] 349pub struct EntryFilter { 350 pub status: Option<String>, // "read", "unread", "removed" 351 pub feed_id: Option<i64>, 352 pub category_id: Option<i64>, 353 pub starred: Option<bool>, 354 pub search: Option<String>, 355 pub after: Option<i64>, // unix timestamp (changed_at) 356 pub after_entry_id: Option<i64>, 357 pub before: Option<i64>, // unix timestamp (changed_at) 358 pub before_entry_id: Option<i64>, 359 pub limit: Option<i64>, 360 pub offset: Option<i64>, 361 pub order: Option<String>, // "id", "status", "published_at", "created_at", "category_title", "category_id" 362 pub direction: Option<String>, // "asc", "desc" 363} 364 365pub async fn get_entry(pool: &SqlitePool, id: i64) -> Result<Option<EntryRow>> { 366 let row = sqlx::query_as::<_, EntryRow>( 367 "SELECT i.id, i.feed_id, i.title, i.author, i.content, i.url, i.is_read, i.is_starred, \ 368 i.published_at, i.created_at, i.changed_at, \ 369 f.title AS feed_title, f.url AS feed_url, f.site_url AS feed_site_url, \ 370 g.id AS category_id, g.name AS category_title \ 371 FROM items i \ 372 JOIN feeds f ON i.feed_id = f.id \ 373 JOIN groups g ON f.group_id = g.id \ 374 WHERE i.id = ?", 375 ) 376 .bind(id) 377 .fetch_optional(pool) 378 .await?; 379 Ok(row) 380} 381 382pub async fn get_entries_filtered(pool: &SqlitePool, filter: &EntryFilter) -> Result<(i64, Vec<EntryRow>)> { 383 let base_from = "FROM items i \ 384 JOIN feeds f ON i.feed_id = f.id \ 385 JOIN groups g ON f.group_id = g.id"; 386 387 let mut where_parts: Vec<String> = Vec::new(); 388 389 if let Some(ref status) = filter.status { 390 match status.as_str() { 391 "read" => where_parts.push("i.is_read = 1".to_string()), 392 "unread" => where_parts.push("i.is_read = 0".to_string()), 393 _ => {} // "removed" not supported, ignore 394 } 395 } 396 if let Some(feed_id) = filter.feed_id { 397 where_parts.push(format!("i.feed_id = {feed_id}")); 398 } 399 if let Some(category_id) = filter.category_id { 400 where_parts.push(format!("f.group_id = {category_id}")); 401 } 402 if let Some(starred) = filter.starred { 403 where_parts.push(format!("i.is_starred = {}", if starred { 1 } else { 0 })); 404 } 405 if let Some(after) = filter.after { 406 where_parts.push(format!("i.changed_at > {after}")); 407 } 408 if let Some(id) = filter.after_entry_id { 409 where_parts.push(format!("i.id > {id}")); 410 } 411 if let Some(before) = filter.before { 412 where_parts.push(format!("i.changed_at < {before}")); 413 } 414 if let Some(id) = filter.before_entry_id { 415 where_parts.push(format!("i.id < {id}")); 416 } 417 418 let where_clause = if where_parts.is_empty() { 419 String::new() 420 } else { 421 format!(" WHERE {}", where_parts.join(" AND ")) 422 }; 423 424 // Count query 425 let count_sql = format!("SELECT COUNT(*) {base_from}{where_clause}"); 426 let total: (i64,) = if let Some(ref search) = filter.search { 427 let search_clause = if where_clause.is_empty() { 428 " WHERE (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')" 429 } else { 430 " AND (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')" 431 }; 432 let sql = format!("SELECT COUNT(*) {base_from}{where_clause}{search_clause}"); 433 sqlx::query_as(&sql) 434 .bind(search) 435 .bind(search) 436 .fetch_one(pool) 437 .await? 438 } else { 439 sqlx::query_as(&count_sql).fetch_one(pool).await? 440 }; 441 442 // Order 443 let order_col = match filter.order.as_deref() { 444 Some("status") => "i.is_read", 445 Some("published_at") => "COALESCE(i.published_at, i.created_at)", 446 Some("created_at") => "i.created_at", 447 Some("category_title") => "category_title", 448 Some("category_id") => "g.id", 449 _ => "i.id", 450 }; 451 let direction = match filter.direction.as_deref() { 452 Some("asc") => "ASC", 453 _ => "DESC", 454 }; 455 456 let limit = filter.limit.unwrap_or(100).min(500).max(1); 457 let offset = filter.offset.unwrap_or(0).max(0); 458 459 let select = "SELECT i.id, i.feed_id, i.title, i.author, i.content, i.url, i.is_read, i.is_starred, \ 460 i.published_at, i.created_at, i.changed_at, \ 461 f.title AS feed_title, f.url AS feed_url, f.site_url AS feed_site_url, \ 462 g.id AS category_id, g.name AS category_title"; 463 464 let entries_sql = if let Some(ref _search) = filter.search { 465 let search_clause = if where_clause.is_empty() { 466 " WHERE (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')" 467 } else { 468 " AND (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')" 469 }; 470 format!( 471 "{select} {base_from}{where_clause}{search_clause} ORDER BY {order_col} {direction} LIMIT {limit} OFFSET {offset}" 472 ) 473 } else { 474 format!( 475 "{select} {base_from}{where_clause} ORDER BY {order_col} {direction} LIMIT {limit} OFFSET {offset}" 476 ) 477 }; 478 479 let entries: Vec<EntryRow> = if let Some(ref search) = filter.search { 480 sqlx::query_as::<_, EntryRow>(&entries_sql) 481 .bind(search) 482 .bind(search) 483 .fetch_all(pool) 484 .await? 485 } else { 486 sqlx::query_as::<_, EntryRow>(&entries_sql) 487 .fetch_all(pool) 488 .await? 489 }; 490 491 Ok((total.0, entries)) 492} 493 494pub async fn update_entries_status(pool: &SqlitePool, entry_ids: &[i64], status: &str) -> Result<()> { 495 if entry_ids.is_empty() { 496 return Ok(()); 497 } 498 let is_read = match status { 499 "read" => 1, 500 "unread" => 0, 501 _ => return Ok(()), 502 }; 503 let placeholders: Vec<String> = entry_ids.iter().map(|_| "?".to_string()).collect(); 504 let sql = format!( 505 "UPDATE items SET is_read = {is_read}, changed_at = unixepoch() WHERE id IN ({})", 506 placeholders.join(",") 507 ); 508 let mut query = sqlx::query(&sql); 509 for id in entry_ids { 510 query = query.bind(id); 511 } 512 query.execute(pool).await?; 513 Ok(()) 514} 515 516pub async fn toggle_entry_starred(pool: &SqlitePool, id: i64) -> Result<bool> { 517 // Returns the new starred state 518 let result = sqlx::query( 519 "UPDATE items SET is_starred = 1 - is_starred, changed_at = unixepoch() WHERE id = ?", 520 ) 521 .bind(id) 522 .execute(pool) 523 .await?; 524 if result.rows_affected() == 0 { 525 return Ok(false); 526 } 527 // Fetch new state 528 let row: Option<(i32,)> = sqlx::query_as("SELECT is_starred FROM items WHERE id = ?") 529 .bind(id) 530 .fetch_optional(pool) 531 .await?; 532 Ok(row.map(|r| r.0 == 1).unwrap_or(false)) 533} 534 535// --------------------------------------------------------------------------- 536// Icons (favicons table) 537// --------------------------------------------------------------------------- 538 539#[derive(Debug, sqlx::FromRow)] 540pub struct IconRow { 541 pub id: i64, 542 pub data: String, 543} 544 545pub async fn get_icon_by_id(pool: &SqlitePool, id: i64) -> Result<Option<IconRow>> { 546 let row = sqlx::query_as::<_, IconRow>("SELECT id, data FROM favicons WHERE id = ?") 547 .bind(id) 548 .fetch_optional(pool) 549 .await?; 550 Ok(row) 551} 552 553pub async fn get_icon_by_feed_id(pool: &SqlitePool, feed_id: i64) -> Result<Option<IconRow>> { 554 let row = sqlx::query_as::<_, IconRow>( 555 "SELECT fav.id, fav.data FROM favicons fav \ 556 JOIN feeds f ON f.favicon_id = fav.id WHERE f.id = ?", 557 ) 558 .bind(feed_id) 559 .fetch_optional(pool) 560 .await?; 561 Ok(row) 562} 563 564// --------------------------------------------------------------------------- 565// Fetcher queries (kept from original) 566// --------------------------------------------------------------------------- 567 568#[derive(sqlx::FromRow)] 569pub struct FetcherFeed { 570 pub id: i64, 571 pub url: String, 572 pub favicon_id: Option<i64>, 573} 574 575pub async fn get_all_feeds(pool: &SqlitePool) -> Result<Vec<FetcherFeed>> { 576 let feeds = sqlx::query_as::<_, FetcherFeed>("SELECT id, url, favicon_id FROM feeds") 577 .fetch_all(pool) 578 .await?; 579 Ok(feeds) 580} 581 582pub async fn get_single_feed_for_fetch(pool: &SqlitePool, feed_id: i64) -> Result<Option<FetcherFeed>> { 583 let feed = sqlx::query_as::<_, FetcherFeed>("SELECT id, url, favicon_id FROM feeds WHERE id = ?") 584 .bind(feed_id) 585 .fetch_optional(pool) 586 .await?; 587 Ok(feed) 588} 589 590pub async fn upsert_feed_metadata( 591 pool: &SqlitePool, 592 feed_id: i64, 593 title: &str, 594 site_url: &str, 595) -> Result<()> { 596 sqlx::query( 597 "UPDATE feeds SET title = ?, site_url = ?, last_fetched_at = unixepoch() WHERE id = ?", 598 ) 599 .bind(title) 600 .bind(site_url) 601 .bind(feed_id) 602 .execute(pool) 603 .await?; 604 Ok(()) 605} 606 607pub async fn insert_favicon(pool: &SqlitePool, data: &str) -> Result<i64> { 608 let result = sqlx::query("INSERT INTO favicons (data) VALUES (?)") 609 .bind(data) 610 .execute(pool) 611 .await?; 612 Ok(result.last_insert_rowid()) 613} 614 615pub async fn set_feed_favicon(pool: &SqlitePool, feed_id: i64, favicon_id: i64) -> Result<()> { 616 sqlx::query("UPDATE feeds SET favicon_id = ? WHERE id = ?") 617 .bind(favicon_id) 618 .bind(feed_id) 619 .execute(pool) 620 .await?; 621 Ok(()) 622} 623 624#[allow(clippy::too_many_arguments)] 625pub async fn insert_item( 626 pool: &SqlitePool, 627 feed_id: i64, 628 guid: &str, 629 title: &str, 630 author: &str, 631 url: &str, 632 content: &str, 633 published_at: Option<i64>, 634) -> Result<()> { 635 sqlx::query( 636 "INSERT OR IGNORE INTO items (feed_id, guid, title, author, url, content, published_at) \ 637 VALUES (?, ?, ?, ?, ?, ?, ?)", 638 ) 639 .bind(feed_id) 640 .bind(guid) 641 .bind(title) 642 .bind(author) 643 .bind(url) 644 .bind(content) 645 .bind(published_at) 646 .execute(pool) 647 .await?; 648 Ok(()) 649} 650 651// --------------------------------------------------------------------------- 652// OPML export 653// --------------------------------------------------------------------------- 654 655#[derive(Debug, sqlx::FromRow)] 656pub struct FeedExportRow { 657 pub feed_title: String, 658 pub feed_url: String, 659 pub site_url: String, 660 pub category_name: String, 661} 662 663pub async fn get_feeds_for_export(pool: &SqlitePool) -> Result<Vec<FeedExportRow>> { 664 let rows = sqlx::query_as::<_, FeedExportRow>( 665 "SELECT f.title AS feed_title, f.url AS feed_url, f.site_url, g.name AS category_name \ 666 FROM feeds f JOIN groups g ON f.group_id = g.id ORDER BY g.name, f.title", 667 ) 668 .fetch_all(pool) 669 .await?; 670 Ok(rows) 671} 672 673// --------------------------------------------------------------------------- 674// Feed existence check 675// --------------------------------------------------------------------------- 676 677pub async fn feed_exists(pool: &SqlitePool, id: i64) -> Result<bool> { 678 let row: Option<(i64,)> = sqlx::query_as("SELECT id FROM feeds WHERE id = ?") 679 .bind(id) 680 .fetch_optional(pool) 681 .await?; 682 Ok(row.is_some()) 683}