Skip to main content

max / balanced_breakfast

14.0 KB · 491 lines History Blame Raw
1 //! Reading list (bookmark) commands.
2
3 use crate::commands::error::ApiError;
4 use crate::state::AppState;
5 use bb_db::{BookmarkId, CreateBookmark, ItemId, UpdateBookmark};
6 use regex::Regex;
7 use serde::{Deserialize, Serialize};
8 use std::sync::{Arc, LazyLock};
9 use tauri::State;
10 use tracing::instrument;
11
12 // ── Response types ───────────────────────────────────────────────
13
14 /// Response for a single bookmark, including tags and relative time.
15 #[derive(Debug, Clone, Serialize)]
16 #[serde(rename_all = "camelCase")]
17 pub struct BookmarkResponse {
18 pub id: String,
19 pub url: String,
20 pub title: String,
21 pub description: String,
22 pub author: String,
23 pub source_name: String,
24 pub feed_item_id: Option<String>,
25 pub notes: String,
26 pub is_pinned: bool,
27 pub tags: Vec<String>,
28 pub created_at: String,
29 pub time_ago: String,
30 }
31
32 /// Input for creating a bookmark from a URL.
33 #[derive(Debug, Clone, Deserialize)]
34 #[serde(rename_all = "camelCase")]
35 pub struct CreateBookmarkInput {
36 pub url: String,
37 pub title: String,
38 #[serde(default)]
39 pub description: String,
40 #[serde(default)]
41 pub author: String,
42 #[serde(default)]
43 pub source_name: String,
44 #[serde(default)]
45 pub notes: String,
46 #[serde(default)]
47 pub tags: Vec<String>,
48 }
49
50 /// Input for updating a bookmark.
51 #[derive(Debug, Clone, Deserialize)]
52 #[serde(rename_all = "camelCase")]
53 pub struct UpdateBookmarkInput {
54 pub title: Option<String>,
55 pub description: Option<String>,
56 pub notes: Option<String>,
57 pub is_pinned: Option<bool>,
58 }
59
60 // ── Helpers ──────────────────────────────────────────────────────
61
62 fn format_time_ago(timestamp: &str) -> String {
63 let dt = bb_db::parse_timestamp(timestamp);
64 let now = chrono::Utc::now();
65 let diff = now.signed_duration_since(dt);
66
67 if diff.num_seconds() < 60 {
68 "just now".to_string()
69 } else if diff.num_minutes() < 60 {
70 format!("{}m ago", diff.num_minutes())
71 } else if diff.num_hours() < 24 {
72 format!("{}h ago", diff.num_hours())
73 } else if diff.num_days() < 7 {
74 format!("{}d ago", diff.num_days())
75 } else {
76 dt.format("%b %d, %Y").to_string()
77 }
78 }
79
80 async fn bookmark_to_response(
81 db: &bb_db::Database,
82 bookmark: bb_db::DbBookmark,
83 ) -> Result<BookmarkResponse, ApiError> {
84 let tags = db.bookmarks().get_tags(bookmark.id).await?;
85 Ok(BookmarkResponse {
86 id: bookmark.id.to_string(),
87 url: bookmark.url,
88 title: bookmark.title,
89 description: bookmark.description,
90 author: bookmark.author,
91 source_name: bookmark.source_name,
92 feed_item_id: bookmark.feed_item_id,
93 notes: bookmark.notes,
94 is_pinned: bookmark.is_pinned,
95 tags,
96 created_at: bookmark.created_at.clone(),
97 time_ago: format_time_ago(&bookmark.created_at),
98 })
99 }
100
101 /// Tag rules for bookmarks (same as feed tags for consistency).
102 const BB_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
103 max_depth: 3,
104 max_length: 80,
105 semantic_depth: 0,
106 };
107
108 fn validate_bookmark_tags(tags: &[String]) -> Result<(), ApiError> {
109 for tag in tags {
110 tagtree::validate_with(tag, &BB_TAG_CONFIG)
111 .map_err(|e| ApiError::bad_request(format!("invalid tag: {}", e.0)))?;
112 }
113 Ok(())
114 }
115
116 fn validate_url(url: &str) -> Result<(), ApiError> {
117 let url = url.trim();
118 if url.is_empty() {
119 return Err(ApiError::bad_request("URL is required"));
120 }
121 if !url.starts_with("http://") && !url.starts_with("https://") {
122 return Err(ApiError::bad_request("URL must start with http:// or https://"));
123 }
124 Ok(())
125 }
126
127 // ── Commands ─────────────────────────────────────────────────────
128
129 /// List all bookmarks, optionally filtered by tag.
130 #[tauri::command]
131 #[instrument(skip_all)]
132 pub async fn list_bookmarks(
133 state: State<'_, Arc<AppState>>,
134 tag: Option<String>,
135 ) -> Result<Vec<BookmarkResponse>, ApiError> {
136 let db = state.orchestrator.database();
137 let bookmarks = db.bookmarks().list(tag.as_deref()).await?;
138
139 let mut responses = Vec::with_capacity(bookmarks.len());
140 for bookmark in bookmarks {
141 responses.push(bookmark_to_response(db, bookmark).await?);
142 }
143
144 Ok(responses)
145 }
146
147 /// Create a bookmark from a URL.
148 #[tauri::command]
149 #[instrument(skip_all)]
150 pub async fn create_bookmark(
151 state: State<'_, Arc<AppState>>,
152 input: CreateBookmarkInput,
153 ) -> Result<BookmarkResponse, ApiError> {
154 validate_url(&input.url)?;
155 validate_bookmark_tags(&input.tags)?;
156
157 let db = state.orchestrator.database();
158
159 // Check for duplicate (best-effort; no UNIQUE constraint on url column)
160 if db.bookmarks().get_by_url(input.url.trim()).await?.is_some() {
161 return Err(ApiError::bad_request("URL is already bookmarked"));
162 }
163
164 let bookmark = db
165 .bookmarks()
166 .create(CreateBookmark {
167 url: input.url.trim().to_string(),
168 title: input.title.trim().to_string(),
169 description: input.description,
170 author: input.author,
171 source_name: input.source_name,
172 feed_item_id: None,
173 notes: input.notes,
174 tags: input.tags,
175 })
176 .await?;
177
178 bookmark_to_response(db, bookmark).await
179 }
180
181 /// Create a bookmark from an existing feed item (copies data, links via feed_item_id).
182 #[tauri::command]
183 #[instrument(skip_all)]
184 pub async fn create_bookmark_from_item(
185 state: State<'_, Arc<AppState>>,
186 item_id: String,
187 tags: Option<Vec<String>>,
188 ) -> Result<BookmarkResponse, ApiError> {
189 if let Some(ref t) = tags {
190 validate_bookmark_tags(t)?;
191 }
192
193 let id: ItemId = item_id
194 .parse()
195 .map_err(|_| ApiError::bad_request("Invalid item ID"))?;
196
197 let db = state.orchestrator.database();
198
199 let item = db
200 .items()
201 .get(id)
202 .await?
203 .ok_or_else(|| ApiError::not_found("Feed item not found"))?;
204
205 // Check if already bookmarked by feed_item_id or URL
206 if db.bookmarks().get_by_feed_item(&item.id.to_string()).await?.is_some() {
207 return Err(ApiError::bad_request("Item is already bookmarked"));
208 }
209 if let Some(ref url) = item.url {
210 if db.bookmarks().get_by_url(url).await?.is_some() {
211 return Err(ApiError::bad_request("URL is already bookmarked"));
212 }
213 }
214
215 let url = item.url.clone().unwrap_or_default();
216 if url.is_empty() {
217 return Err(ApiError::bad_request("Item has no URL to bookmark"));
218 }
219
220 let bookmark = db
221 .bookmarks()
222 .create(CreateBookmark {
223 url,
224 title: item.title.clone().unwrap_or_else(|| item.bite_text.clone()),
225 description: item.body.clone().map(|b| {
226 // Truncate body to a description-length excerpt
227 let plain = b.chars().take(300).collect::<String>();
228 plain
229 }).unwrap_or_default(),
230 author: item.bite_author.clone(),
231 source_name: item.source_name.clone(),
232 feed_item_id: Some(item.id.to_string()),
233 notes: String::new(),
234 tags: tags.unwrap_or_default(),
235 })
236 .await?;
237
238 bookmark_to_response(db, bookmark).await
239 }
240
241 /// Update a bookmark.
242 #[tauri::command]
243 #[instrument(skip_all)]
244 pub async fn update_bookmark(
245 state: State<'_, Arc<AppState>>,
246 id: String,
247 input: UpdateBookmarkInput,
248 ) -> Result<(), ApiError> {
249 let bookmark_id: BookmarkId = id
250 .parse()
251 .map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?;
252
253 state
254 .orchestrator
255 .database()
256 .bookmarks()
257 .update(
258 bookmark_id,
259 UpdateBookmark {
260 title: input.title,
261 description: input.description,
262 notes: input.notes,
263 is_pinned: input.is_pinned,
264 },
265 )
266 .await?;
267
268 Ok(())
269 }
270
271 /// Delete a bookmark.
272 #[tauri::command]
273 #[instrument(skip_all)]
274 pub async fn delete_bookmark(
275 state: State<'_, Arc<AppState>>,
276 id: String,
277 ) -> Result<(), ApiError> {
278 let bookmark_id: BookmarkId = id
279 .parse()
280 .map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?;
281
282 state
283 .orchestrator
284 .database()
285 .bookmarks()
286 .delete(bookmark_id)
287 .await?;
288
289 Ok(())
290 }
291
292 /// Set tags for a bookmark (replaces existing tags).
293 #[tauri::command]
294 #[instrument(skip_all)]
295 pub async fn set_bookmark_tags(
296 state: State<'_, Arc<AppState>>,
297 id: String,
298 tags: Vec<String>,
299 ) -> Result<(), ApiError> {
300 validate_bookmark_tags(&tags)?;
301
302 let bookmark_id: BookmarkId = id
303 .parse()
304 .map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?;
305
306 state
307 .orchestrator
308 .database()
309 .bookmarks()
310 .set_tags(bookmark_id, &tags)
311 .await?;
312
313 Ok(())
314 }
315
316 /// List all distinct bookmark tags.
317 #[tauri::command]
318 #[instrument(skip_all)]
319 pub async fn list_bookmark_tags(
320 state: State<'_, Arc<AppState>>,
321 ) -> Result<Vec<String>, ApiError> {
322 Ok(state
323 .orchestrator
324 .database()
325 .bookmarks()
326 .list_all_tags()
327 .await?)
328 }
329
330 /// Check if a URL is already bookmarked.
331 #[tauri::command]
332 #[instrument(skip_all)]
333 pub async fn is_bookmarked(
334 state: State<'_, Arc<AppState>>,
335 url: String,
336 ) -> Result<bool, ApiError> {
337 Ok(state
338 .orchestrator
339 .database()
340 .bookmarks()
341 .get_by_url(&url)
342 .await?
343 .is_some())
344 }
345
346 /// Get the total bookmark count (for sidebar badge).
347 #[tauri::command]
348 #[instrument(skip_all)]
349 pub async fn get_bookmark_count(
350 state: State<'_, Arc<AppState>>,
351 ) -> Result<i64, ApiError> {
352 Ok(state
353 .orchestrator
354 .database()
355 .bookmarks()
356 .count()
357 .await?)
358 }
359
360 /// Export a bookmark as a self-contained HTML file.
361 /// Returns the HTML string for the frontend to trigger a download.
362 #[tauri::command]
363 #[instrument(skip_all)]
364 pub async fn export_bookmark_html(
365 state: State<'_, Arc<AppState>>,
366 id: String,
367 ) -> Result<String, ApiError> {
368 let bookmark_id: BookmarkId = id
369 .parse()
370 .map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?;
371
372 let db = state.orchestrator.database();
373 let bookmark = db
374 .bookmarks()
375 .get(bookmark_id)
376 .await?
377 .ok_or_else(|| ApiError::not_found("Bookmark not found"))?;
378
379 // If linked to a feed item, try to get the full body
380 let body = if let Some(ref item_id) = bookmark.feed_item_id {
381 if let Ok(id) = item_id.parse::<ItemId>() {
382 db.items()
383 .get(id)
384 .await?
385 .and_then(|item| item.body.clone())
386 .unwrap_or_default()
387 } else {
388 String::new()
389 }
390 } else {
391 String::new()
392 };
393
394 let html = format!(
395 r#"<!DOCTYPE html>
396 <html lang="en">
397 <head>
398 <meta charset="UTF-8">
399 <meta name="viewport" content="width=device-width, initial-scale=1.0">
400 <title>{title}</title>
401 <style>
402 body {{ max-width: 700px; margin: 2rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; line-height: 1.6; color: #333; }}
403 h1 {{ font-size: 1.5rem; }}
404 .meta {{ color: #666; font-size: 0.9rem; margin-bottom: 1.5rem; }}
405 .meta a {{ color: #0066cc; }}
406 img {{ max-width: 100%; height: auto; }}
407 </style>
408 </head>
409 <body>
410 <h1>{title}</h1>
411 <div class="meta">
412 {author_line}
413 <a href="{url}">{url}</a><br>
414 Saved from Balanced Breakfast
415 </div>
416 <div class="content">{body}</div>
417 </body>
418 </html>"#,
419 title = html_escape(&bookmark.title),
420 url = html_escape(&bookmark.url),
421 author_line = if bookmark.author.is_empty() {
422 String::new()
423 } else {
424 format!("By {}<br>", html_escape(&bookmark.author))
425 },
426 // Body is pre-sanitized at ingest (docengine::sanitize_html strips
427 // script/iframe/on* etc.), but re-sanitize defensively for the export
428 // context in case DB was modified directly or via sync.
429 body = sanitize_body_for_export(&body),
430 );
431
432 Ok(html)
433 }
434
435 /// Strip dangerous HTML elements from body content for standalone HTML export.
436 /// The body is already sanitized at ingest time by docengine, but this provides
437 /// defense-in-depth in case the DB was modified directly or via sync.
438 fn sanitize_body_for_export(html: &str) -> String {
439 static DANGEROUS_TAGS: LazyLock<Regex> = LazyLock::new(|| {
440 Regex::new(r#"(?i)<\s*/?\s*(script|iframe|object|embed|form|base|style)\b[^>]*>"#).expect("invalid regex")
441 });
442 static ON_HANDLERS: LazyLock<Regex> = LazyLock::new(|| {
443 Regex::new(r#"(?i)\bon\w+\s*=\s*["'][^"']*["']"#).expect("invalid regex")
444 });
445 let s = DANGEROUS_TAGS.replace_all(html, "");
446 ON_HANDLERS.replace_all(&s, "").into_owned()
447 }
448
449 fn html_escape(s: &str) -> String {
450 s.replace('&', "&amp;")
451 .replace('<', "&lt;")
452 .replace('>', "&gt;")
453 .replace('"', "&quot;")
454 }
455
456 #[cfg(test)]
457 mod tests {
458 use super::*;
459
460 #[test]
461 fn validate_url_rejects_empty() {
462 assert!(validate_url("").is_err());
463 assert!(validate_url(" ").is_err());
464 }
465
466 #[test]
467 fn validate_url_rejects_non_http() {
468 assert!(validate_url("ftp://example.com").is_err());
469 assert!(validate_url("javascript:alert(1)").is_err());
470 }
471
472 #[test]
473 fn validate_url_accepts_http() {
474 assert!(validate_url("http://example.com").is_ok());
475 assert!(validate_url("https://example.com/path?q=1").is_ok());
476 }
477
478 #[test]
479 fn html_escape_special_chars() {
480 assert_eq!(html_escape("<script>"), "&lt;script&gt;");
481 assert_eq!(html_escape("a&b"), "a&amp;b");
482 assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
483 }
484
485 #[test]
486 fn time_ago_just_now() {
487 let now = chrono::Utc::now().format(bb_db::TIMESTAMP_FMT).to_string();
488 assert_eq!(format_time_ago(&now), "just now");
489 }
490 }
491