Skip to main content

max / balanced_breakfast

17.2 KB · 501 lines History Blame Raw
1 //! Feed item commands (list, get, read/star, download)
2 use crate::commands::error::ApiError;
3 use crate::state::AppState;
4 use bb_db::{parse_timestamp, ItemId, QueryFeedId};
5 use bb_feed::{FeedFilter, FeedGenerator, OrderBy};
6 use bb_interface::FeedItem;
7 use serde::{Deserialize, Serialize};
8 use std::io::Read;
9 use std::sync::Arc;
10 use tauri::State;
11 use tracing::instrument;
12
13 /// A plugin-declared custom action button for the detail view.
14 #[derive(Debug, Clone, Serialize)]
15 #[serde(rename_all = "camelCase")]
16 pub struct ItemActionResponse {
17 pub label: String,
18 pub action_type: String,
19 pub url: String,
20 }
21
22 /// Compact item representation for the feed list view.
23 #[derive(Debug, Clone, Serialize)]
24 #[serde(rename_all = "camelCase")]
25 pub struct ItemSummaryResponse {
26 /// Internal UUID (database primary key).
27 pub id: String,
28 /// Busser-scoped unique key (format: `busser_id:item_id`).
29 pub external_id: String,
30 /// Busser/plugin identifier that produced this item.
31 pub source_id: String,
32 /// Human-readable source name shown in the item header.
33 pub source_name: String,
34 /// Author attribution displayed in the compact bite view.
35 pub author: String,
36 /// Primary text line in the bite view (usually a headline or summary).
37 pub text: String,
38 /// Optional secondary line beneath the primary text (e.g. score, reply count).
39 pub secondary: Option<String>,
40 /// Optional status badge or emoji shown next to the item (e.g. category icon).
41 pub indicator: Option<String>,
42 /// Full article/post title, if distinct from `text`.
43 pub title: Option<String>,
44 /// Canonical URL to the original content.
45 pub url: Option<String>,
46 /// UTC publication timestamp formatted as `YYYY-MM-DD HH:MM:SS`.
47 pub published_at: String,
48 /// Human-readable relative time string (e.g. "5m ago", "2d ago").
49 pub time_ago: String,
50 /// Plugin-provided relevance or popularity score (e.g. HN points).
51 pub score: Option<i64>,
52 /// Whether the user has viewed this item.
53 pub is_read: bool,
54 /// Whether the user has starred/favourited this item.
55 pub is_starred: bool,
56 }
57
58 /// Full item representation for the detail/reading view.
59 #[derive(Debug, Clone, Serialize)]
60 #[serde(rename_all = "camelCase")]
61 pub struct ItemDetailResponse {
62 /// Internal UUID (database primary key).
63 pub id: String,
64 /// Busser-scoped unique key (format: `busser_id:item_id`).
65 pub external_id: String,
66 /// Busser/plugin identifier that produced this item.
67 pub source_id: String,
68 /// Human-readable source name shown in the detail header.
69 pub source_name: String,
70 /// Author attribution displayed in the detail view.
71 pub author: String,
72 /// Primary text line from the bite view (headline or summary).
73 pub text: String,
74 /// Optional secondary line (e.g. score, reply count) from the bite view.
75 pub secondary: Option<String>,
76 /// Optional status badge or emoji (e.g. category icon) from the bite view.
77 pub indicator: Option<String>,
78 /// Full article/post title.
79 pub title: Option<String>,
80 /// Full HTML or plain-text body content for the reading view.
81 pub body: Option<String>,
82 /// Canonical URL to the original content.
83 pub url: Option<String>,
84 /// URLs of attached media (images, audio, video).
85 pub media: Vec<String>,
86 /// UTC publication timestamp formatted as `YYYY-MM-DD HH:MM:SS`.
87 pub published_at: String,
88 /// Human-readable relative time string (e.g. "5m ago", "2d ago").
89 pub time_ago: String,
90 /// UTC timestamp of when this item was last fetched from the source.
91 pub fetched_at: String,
92 /// Plugin-provided relevance or popularity score (e.g. HN points).
93 pub score: Option<i64>,
94 /// Tags or categories assigned by the source plugin.
95 pub tags: Vec<String>,
96 /// Plugin-declared custom action buttons.
97 pub actions: Vec<ItemActionResponse>,
98 /// Whether the user has viewed this item.
99 pub is_read: bool,
100 /// Whether the user has starred/favourited this item.
101 pub is_starred: bool,
102 }
103
104 /// Paginated list of item summaries.
105 #[derive(Debug, Clone, Serialize)]
106 #[serde(rename_all = "camelCase")]
107 pub struct ItemsListResponse {
108 pub items: Vec<ItemSummaryResponse>,
109 pub page: i64,
110 pub has_more: bool,
111 }
112
113 /// Frontend-provided filter and pagination parameters.
114 #[derive(Debug, Clone, Deserialize)]
115 #[serde(rename_all = "camelCase")]
116 pub struct ItemsFilter {
117 pub source: Option<String>,
118 pub unread: Option<bool>,
119 pub starred: Option<bool>,
120 pub search: Option<String>,
121 pub order: Option<String>,
122 pub page: Option<i64>,
123 pub tag: Option<String>,
124 /// When set, apply the saved query feed's conditions instead of individual filters.
125 pub query_feed_id: Option<String>,
126 }
127
128 /// Convert a `FeedItem` (from `FeedGenerator`) to a compact summary response.
129 ///
130 /// Takes ownership of the `FeedItem` to move fields instead of cloning.
131 fn feed_item_to_summary(item: FeedItem) -> ItemSummaryResponse {
132 let published_at = match chrono::DateTime::from_timestamp(item.meta.published_at, 0) {
133 Some(dt) => dt.format(bb_db::TIMESTAMP_FMT).to_string(),
134 None => {
135 tracing::warn!(
136 timestamp = item.meta.published_at,
137 item_id = ?item.id.item_id,
138 "Invalid published_at timestamp, falling back to epoch"
139 );
140 chrono::DateTime::UNIX_EPOCH
141 .format(bb_db::TIMESTAMP_FMT)
142 .to_string()
143 }
144 };
145
146 let id = match item.db_id {
147 Some(id) => id,
148 None => {
149 tracing::warn!(
150 item_id = ?item.id.item_id,
151 "FeedItem has no db_id, using empty string as ID"
152 );
153 String::new()
154 }
155 };
156
157 let time_ago = format_time_ago(&published_at);
158
159 ItemSummaryResponse {
160 id,
161 external_id: item.id.item_id,
162 source_id: item.id.source,
163 source_name: item.meta.source_name,
164 author: item.bite.author,
165 text: item.bite.text,
166 secondary: item.bite.secondary,
167 indicator: item.bite.indicator,
168 title: item.content.title,
169 url: item.content.url,
170 published_at,
171 time_ago,
172 score: item.meta.score,
173 is_read: item.is_read,
174 is_starred: item.is_starred,
175 }
176 }
177
178 /// Convert a database feed item to a full detail response.
179 fn item_to_detail(item: &bb_db::DbFeedItem) -> ItemDetailResponse {
180 ItemDetailResponse {
181 id: item.id.to_string(),
182 external_id: item.external_id.clone(),
183 source_id: item.busser_id.to_string(),
184 source_name: item.source_name.clone(),
185 author: item.bite_author.clone(),
186 text: item.bite_text.clone(),
187 secondary: item.bite_secondary.clone(),
188 indicator: item.bite_indicator.clone(),
189 title: item.title.clone(),
190 body: item.body.clone(),
191 url: item.url.clone(),
192 media: item.media_vec(),
193 published_at: item.published_at.clone(),
194 time_ago: format_time_ago(&item.published_at),
195 fetched_at: item.fetched_at.clone(),
196 score: item.score,
197 tags: item.tags_vec(),
198 actions: item.actions_vec().into_iter().map(|a| ItemActionResponse {
199 label: a.label,
200 action_type: a.action_type,
201 url: a.url,
202 }).collect(),
203 is_read: item.is_read,
204 is_starred: item.is_starred,
205 }
206 }
207
208 /// Format a timestamp as a human-readable relative time string (e.g. "5m ago").
209 fn format_time_ago(timestamp: &str) -> String {
210 let dt = parse_timestamp(timestamp);
211 let now = chrono::Utc::now();
212 let diff = now.signed_duration_since(dt);
213
214 if diff.num_seconds() < 60 {
215 "just now".to_string()
216 } else if diff.num_minutes() < 60 {
217 format!("{}m ago", diff.num_minutes())
218 } else if diff.num_hours() < 24 {
219 format!("{}h ago", diff.num_hours())
220 } else if diff.num_days() < 7 {
221 format!("{}d ago", diff.num_days())
222 } else {
223 dt.format("%b %d, %Y").to_string()
224 }
225 }
226
227 /// List feed items with filtering, ordering, and pagination.
228 ///
229 /// Delegates to [`FeedGenerator::get_items`] so the filter -> sort -> paginate
230 /// pipeline is defined in one place (the `bb-feed` crate).
231 #[tauri::command]
232 #[instrument(skip_all)]
233 pub async fn list_items(
234 state: State<'_, Arc<AppState>>,
235 filter: ItemsFilter,
236 ) -> Result<ItemsListResponse, ApiError> {
237 let db = state.orchestrator.database().clone();
238 let page = filter.page.unwrap_or(0);
239
240 // Build a FeedFilter — either from a saved query feed or individual params.
241 let feed_filter = if let Some(ref qf_id) = filter.query_feed_id {
242 let id: QueryFeedId = qf_id
243 .parse()
244 .map_err(|_| ApiError::bad_request("Invalid query feed ID"))?;
245 let qf = db
246 .query_feeds()
247 .get(id)
248 .await?
249 .ok_or_else(|| ApiError::not_found(format!("Query feed {} not found", qf_id)))?;
250 FeedFilter::from_conditions(qf.rules_vec())
251 } else {
252 let mut f = FeedFilter::new();
253 if let Some(ref source) = filter.source {
254 f = f.source(source);
255 }
256 if filter.unread == Some(true) {
257 f = f.unread_only();
258 }
259 if filter.starred == Some(true) {
260 f = f.starred_only();
261 }
262 if let Some(ref search) = filter.search {
263 f = f.search(search);
264 }
265 if let Some(ref tag) = filter.tag {
266 f = f.with_feed_tag(tag);
267 }
268 f
269 };
270
271 let order = filter
272 .order
273 .as_deref()
274 .map(OrderBy::from_str_loose)
275 .unwrap_or_default();
276
277 let generator = FeedGenerator::new(db)
278 .with_filter(feed_filter)
279 .with_order(order);
280
281 let result = generator.get_items(page).await?;
282
283 let summaries: Vec<ItemSummaryResponse> =
284 result.items.into_iter().map(feed_item_to_summary).collect();
285
286 Ok(ItemsListResponse {
287 items: summaries,
288 page,
289 has_more: result.has_more,
290 })
291 }
292
293 /// Get the full detail view for a single item by UUID.
294 #[tauri::command]
295 #[instrument(skip_all)]
296 pub async fn get_item(
297 state: State<'_, Arc<AppState>>,
298 id: String,
299 ) -> Result<ItemDetailResponse, ApiError> {
300 let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?;
301 let db = state.orchestrator.database();
302
303 let item = db
304 .items()
305 .get(item_id)
306 .await?
307 .ok_or_else(|| ApiError::not_found(format!("Item {} not found", id)))?;
308
309 Ok(item_to_detail(&item))
310 }
311
312 /// Mark an item as read.
313 #[tauri::command]
314 #[instrument(skip_all)]
315 pub async fn mark_item_read(
316 state: State<'_, Arc<AppState>>,
317 id: String,
318 ) -> Result<(), ApiError> {
319 let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?;
320 Ok(state.orchestrator.database().items().mark_read(item_id, true).await?)
321 }
322
323 /// Mark an item as unread.
324 #[tauri::command]
325 #[instrument(skip_all)]
326 pub async fn mark_item_unread(
327 state: State<'_, Arc<AppState>>,
328 id: String,
329 ) -> Result<(), ApiError> {
330 let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?;
331 Ok(state.orchestrator.database().items().mark_read(item_id, false).await?)
332 }
333
334 /// Star (favourite) an item.
335 #[tauri::command]
336 #[instrument(skip_all)]
337 pub async fn star_item(
338 state: State<'_, Arc<AppState>>,
339 id: String,
340 ) -> Result<(), ApiError> {
341 let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?;
342 Ok(state.orchestrator.database().items().mark_starred(item_id, true).await?)
343 }
344
345 /// Remove the star from an item.
346 #[tauri::command]
347 #[instrument(skip_all)]
348 pub async fn unstar_item(
349 state: State<'_, Arc<AppState>>,
350 id: String,
351 ) -> Result<(), ApiError> {
352 let item_id: ItemId = id.parse().map_err(|_| ApiError::bad_request("Invalid item ID"))?;
353 Ok(state.orchestrator.database().items().mark_starred(item_id, false).await?)
354 }
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
371 /// Get the total count of unread items.
372 #[tauri::command]
373 #[instrument(skip_all)]
374 pub async fn get_unread_count(
375 state: State<'_, Arc<AppState>>,
376 ) -> Result<i64, ApiError> {
377 Ok(state.orchestrator.database().items().count_unread().await?)
378 }
379
380 /// Maximum download size (50 MB) to prevent disk exhaustion from malicious URLs.
381 const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024;
382
383 /// Download a file from a URL to a temp directory, then open it with the system default app.
384 #[tauri::command]
385 #[instrument(skip_all)]
386 pub async fn download_and_open(url: String) -> Result<(), ApiError> {
387 // Validate URL scheme before downloading
388 let lower = url.to_ascii_lowercase();
389 if !lower.starts_with("http://") && !lower.starts_with("https://") {
390 return Err(ApiError::bad_request("Only http and https URLs are allowed"));
391 }
392
393 let result = tokio::task::spawn_blocking(move || -> Result<(), ApiError> {
394 let resp = ureq::get(&url)
395 .call()
396 .map_err(|e| ApiError::internal(format!("Download failed: {}", e)))?;
397
398 // Derive filename from URL path, or fall back to "download.bin".
399 // Sanitize: strip path separators and ".." to prevent directory traversal.
400 let raw_filename = url
401 .rsplit('/')
402 .next()
403 .filter(|s| !s.is_empty() && s.contains('.'))
404 .unwrap_or("download.bin");
405 let filename: String = raw_filename
406 .replace(['/', '\\'], "")
407 .replace("..", "");
408 let filename = if filename.is_empty() { "download.bin".to_string() } else { filename };
409
410 // Block executable extensions to prevent arbitrary code execution.
411 const BLOCKED_EXTENSIONS: &[&str] = &[
412 "exe", "msi", "bat", "cmd", "com", "scr", "pif", // Windows
413 "app", "command", "scpt", "scptd", "action", // macOS
414 "sh", "bash", "csh", "ksh", "run", // Unix
415 "ps1", "psm1", "vbs", "vbe", "js", "jse", "wsf", // Script
416 ];
417 let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
418 if BLOCKED_EXTENSIONS.contains(&ext.as_str()) {
419 return Err(ApiError::bad_request(format!(
420 "Cannot open files with .{} extension for security reasons",
421 ext
422 )));
423 }
424
425 let dir = std::env::temp_dir().join("bb-downloads");
426 std::fs::create_dir_all(&dir)
427 .map_err(|e| ApiError::internal(format!("Failed to create temp dir: {}", e)))?;
428
429 // Use a unique prefix to prevent filename collisions between different URLs
430 let unique_name = format!("{}_{}", std::process::id(), filename);
431 let path = dir.join(&unique_name);
432 let mut file = std::fs::File::create(&path)
433 .map_err(|e| ApiError::internal(format!("Failed to create file: {}", e)))?;
434
435 // Cap download size to prevent disk exhaustion
436 let mut reader = resp.into_reader().take(MAX_DOWNLOAD_BYTES);
437 std::io::copy(&mut reader, &mut file)
438 .map_err(|e| ApiError::internal(format!("Failed to write file: {}", e)))?;
439
440 // Cross-platform open with default application
441 open::that(&path)
442 .map_err(|e| ApiError::internal(format!("Failed to open file: {}", e)))?;
443
444 Ok(())
445 })
446 .await
447 .map_err(|e| ApiError::internal(format!("Task join error: {}", e)))?;
448
449 result
450 }
451
452 #[cfg(test)]
453 mod tests {
454 use super::*;
455
456 /// Create a timestamp string N seconds in the past.
457 fn timestamp_ago(seconds: i64) -> String {
458 let dt = chrono::Utc::now() - chrono::Duration::seconds(seconds);
459 dt.format(bb_db::TIMESTAMP_FMT).to_string()
460 }
461
462 #[test]
463 fn format_time_ago_just_now() {
464 assert_eq!(format_time_ago(&timestamp_ago(0)), "just now");
465 assert_eq!(format_time_ago(&timestamp_ago(30)), "just now");
466 assert_eq!(format_time_ago(&timestamp_ago(59)), "just now");
467 }
468
469 #[test]
470 fn format_time_ago_minutes() {
471 assert_eq!(format_time_ago(&timestamp_ago(60)), "1m ago");
472 assert_eq!(format_time_ago(&timestamp_ago(300)), "5m ago");
473 assert_eq!(format_time_ago(&timestamp_ago(3540)), "59m ago");
474 }
475
476 #[test]
477 fn format_time_ago_hours() {
478 assert_eq!(format_time_ago(&timestamp_ago(3600)), "1h ago");
479 assert_eq!(format_time_ago(&timestamp_ago(7200)), "2h ago");
480 assert_eq!(format_time_ago(&timestamp_ago(82800)), "23h ago");
481 }
482
483 #[test]
484 fn format_time_ago_days() {
485 assert_eq!(format_time_ago(&timestamp_ago(86400)), "1d ago");
486 assert_eq!(format_time_ago(&timestamp_ago(86400 * 3)), "3d ago");
487 assert_eq!(format_time_ago(&timestamp_ago(86400 * 6)), "6d ago");
488 }
489
490 #[test]
491 fn format_time_ago_old_date() {
492 let dt = chrono::NaiveDate::from_ymd_opt(2024, 6, 15)
493 .unwrap()
494 .and_hms_opt(12, 0, 0)
495 .unwrap()
496 .and_utc();
497 let ts = dt.format(bb_db::TIMESTAMP_FMT).to_string();
498 assert_eq!(format_time_ago(&ts), "Jun 15, 2024");
499 }
500 }
501