//! Feed item types for BalancedBreakfast use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] /// Unique identifier for a feed item pub struct FeedItemId { /// Source busser ID pub source: String, /// Item ID within the source pub item_id: String, } impl FeedItemId { /// Create a new feed item ID from a source busser and item key. pub fn new(source: impl Into, item_id: impl Into) -> Self { Self { source: source.into(), item_id: item_id.into(), } } /// Create a combined string ID for database storage. /// /// Format: `source:item_id`. Uses `split_once(':')` for parsing, so the /// `source` field must not contain `:`. This is guaranteed by convention — /// busser IDs are simple ASCII identifiers (e.g. `rss`, `hn`, `arxiv`). pub fn to_combined(&self) -> String { debug_assert!( !self.source.contains(':'), "FeedItemId source must not contain ':', got: {}", self.source ); format!("{}:{}", self.source, self.item_id) } /// Parse a combined string ID. /// /// Splits at the first `:`, so item_id may contain colons but source must not. pub fn from_combined(s: &str) -> Option { let (source, item_id) = s.split_once(':')?; Some(Self::new(source, item_id)) } } #[derive(Clone, Debug, Serialize, Deserialize)] /// Compact display for feed list view pub struct BiteDisplay { /// Author attribution (e.g., "@username", "HN", "RSS") pub author: String, /// Primary content text (truncated for display) pub text: String, /// Secondary info (e.g., score, retweet count) pub secondary: Option, /// Type indicator emoji/icon pub indicator: Option, } impl BiteDisplay { /// Create a bite with author and primary text. pub fn new(author: impl Into, text: impl Into) -> Self { Self { author: author.into(), text: text.into(), secondary: None, indicator: None, } } /// Set secondary info (e.g. score, retweet count). pub fn with_secondary(mut self, secondary: impl Into) -> Self { self.secondary = Some(secondary.into()); self } /// Set the type indicator emoji/icon. pub fn with_indicator(mut self, indicator: impl Into) -> Self { self.indicator = Some(indicator.into()); self } } #[derive(Clone, Debug, Serialize, Deserialize)] /// A custom action button declared by a plugin pub struct ItemAction { /// Button label shown in the detail view. pub label: String, /// Action type: `"open"` (system browser) or `"download"` (download + open). pub action_type: String, /// Target URL for the action. pub url: String, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] /// Full content for detail view pub struct FeedItemContent { /// Title (for articles/posts) pub title: Option, /// Full body text/content pub body: Option, /// URL to original content pub url: Option, /// Media attachments (URLs) pub media: Vec, /// Plugin-declared custom action buttons. #[serde(default)] pub actions: Vec, } impl FeedItemContent { /// Create empty content. pub fn new() -> Self { Self::default() } /// Set the article/post title. pub fn with_title(mut self, title: impl Into) -> Self { self.title = Some(title.into()); self } /// Set the full body text. pub fn with_body(mut self, body: impl Into) -> Self { self.body = Some(body.into()); self } /// Set the URL to the original content. pub fn with_url(mut self, url: impl Into) -> Self { self.url = Some(url.into()); self } /// Add a media attachment URL. pub fn add_media(mut self, url: impl Into) -> Self { self.media.push(url.into()); self } } #[derive(Clone, Debug, Serialize, Deserialize)] /// Metadata for feed items pub struct FeedItemMeta { /// When the item was published pub published_at: i64, /// When we fetched this item pub fetched_at: i64, /// Source busser name for display pub source_name: String, /// Engagement score (likes, upvotes, etc.) pub score: Option, /// Tags/categories pub tags: Vec, } impl FeedItemMeta { /// Create metadata with source name and publication timestamp. pub fn new(source_name: impl Into, published_at: i64) -> Self { Self { published_at, fetched_at: chrono::Utc::now().timestamp(), source_name: source_name.into(), score: None, tags: Vec::new(), } } /// Set the engagement score (likes, upvotes). pub fn with_score(mut self, score: i64) -> Self { self.score = Some(score); self } /// Add a tag/category. pub fn add_tag(mut self, tag: impl Into) -> Self { self.tags.push(tag.into()); self } } #[derive(Clone, Debug, Serialize, Deserialize)] /// Complete feed item with all information pub struct FeedItem { /// Unique identifier pub id: FeedItemId, /// Internal database UUID (set when loaded from DB, `None` for freshly fetched items). #[serde(default, skip_serializing_if = "Option::is_none")] pub db_id: Option, /// Compact display data pub bite: BiteDisplay, /// Full content pub content: FeedItemContent, /// Metadata pub meta: FeedItemMeta, /// Whether user has read this item pub is_read: bool, /// Whether user has starred this item pub is_starred: bool, } impl FeedItem { /// Create a new feed item (unread, not starred). pub fn new( id: FeedItemId, bite: BiteDisplay, content: FeedItemContent, meta: FeedItemMeta, ) -> Self { Self { id, db_id: None, bite, content, meta, is_read: false, is_starred: false, } } } #[derive(Clone, Debug, Serialize, Deserialize)] /// Result of a fetch operation pub struct FetchResult { /// Fetched items pub items: Vec, /// Cursor for pagination (if more items available) pub next_cursor: Option, /// Whether there are more items pub has_more: bool, } impl FetchResult { /// Create a result with no pagination cursor. pub fn new(items: Vec) -> Self { Self { items, next_cursor: None, has_more: false, } } /// Set a pagination cursor, indicating more items are available. pub fn with_cursor(mut self, cursor: impl Into) -> Self { self.next_cursor = Some(cursor.into()); self.has_more = true; self } } #[cfg(test)] mod tests { use super::*; #[test] fn feed_item_id_combined_roundtrip() { let id = FeedItemId::new("rss", "post-123"); let combined = id.to_combined(); assert_eq!(combined, "rss:post-123"); let parsed = FeedItemId::from_combined(&combined).unwrap(); assert_eq!(parsed, id); } #[test] fn feed_item_id_equality() { let a = FeedItemId::new("src", "1"); let b = FeedItemId::new("src", "1"); let c = FeedItemId::new("src", "2"); assert_eq!(a, b); assert_ne!(a, c); } #[test] fn feed_item_content_builder() { let content = FeedItemContent::new() .with_title("Title") .with_body("Body") .with_url("https://example.com") .add_media("img.jpg"); assert_eq!(content.title.as_deref(), Some("Title")); assert_eq!(content.body.as_deref(), Some("Body")); assert_eq!(content.url.as_deref(), Some("https://example.com")); assert_eq!(content.media, vec!["img.jpg"]); } #[test] fn feed_item_defaults_unread_unstarred() { let item = FeedItem::new( FeedItemId::new("s", "1"), BiteDisplay::new("a", "t"), FeedItemContent::new(), FeedItemMeta::new("s", 0), ); assert!(!item.is_read); assert!(!item.is_starred); } }