//! Feed item commands (list, get, read/star, download) use crate::commands::error::ApiError; use crate::state::AppState; use bb_db::{parse_timestamp, ItemId, QueryFeedId}; use bb_feed::{FeedFilter, FeedGenerator, OrderBy}; use bb_interface::FeedItem; use serde::{Deserialize, Serialize}; use std::io::Read; use std::sync::Arc; use tauri::State; use tracing::instrument; /// A plugin-declared custom action button for the detail view. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ItemActionResponse { pub label: String, pub action_type: String, pub url: String, } /// Compact item representation for the feed list view. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ItemSummaryResponse { /// Internal UUID (database primary key). pub id: String, /// Busser-scoped unique key (format: `busser_id:item_id`). pub external_id: String, /// Busser/plugin identifier that produced this item. pub source_id: String, /// Human-readable source name shown in the item header. pub source_name: String, /// Author attribution displayed in the compact bite view. pub author: String, /// Primary text line in the bite view (usually a headline or summary). pub text: String, /// Optional secondary line beneath the primary text (e.g. score, reply count). pub secondary: Option, /// Optional status badge or emoji shown next to the item (e.g. category icon). pub indicator: Option, /// Full article/post title, if distinct from `text`. pub title: Option, /// Canonical URL to the original content. pub url: Option, /// UTC publication timestamp formatted as `YYYY-MM-DD HH:MM:SS`. pub published_at: String, /// Human-readable relative time string (e.g. "5m ago", "2d ago"). pub time_ago: String, /// Plugin-provided relevance or popularity score (e.g. HN points). pub score: Option, /// Whether the user has viewed this item. pub is_read: bool, /// Whether the user has starred/favourited this item. pub is_starred: bool, } /// Full item representation for the detail/reading view. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ItemDetailResponse { /// Internal UUID (database primary key). pub id: String, /// Busser-scoped unique key (format: `busser_id:item_id`). pub external_id: String, /// Busser/plugin identifier that produced this item. pub source_id: String, /// Human-readable source name shown in the detail header. pub source_name: String, /// Author attribution displayed in the detail view. pub author: String, /// Primary text line from the bite view (headline or summary). pub text: String, /// Optional secondary line (e.g. score, reply count) from the bite view. pub secondary: Option, /// Optional status badge or emoji (e.g. category icon) from the bite view. pub indicator: Option, /// Full article/post title. pub title: Option, /// Full HTML or plain-text body content for the reading view. pub body: Option, /// Canonical URL to the original content. pub url: Option, /// URLs of attached media (images, audio, video). pub media: Vec, /// UTC publication timestamp formatted as `YYYY-MM-DD HH:MM:SS`. pub published_at: String, /// Human-readable relative time string (e.g. "5m ago", "2d ago"). pub time_ago: String, /// UTC timestamp of when this item was last fetched from the source. pub fetched_at: String, /// Plugin-provided relevance or popularity score (e.g. HN points). pub score: Option, /// Tags or categories assigned by the source plugin. pub tags: Vec, /// Plugin-declared custom action buttons. pub actions: Vec, /// Whether the user has viewed this item. pub is_read: bool, /// Whether the user has starred/favourited this item. pub is_starred: bool, } /// Paginated list of item summaries. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ItemsListResponse { pub items: Vec, pub page: i64, pub has_more: bool, } /// Frontend-provided filter and pagination parameters. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ItemsFilter { pub source: Option, pub unread: Option, pub starred: Option, pub search: Option, pub order: Option, pub page: Option, pub tag: Option, /// When set, apply the saved query feed's conditions instead of individual filters. pub query_feed_id: Option, } /// Convert a `FeedItem` (from `FeedGenerator`) to a compact summary response. /// /// Takes ownership of the `FeedItem` to move fields instead of cloning. fn feed_item_to_summary(item: FeedItem) -> ItemSummaryResponse { let published_at = match chrono::DateTime::from_timestamp(item.meta.published_at, 0) { Some(dt) => dt.format(bb_db::TIMESTAMP_FMT).to_string(), None => { tracing::warn!( timestamp = item.meta.published_at, item_id = ?item.id.item_id, "Invalid published_at timestamp, falling back to epoch" ); chrono::DateTime::UNIX_EPOCH .format(bb_db::TIMESTAMP_FMT) .to_string() } }; let id = match item.db_id { Some(id) => id, None => { tracing::warn!( item_id = ?item.id.item_id, "FeedItem has no db_id, using empty string as ID" ); String::new() } }; let time_ago = format_time_ago(&published_at); ItemSummaryResponse { id, external_id: item.id.item_id, source_id: item.id.source, source_name: item.meta.source_name, author: item.bite.author, text: item.bite.text, secondary: item.bite.secondary, indicator: item.bite.indicator, title: item.content.title, url: item.content.url, published_at, time_ago, score: item.meta.score, is_read: item.is_read, is_starred: item.is_starred, } } /// Convert a database feed item to a full detail response. fn item_to_detail(item: &bb_db::DbFeedItem) -> ItemDetailResponse { ItemDetailResponse { id: item.id.to_string(), external_id: item.external_id.clone(), source_id: item.busser_id.to_string(), source_name: item.source_name.clone(), author: item.bite_author.clone(), text: item.bite_text.clone(), secondary: item.bite_secondary.clone(), indicator: item.bite_indicator.clone(), title: item.title.clone(), body: item.body.clone(), url: item.url.clone(), media: item.media_vec(), published_at: item.published_at.clone(), time_ago: format_time_ago(&item.published_at), fetched_at: item.fetched_at.clone(), score: item.score, tags: item.tags_vec(), actions: item.actions_vec().into_iter().map(|a| ItemActionResponse { label: a.label, action_type: a.action_type, url: a.url, }).collect(), is_read: item.is_read, is_starred: item.is_starred, } } /// Format a timestamp as a human-readable relative time string (e.g. "5m ago"). fn format_time_ago(timestamp: &str) -> String { let dt = parse_timestamp(timestamp); let now = chrono::Utc::now(); let diff = now.signed_duration_since(dt); if diff.num_seconds() < 60 { "just now".to_string() } else if diff.num_minutes() < 60 { format!("{}m ago", diff.num_minutes()) } else if diff.num_hours() < 24 { format!("{}h ago", diff.num_hours()) } else if diff.num_days() < 7 { format!("{}d ago", diff.num_days()) } else { dt.format("%b %d, %Y").to_string() } } /// List feed items with filtering, ordering, and pagination. /// /// Delegates to [`FeedGenerator::get_items`] so the filter -> sort -> paginate /// pipeline is defined in one place (the `bb-feed` crate). #[tauri::command] #[instrument(skip_all)] pub async fn list_items( state: State<'_, Arc>, filter: ItemsFilter, ) -> Result { let db = state.orchestrator.database().clone(); let page = filter.page.unwrap_or(0); // Build a FeedFilter — either from a saved query feed or individual params. let feed_filter = if let Some(ref qf_id) = filter.query_feed_id { let id: QueryFeedId = qf_id .parse() .map_err(|_| ApiError::bad_request("Invalid query feed ID"))?; let qf = db .query_feeds() .get(id) .await? .ok_or_else(|| ApiError::not_found(format!("Query feed {} not found", qf_id)))?; FeedFilter::from_conditions(qf.rules_vec()) } else { let mut f = FeedFilter::new(); if let Some(ref source) = filter.source { f = f.source(source); } if filter.unread == Some(true) { f = f.unread_only(); } if filter.starred == Some(true) { f = f.starred_only(); } if let Some(ref search) = filter.search { f = f.search(search); } if let Some(ref tag) = filter.tag { f = f.with_feed_tag(tag); } f }; let order = filter .order .as_deref() .map(OrderBy::from_str_loose) .unwrap_or_default(); let generator = FeedGenerator::new(db) .with_filter(feed_filter) .with_order(order); let result = generator.get_items(page).await?; let summaries: Vec = result.items.into_iter().map(feed_item_to_summary).collect(); Ok(ItemsListResponse { items: summaries, page, has_more: result.has_more, }) } /// Get the full detail view for a single item by UUID. #[tauri::command] #[instrument(skip_all)] pub async fn get_item( state: State<'_, Arc>, id: String, ) -> Result { let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?; let db = state.orchestrator.database(); let item = db .items() .get(item_id) .await? .ok_or_else(|| ApiError::not_found(format!("Item {} not found", id)))?; Ok(item_to_detail(&item)) } /// Mark an item as read. #[tauri::command] #[instrument(skip_all)] pub async fn mark_item_read( state: State<'_, Arc>, id: String, ) -> Result<(), ApiError> { let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?; Ok(state.orchestrator.database().items().mark_read(item_id, true).await?) } /// Mark an item as unread. #[tauri::command] #[instrument(skip_all)] pub async fn mark_item_unread( state: State<'_, Arc>, id: String, ) -> Result<(), ApiError> { let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?; Ok(state.orchestrator.database().items().mark_read(item_id, false).await?) } /// Star (favourite) an item. #[tauri::command] #[instrument(skip_all)] pub async fn star_item( state: State<'_, Arc>, id: String, ) -> Result<(), ApiError> { let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?; Ok(state.orchestrator.database().items().mark_starred(item_id, true).await?) } /// Remove the star from an item. #[tauri::command] #[instrument(skip_all)] pub async fn unstar_item( state: State<'_, Arc>, id: String, ) -> Result<(), ApiError> { let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?; Ok(state.orchestrator.database().items().mark_starred(item_id, false).await?) } /// Mark all unread items as read, optionally for a specific source. #[tauri::command] #[instrument(skip_all)] pub async fn mark_all_read( state: State<'_, Arc>, source_id: Option, ) -> Result { Ok(state .orchestrator .database() .items() .mark_all_read(source_id.as_deref()) .await?) } /// Get the total count of unread items. #[tauri::command] #[instrument(skip_all)] pub async fn get_unread_count( state: State<'_, Arc>, ) -> Result { Ok(state.orchestrator.database().items().count_unread().await?) } /// Maximum download size (50 MB) to prevent disk exhaustion from malicious URLs. const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; /// Download a file from a URL to a temp directory, then open it with the system default app. #[tauri::command] #[instrument(skip_all)] pub async fn download_and_open(url: String) -> Result<(), ApiError> { // Validate URL scheme before downloading let lower = url.to_ascii_lowercase(); if !lower.starts_with("http://") && !lower.starts_with("https://") { return Err(ApiError::bad_request("Only http and https URLs are allowed")); } let result = tokio::task::spawn_blocking(move || -> Result<(), ApiError> { let resp = ureq::get(&url) .call() .map_err(|e| ApiError::internal(format!("Download failed: {}", e)))?; // Derive filename from URL path, or fall back to "download.bin". // Sanitize: strip path separators and ".." to prevent directory traversal. let raw_filename = url .rsplit('/') .next() .filter(|s| !s.is_empty() && s.contains('.')) .unwrap_or("download.bin"); let filename: String = raw_filename .replace(['/', '\\'], "") .replace("..", ""); let filename = if filename.is_empty() { "download.bin".to_string() } else { filename }; // Block executable extensions to prevent arbitrary code execution. const BLOCKED_EXTENSIONS: &[&str] = &[ "exe", "msi", "bat", "cmd", "com", "scr", "pif", // Windows "app", "command", "scpt", "scptd", "action", // macOS "sh", "bash", "csh", "ksh", "run", // Unix "ps1", "psm1", "vbs", "vbe", "js", "jse", "wsf", // Script ]; let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); if BLOCKED_EXTENSIONS.contains(&ext.as_str()) { return Err(ApiError::bad_request(format!( "Cannot open files with .{} extension for security reasons", ext ))); } let dir = std::env::temp_dir().join("bb-downloads"); std::fs::create_dir_all(&dir) .map_err(|e| ApiError::internal(format!("Failed to create temp dir: {}", e)))?; // Use a unique prefix to prevent filename collisions between different URLs let unique_name = format!("{}_{}", std::process::id(), filename); let path = dir.join(&unique_name); let mut file = std::fs::File::create(&path) .map_err(|e| ApiError::internal(format!("Failed to create file: {}", e)))?; // Cap download size to prevent disk exhaustion let mut reader = resp.into_reader().take(MAX_DOWNLOAD_BYTES); std::io::copy(&mut reader, &mut file) .map_err(|e| ApiError::internal(format!("Failed to write file: {}", e)))?; // Cross-platform open with default application open::that(&path) .map_err(|e| ApiError::internal(format!("Failed to open file: {}", e)))?; Ok(()) }) .await .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?; result } #[cfg(test)] mod tests { use super::*; /// Create a timestamp string N seconds in the past. fn timestamp_ago(seconds: i64) -> String { let dt = chrono::Utc::now() - chrono::Duration::seconds(seconds); dt.format(bb_db::TIMESTAMP_FMT).to_string() } #[test] fn format_time_ago_just_now() { assert_eq!(format_time_ago(×tamp_ago(0)), "just now"); assert_eq!(format_time_ago(×tamp_ago(30)), "just now"); assert_eq!(format_time_ago(×tamp_ago(59)), "just now"); } #[test] fn format_time_ago_minutes() { assert_eq!(format_time_ago(×tamp_ago(60)), "1m ago"); assert_eq!(format_time_ago(×tamp_ago(300)), "5m ago"); assert_eq!(format_time_ago(×tamp_ago(3540)), "59m ago"); } #[test] fn format_time_ago_hours() { assert_eq!(format_time_ago(×tamp_ago(3600)), "1h ago"); assert_eq!(format_time_ago(×tamp_ago(7200)), "2h ago"); assert_eq!(format_time_ago(×tamp_ago(82800)), "23h ago"); } #[test] fn format_time_ago_days() { assert_eq!(format_time_ago(×tamp_ago(86400)), "1d ago"); assert_eq!(format_time_ago(×tamp_ago(86400 * 3)), "3d ago"); assert_eq!(format_time_ago(×tamp_ago(86400 * 6)), "6d ago"); } #[test] fn format_time_ago_old_date() { let dt = chrono::NaiveDate::from_ymd_opt(2024, 6, 15) .unwrap() .and_hms_opt(12, 0, 0) .unwrap() .and_utc(); let ts = dt.format(bb_db::TIMESTAMP_FMT).to_string(); assert_eq!(format_time_ago(&ts), "Jun 15, 2024"); } }