//! OPML import/export commands use crate::commands::error::ApiError; use crate::state::AppState; use serde::Serialize; use std::sync::Arc; use tauri::State; use tracing::{info, instrument}; /// Result of an OPML import operation. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct ImportResult { pub imported: usize, pub skipped: usize, pub errors: Vec, } /// Generate OPML XML from all RSS feeds in the database. #[tauri::command] #[instrument(skip_all)] pub async fn export_opml(state: State<'_, Arc>) -> Result { let db = state.orchestrator.database(); let feeds = db.feeds().list_all().await?; let mut outlines = String::new(); for feed in &feeds { if feed.busser_id != "rss" { continue; } let config = feed.config_json(); let feed_url = config .get("feed_url") .and_then(|v| v.as_str()) .unwrap_or(""); if feed_url.is_empty() { continue; } outlines.push_str(&format!( " \n", xml_escape(&feed.name), xml_escape(feed_url), )); } let opml = format!( "\n\ \n\ \x20 \n\ \x20 Balanced Breakfast Feeds\n\ \x20 \n\ \x20 \n\ {}\ \x20 \n\ \n", outlines ); info!(count = feeds.iter().filter(|f| f.busser_id == "rss").count(), "Exported RSS feeds as OPML"); Ok(opml) } /// Parse OPML content and create RSS feeds for each outline with an xmlUrl. #[tauri::command] #[instrument(skip_all)] pub async fn import_opml( state: State<'_, Arc>, content: String, ) -> Result { // Cap OPML input size to prevent memory exhaustion from huge XML payloads. const MAX_OPML_SIZE: usize = 10 * 1024 * 1024; // 10 MB if content.len() > MAX_OPML_SIZE { return Err(ApiError::bad_request(format!( "OPML file too large ({} bytes, max {} bytes)", content.len(), MAX_OPML_SIZE ))); } let doc = roxmltree::Document::parse(&content) .map_err(|e| ApiError::bad_request(format!("Invalid OPML: {}", e)))?; let db = state.orchestrator.database(); // Collect existing RSS feed URLs to skip duplicates let existing_feeds = db.feeds().list_all().await?; let existing_urls: std::collections::HashSet = existing_feeds .iter() .filter(|f| f.busser_id == "rss") .filter_map(|f| { f.config_json() .get("feed_url") .and_then(|v| v.as_str()) .map(|s| s.to_string()) }) .collect(); let mut imported = 0; let mut skipped = 0; let mut errors = Vec::new(); // Walk all elements with xmlUrl attribute for node in doc.descendants() { if !node.is_element() || node.tag_name().name() != "outline" { continue; } let xml_url = match node.attribute("xmlUrl") { Some(url) if !url.is_empty() => url, _ => continue, }; // Validate URL scheme (same check as feed creation) if !xml_url.starts_with("http://") && !xml_url.starts_with("https://") { errors.push(format!("{}: invalid URL scheme (must be http or https)", xml_url)); continue; } if existing_urls.contains(xml_url) { skipped += 1; continue; } let name = node .attribute("text") .or_else(|| node.attribute("title")) .unwrap_or(xml_url) .to_string(); let config = serde_json::json!({ "feed_url": xml_url }); let create = bb_db::CreateFeed { busser_id: bb_db::BusserId::new("rss"), name, config, }; match db.feeds().create(create).await { Ok(_) => imported += 1, Err(e) => errors.push(format!("{}: {}", xml_url, e)), } } // Re-initialize the RSS plugin so it discovers the newly imported feed // URLs without requiring an app restart. if imported > 0 { if let Err(e) = state.orchestrator.init_plugin_from_db("rss").await { tracing::warn!(error = %e, "Failed to reinitialize RSS plugin"); } } info!( imported, skipped, errors = errors.len(), "OPML import complete" ); Ok(ImportResult { imported, skipped, errors, }) } fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[cfg(test)] mod tests { use super::*; #[test] fn xml_escape_special_chars() { assert_eq!(xml_escape("a&bd\"e'f"), "a&b<c>d"e'f"); } #[test] fn xml_escape_no_change() { assert_eq!(xml_escape("hello world"), "hello world"); } }