//! Reading list (bookmark) commands. use crate::commands::error::ApiError; use crate::state::AppState; use bb_db::{BookmarkId, CreateBookmark, ItemId, UpdateBookmark}; use regex::Regex; use serde::{Deserialize, Serialize}; use std::sync::{Arc, LazyLock}; use tauri::State; use tracing::instrument; // ── Response types ─────────────────────────────────────────────── /// Response for a single bookmark, including tags and relative time. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct BookmarkResponse { pub id: String, pub url: String, pub title: String, pub description: String, pub author: String, pub source_name: String, pub feed_item_id: Option, pub notes: String, pub is_pinned: bool, pub tags: Vec, pub created_at: String, pub time_ago: String, } /// Input for creating a bookmark from a URL. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateBookmarkInput { pub url: String, pub title: String, #[serde(default)] pub description: String, #[serde(default)] pub author: String, #[serde(default)] pub source_name: String, #[serde(default)] pub notes: String, #[serde(default)] pub tags: Vec, } /// Input for updating a bookmark. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateBookmarkInput { pub title: Option, pub description: Option, pub notes: Option, pub is_pinned: Option, } // ── Helpers ────────────────────────────────────────────────────── fn format_time_ago(timestamp: &str) -> String { let dt = bb_db::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() } } async fn bookmark_to_response( db: &bb_db::Database, bookmark: bb_db::DbBookmark, ) -> Result { let tags = db.bookmarks().get_tags(bookmark.id).await?; Ok(BookmarkResponse { id: bookmark.id.to_string(), url: bookmark.url, title: bookmark.title, description: bookmark.description, author: bookmark.author, source_name: bookmark.source_name, feed_item_id: bookmark.feed_item_id, notes: bookmark.notes, is_pinned: bookmark.is_pinned, tags, created_at: bookmark.created_at.clone(), time_ago: format_time_ago(&bookmark.created_at), }) } /// Tag rules for bookmarks (same as feed tags for consistency). const BB_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { max_depth: 3, max_length: 80, semantic_depth: 0, }; fn validate_bookmark_tags(tags: &[String]) -> Result<(), ApiError> { for tag in tags { tagtree::validate_with(tag, &BB_TAG_CONFIG) .map_err(|e| ApiError::bad_request(format!("invalid tag: {}", e.0)))?; } Ok(()) } fn validate_url(url: &str) -> Result<(), ApiError> { let url = url.trim(); if url.is_empty() { return Err(ApiError::bad_request("URL is required")); } if !url.starts_with("http://") && !url.starts_with("https://") { return Err(ApiError::bad_request("URL must start with http:// or https://")); } Ok(()) } // ── Commands ───────────────────────────────────────────────────── /// List all bookmarks, optionally filtered by tag. #[tauri::command] #[instrument(skip_all)] pub async fn list_bookmarks( state: State<'_, Arc>, tag: Option, ) -> Result, ApiError> { let db = state.orchestrator.database(); let bookmarks = db.bookmarks().list(tag.as_deref()).await?; let mut responses = Vec::with_capacity(bookmarks.len()); for bookmark in bookmarks { responses.push(bookmark_to_response(db, bookmark).await?); } Ok(responses) } /// Create a bookmark from a URL. #[tauri::command] #[instrument(skip_all)] pub async fn create_bookmark( state: State<'_, Arc>, input: CreateBookmarkInput, ) -> Result { validate_url(&input.url)?; validate_bookmark_tags(&input.tags)?; let db = state.orchestrator.database(); // Check for duplicate (best-effort; no UNIQUE constraint on url column) if db.bookmarks().get_by_url(input.url.trim()).await?.is_some() { return Err(ApiError::bad_request("URL is already bookmarked")); } let bookmark = db .bookmarks() .create(CreateBookmark { url: input.url.trim().to_string(), title: input.title.trim().to_string(), description: input.description, author: input.author, source_name: input.source_name, feed_item_id: None, notes: input.notes, tags: input.tags, }) .await?; bookmark_to_response(db, bookmark).await } /// Create a bookmark from an existing feed item (copies data, links via feed_item_id). #[tauri::command] #[instrument(skip_all)] pub async fn create_bookmark_from_item( state: State<'_, Arc>, item_id: String, tags: Option>, ) -> Result { if let Some(ref t) = tags { validate_bookmark_tags(t)?; } let id: ItemId = item_id .parse() .map_err(|_| ApiError::bad_request("Invalid item ID"))?; let db = state.orchestrator.database(); let item = db .items() .get(id) .await? .ok_or_else(|| ApiError::not_found("Feed item not found"))?; // Check if already bookmarked by feed_item_id or URL if db.bookmarks().get_by_feed_item(&item.id.to_string()).await?.is_some() { return Err(ApiError::bad_request("Item is already bookmarked")); } if let Some(ref url) = item.url { if db.bookmarks().get_by_url(url).await?.is_some() { return Err(ApiError::bad_request("URL is already bookmarked")); } } let url = item.url.clone().unwrap_or_default(); if url.is_empty() { return Err(ApiError::bad_request("Item has no URL to bookmark")); } let bookmark = db .bookmarks() .create(CreateBookmark { url, title: item.title.clone().unwrap_or_else(|| item.bite_text.clone()), description: item.body.clone().map(|b| { // Truncate body to a description-length excerpt let plain = b.chars().take(300).collect::(); plain }).unwrap_or_default(), author: item.bite_author.clone(), source_name: item.source_name.clone(), feed_item_id: Some(item.id.to_string()), notes: String::new(), tags: tags.unwrap_or_default(), }) .await?; bookmark_to_response(db, bookmark).await } /// Update a bookmark. #[tauri::command] #[instrument(skip_all)] pub async fn update_bookmark( state: State<'_, Arc>, id: String, input: UpdateBookmarkInput, ) -> Result<(), ApiError> { let bookmark_id: BookmarkId = id .parse() .map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?; state .orchestrator .database() .bookmarks() .update( bookmark_id, UpdateBookmark { title: input.title, description: input.description, notes: input.notes, is_pinned: input.is_pinned, }, ) .await?; Ok(()) } /// Delete a bookmark. #[tauri::command] #[instrument(skip_all)] pub async fn delete_bookmark( state: State<'_, Arc>, id: String, ) -> Result<(), ApiError> { let bookmark_id: BookmarkId = id .parse() .map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?; state .orchestrator .database() .bookmarks() .delete(bookmark_id) .await?; Ok(()) } /// Set tags for a bookmark (replaces existing tags). #[tauri::command] #[instrument(skip_all)] pub async fn set_bookmark_tags( state: State<'_, Arc>, id: String, tags: Vec, ) -> Result<(), ApiError> { validate_bookmark_tags(&tags)?; let bookmark_id: BookmarkId = id .parse() .map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?; state .orchestrator .database() .bookmarks() .set_tags(bookmark_id, &tags) .await?; Ok(()) } /// List all distinct bookmark tags. #[tauri::command] #[instrument(skip_all)] pub async fn list_bookmark_tags( state: State<'_, Arc>, ) -> Result, ApiError> { Ok(state .orchestrator .database() .bookmarks() .list_all_tags() .await?) } /// Check if a URL is already bookmarked. #[tauri::command] #[instrument(skip_all)] pub async fn is_bookmarked( state: State<'_, Arc>, url: String, ) -> Result { Ok(state .orchestrator .database() .bookmarks() .get_by_url(&url) .await? .is_some()) } /// Get the total bookmark count (for sidebar badge). #[tauri::command] #[instrument(skip_all)] pub async fn get_bookmark_count( state: State<'_, Arc>, ) -> Result { Ok(state .orchestrator .database() .bookmarks() .count() .await?) } /// Export a bookmark as a self-contained HTML file. /// Returns the HTML string for the frontend to trigger a download. #[tauri::command] #[instrument(skip_all)] pub async fn export_bookmark_html( state: State<'_, Arc>, id: String, ) -> Result { let bookmark_id: BookmarkId = id .parse() .map_err(|_| ApiError::bad_request("Invalid bookmark ID"))?; let db = state.orchestrator.database(); let bookmark = db .bookmarks() .get(bookmark_id) .await? .ok_or_else(|| ApiError::not_found("Bookmark not found"))?; // If linked to a feed item, try to get the full body let body = if let Some(ref item_id) = bookmark.feed_item_id { if let Ok(id) = item_id.parse::() { db.items() .get(id) .await? .and_then(|item| item.body.clone()) .unwrap_or_default() } else { String::new() } } else { String::new() }; let html = format!( r#" {title}

{title}

{author_line} {url}
Saved from Balanced Breakfast
{body}
"#, title = html_escape(&bookmark.title), url = html_escape(&bookmark.url), author_line = if bookmark.author.is_empty() { String::new() } else { format!("By {}
", html_escape(&bookmark.author)) }, // Body is pre-sanitized at ingest (docengine::sanitize_html strips // script/iframe/on* etc.), but re-sanitize defensively for the export // context in case DB was modified directly or via sync. body = sanitize_body_for_export(&body), ); Ok(html) } /// Strip dangerous HTML elements from body content for standalone HTML export. /// The body is already sanitized at ingest time by docengine, but this provides /// defense-in-depth in case the DB was modified directly or via sync. fn sanitize_body_for_export(html: &str) -> String { static DANGEROUS_TAGS: LazyLock = LazyLock::new(|| { Regex::new(r#"(?i)<\s*/?\s*(script|iframe|object|embed|form|base|style)\b[^>]*>"#).expect("invalid regex") }); static ON_HANDLERS: LazyLock = LazyLock::new(|| { Regex::new(r#"(?i)\bon\w+\s*=\s*["'][^"']*["']"#).expect("invalid regex") }); let s = DANGEROUS_TAGS.replace_all(html, ""); ON_HANDLERS.replace_all(&s, "").into_owned() } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } #[cfg(test)] mod tests { use super::*; #[test] fn validate_url_rejects_empty() { assert!(validate_url("").is_err()); assert!(validate_url(" ").is_err()); } #[test] fn validate_url_rejects_non_http() { assert!(validate_url("ftp://example.com").is_err()); assert!(validate_url("javascript:alert(1)").is_err()); } #[test] fn validate_url_accepts_http() { assert!(validate_url("http://example.com").is_ok()); assert!(validate_url("https://example.com/path?q=1").is_ok()); } #[test] fn html_escape_special_chars() { assert_eq!(html_escape("