Skip to main content

max / balanced_breakfast

8.3 KB · 297 lines History Blame Raw
1 //! Feed item types for BalancedBreakfast
2
3 use serde::{Deserialize, Serialize};
4
5 #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
6 /// Unique identifier for a feed item
7 pub struct FeedItemId {
8 /// Source busser ID
9 pub source: String,
10 /// Item ID within the source
11 pub item_id: String,
12 }
13
14 impl FeedItemId {
15 /// Create a new feed item ID from a source busser and item key.
16 pub fn new(source: impl Into<String>, item_id: impl Into<String>) -> Self {
17 Self {
18 source: source.into(),
19 item_id: item_id.into(),
20 }
21 }
22
23 /// Create a combined string ID for database storage.
24 ///
25 /// Format: `source:item_id`. Uses `split_once(':')` for parsing, so the
26 /// `source` field must not contain `:`. This is guaranteed by convention —
27 /// busser IDs are simple ASCII identifiers (e.g. `rss`, `hn`, `arxiv`).
28 pub fn to_combined(&self) -> String {
29 debug_assert!(
30 !self.source.contains(':'),
31 "FeedItemId source must not contain ':', got: {}",
32 self.source
33 );
34 format!("{}:{}", self.source, self.item_id)
35 }
36
37 /// Parse a combined string ID.
38 ///
39 /// Splits at the first `:`, so item_id may contain colons but source must not.
40 pub fn from_combined(s: &str) -> Option<Self> {
41 let (source, item_id) = s.split_once(':')?;
42 Some(Self::new(source, item_id))
43 }
44 }
45
46 #[derive(Clone, Debug, Serialize, Deserialize)]
47 /// Compact display for feed list view
48 pub struct BiteDisplay {
49 /// Author attribution (e.g., "@username", "HN", "RSS")
50 pub author: String,
51 /// Primary content text (truncated for display)
52 pub text: String,
53 /// Secondary info (e.g., score, retweet count)
54 pub secondary: Option<String>,
55 /// Type indicator emoji/icon
56 pub indicator: Option<String>,
57 }
58
59 impl BiteDisplay {
60 /// Create a bite with author and primary text.
61 pub fn new(author: impl Into<String>, text: impl Into<String>) -> Self {
62 Self {
63 author: author.into(),
64 text: text.into(),
65 secondary: None,
66 indicator: None,
67 }
68 }
69
70 /// Set secondary info (e.g. score, retweet count).
71 pub fn with_secondary(mut self, secondary: impl Into<String>) -> Self {
72 self.secondary = Some(secondary.into());
73 self
74 }
75
76 /// Set the type indicator emoji/icon.
77 pub fn with_indicator(mut self, indicator: impl Into<String>) -> Self {
78 self.indicator = Some(indicator.into());
79 self
80 }
81 }
82
83 #[derive(Clone, Debug, Serialize, Deserialize)]
84 /// A custom action button declared by a plugin
85 pub struct ItemAction {
86 /// Button label shown in the detail view.
87 pub label: String,
88 /// Action type: `"open"` (system browser) or `"download"` (download + open).
89 pub action_type: String,
90 /// Target URL for the action.
91 pub url: String,
92 }
93
94 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
95 /// Full content for detail view
96 pub struct FeedItemContent {
97 /// Title (for articles/posts)
98 pub title: Option<String>,
99 /// Full body text/content
100 pub body: Option<String>,
101 /// URL to original content
102 pub url: Option<String>,
103 /// Media attachments (URLs)
104 pub media: Vec<String>,
105 /// Plugin-declared custom action buttons.
106 #[serde(default)]
107 pub actions: Vec<ItemAction>,
108 }
109
110 impl FeedItemContent {
111 /// Create empty content.
112 pub fn new() -> Self {
113 Self::default()
114 }
115
116 /// Set the article/post title.
117 pub fn with_title(mut self, title: impl Into<String>) -> Self {
118 self.title = Some(title.into());
119 self
120 }
121
122 /// Set the full body text.
123 pub fn with_body(mut self, body: impl Into<String>) -> Self {
124 self.body = Some(body.into());
125 self
126 }
127
128 /// Set the URL to the original content.
129 pub fn with_url(mut self, url: impl Into<String>) -> Self {
130 self.url = Some(url.into());
131 self
132 }
133
134 /// Add a media attachment URL.
135 pub fn add_media(mut self, url: impl Into<String>) -> Self {
136 self.media.push(url.into());
137 self
138 }
139 }
140
141 #[derive(Clone, Debug, Serialize, Deserialize)]
142 /// Metadata for feed items
143 pub struct FeedItemMeta {
144 /// When the item was published
145 pub published_at: i64,
146 /// When we fetched this item
147 pub fetched_at: i64,
148 /// Source busser name for display
149 pub source_name: String,
150 /// Engagement score (likes, upvotes, etc.)
151 pub score: Option<i64>,
152 /// Tags/categories
153 pub tags: Vec<String>,
154 }
155
156 impl FeedItemMeta {
157 /// Create metadata with source name and publication timestamp.
158 pub fn new(source_name: impl Into<String>, published_at: i64) -> Self {
159 Self {
160 published_at,
161 fetched_at: chrono::Utc::now().timestamp(),
162 source_name: source_name.into(),
163 score: None,
164 tags: Vec::new(),
165 }
166 }
167
168 /// Set the engagement score (likes, upvotes).
169 pub fn with_score(mut self, score: i64) -> Self {
170 self.score = Some(score);
171 self
172 }
173
174 /// Add a tag/category.
175 pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
176 self.tags.push(tag.into());
177 self
178 }
179 }
180
181 #[derive(Clone, Debug, Serialize, Deserialize)]
182 /// Complete feed item with all information
183 pub struct FeedItem {
184 /// Unique identifier
185 pub id: FeedItemId,
186 /// Internal database UUID (set when loaded from DB, `None` for freshly fetched items).
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub db_id: Option<String>,
189 /// Compact display data
190 pub bite: BiteDisplay,
191 /// Full content
192 pub content: FeedItemContent,
193 /// Metadata
194 pub meta: FeedItemMeta,
195 /// Whether user has read this item
196 pub is_read: bool,
197 /// Whether user has starred this item
198 pub is_starred: bool,
199 }
200
201 impl FeedItem {
202 /// Create a new feed item (unread, not starred).
203 pub fn new(
204 id: FeedItemId,
205 bite: BiteDisplay,
206 content: FeedItemContent,
207 meta: FeedItemMeta,
208 ) -> Self {
209 Self {
210 id,
211 db_id: None,
212 bite,
213 content,
214 meta,
215 is_read: false,
216 is_starred: false,
217 }
218 }
219 }
220
221 #[derive(Clone, Debug, Serialize, Deserialize)]
222 /// Result of a fetch operation
223 pub struct FetchResult {
224 /// Fetched items
225 pub items: Vec<FeedItem>,
226 /// Cursor for pagination (if more items available)
227 pub next_cursor: Option<String>,
228 /// Whether there are more items
229 pub has_more: bool,
230 }
231
232 impl FetchResult {
233 /// Create a result with no pagination cursor.
234 pub fn new(items: Vec<FeedItem>) -> Self {
235 Self {
236 items,
237 next_cursor: None,
238 has_more: false,
239 }
240 }
241
242 /// Set a pagination cursor, indicating more items are available.
243 pub fn with_cursor(mut self, cursor: impl Into<String>) -> Self {
244 self.next_cursor = Some(cursor.into());
245 self.has_more = true;
246 self
247 }
248 }
249
250 #[cfg(test)]
251 mod tests {
252 use super::*;
253
254 #[test]
255 fn feed_item_id_combined_roundtrip() {
256 let id = FeedItemId::new("rss", "post-123");
257 let combined = id.to_combined();
258 assert_eq!(combined, "rss:post-123");
259 let parsed = FeedItemId::from_combined(&combined).unwrap();
260 assert_eq!(parsed, id);
261 }
262
263 #[test]
264 fn feed_item_id_equality() {
265 let a = FeedItemId::new("src", "1");
266 let b = FeedItemId::new("src", "1");
267 let c = FeedItemId::new("src", "2");
268 assert_eq!(a, b);
269 assert_ne!(a, c);
270 }
271
272 #[test]
273 fn feed_item_content_builder() {
274 let content = FeedItemContent::new()
275 .with_title("Title")
276 .with_body("Body")
277 .with_url("https://example.com")
278 .add_media("img.jpg");
279 assert_eq!(content.title.as_deref(), Some("Title"));
280 assert_eq!(content.body.as_deref(), Some("Body"));
281 assert_eq!(content.url.as_deref(), Some("https://example.com"));
282 assert_eq!(content.media, vec!["img.jpg"]);
283 }
284
285 #[test]
286 fn feed_item_defaults_unread_unstarred() {
287 let item = FeedItem::new(
288 FeedItemId::new("s", "1"),
289 BiteDisplay::new("a", "t"),
290 FeedItemContent::new(),
291 FeedItemMeta::new("s", 0),
292 );
293 assert!(!item.is_read);
294 assert!(!item.is_starred);
295 }
296 }
297