Skip to main content

max / balanced_breakfast

5.2 KB · 187 lines History Blame Raw
1 //! OPML import/export commands
2 use crate::commands::error::ApiError;
3 use crate::state::AppState;
4 use serde::Serialize;
5 use std::sync::Arc;
6 use tauri::State;
7 use tracing::{info, instrument};
8
9 /// Result of an OPML import operation.
10 #[derive(Debug, Clone, Serialize)]
11 #[serde(rename_all = "camelCase")]
12 pub struct ImportResult {
13 pub imported: usize,
14 pub skipped: usize,
15 pub errors: Vec<String>,
16 }
17
18 /// Generate OPML XML from all RSS feeds in the database.
19 #[tauri::command]
20 #[instrument(skip_all)]
21 pub async fn export_opml(state: State<'_, Arc<AppState>>) -> Result<String, ApiError> {
22 let db = state.orchestrator.database();
23 let feeds = db.feeds().list_all().await?;
24
25 let mut outlines = String::new();
26 for feed in &feeds {
27 if feed.busser_id != "rss" {
28 continue;
29 }
30 let config = feed.config_json();
31 let feed_url = config
32 .get("feed_url")
33 .and_then(|v| v.as_str())
34 .unwrap_or("");
35 if feed_url.is_empty() {
36 continue;
37 }
38
39 outlines.push_str(&format!(
40 " <outline text=\"{}\" type=\"rss\" xmlUrl=\"{}\" />\n",
41 xml_escape(&feed.name),
42 xml_escape(feed_url),
43 ));
44 }
45
46 let opml = format!(
47 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
48 <opml version=\"2.0\">\n\
49 \x20 <head>\n\
50 \x20 <title>Balanced Breakfast Feeds</title>\n\
51 \x20 </head>\n\
52 \x20 <body>\n\
53 {}\
54 \x20 </body>\n\
55 </opml>\n",
56 outlines
57 );
58
59 info!(count = feeds.iter().filter(|f| f.busser_id == "rss").count(), "Exported RSS feeds as OPML");
60 Ok(opml)
61 }
62
63 /// Parse OPML content and create RSS feeds for each outline with an xmlUrl.
64 #[tauri::command]
65 #[instrument(skip_all)]
66 pub async fn import_opml(
67 state: State<'_, Arc<AppState>>,
68 content: String,
69 ) -> Result<ImportResult, ApiError> {
70 // Cap OPML input size to prevent memory exhaustion from huge XML payloads.
71 const MAX_OPML_SIZE: usize = 10 * 1024 * 1024; // 10 MB
72 if content.len() > MAX_OPML_SIZE {
73 return Err(ApiError::bad_request(format!(
74 "OPML file too large ({} bytes, max {} bytes)",
75 content.len(),
76 MAX_OPML_SIZE
77 )));
78 }
79
80 let doc = roxmltree::Document::parse(&content)
81 .map_err(|e| ApiError::bad_request(format!("Invalid OPML: {}", e)))?;
82
83 let db = state.orchestrator.database();
84
85 // Collect existing RSS feed URLs to skip duplicates
86 let existing_feeds = db.feeds().list_all().await?;
87
88 let existing_urls: std::collections::HashSet<String> = existing_feeds
89 .iter()
90 .filter(|f| f.busser_id == "rss")
91 .filter_map(|f| {
92 f.config_json()
93 .get("feed_url")
94 .and_then(|v| v.as_str())
95 .map(|s| s.to_string())
96 })
97 .collect();
98
99 let mut imported = 0;
100 let mut skipped = 0;
101 let mut errors = Vec::new();
102
103 // Walk all <outline> elements with xmlUrl attribute
104 for node in doc.descendants() {
105 if !node.is_element() || node.tag_name().name() != "outline" {
106 continue;
107 }
108 let xml_url = match node.attribute("xmlUrl") {
109 Some(url) if !url.is_empty() => url,
110 _ => continue,
111 };
112
113 // Validate URL scheme (same check as feed creation)
114 if !xml_url.starts_with("http://") && !xml_url.starts_with("https://") {
115 errors.push(format!("{}: invalid URL scheme (must be http or https)", xml_url));
116 continue;
117 }
118
119 if existing_urls.contains(xml_url) {
120 skipped += 1;
121 continue;
122 }
123
124 let name = node
125 .attribute("text")
126 .or_else(|| node.attribute("title"))
127 .unwrap_or(xml_url)
128 .to_string();
129
130 let config = serde_json::json!({ "feed_url": xml_url });
131 let create = bb_db::CreateFeed {
132 busser_id: bb_db::BusserId::new("rss"),
133 name,
134 config,
135 };
136
137 match db.feeds().create(create).await {
138 Ok(_) => imported += 1,
139 Err(e) => errors.push(format!("{}: {}", xml_url, e)),
140 }
141 }
142
143 // Re-initialize the RSS plugin so it discovers the newly imported feed
144 // URLs without requiring an app restart.
145 if imported > 0 {
146 if let Err(e) = state.orchestrator.init_plugin_from_db("rss").await {
147 tracing::warn!(error = %e, "Failed to reinitialize RSS plugin");
148 }
149 }
150
151 info!(
152 imported,
153 skipped,
154 errors = errors.len(),
155 "OPML import complete"
156 );
157
158 Ok(ImportResult {
159 imported,
160 skipped,
161 errors,
162 })
163 }
164
165 fn xml_escape(s: &str) -> String {
166 s.replace('&', "&amp;")
167 .replace('<', "&lt;")
168 .replace('>', "&gt;")
169 .replace('"', "&quot;")
170 .replace('\'', "&apos;")
171 }
172
173 #[cfg(test)]
174 mod tests {
175 use super::*;
176
177 #[test]
178 fn xml_escape_special_chars() {
179 assert_eq!(xml_escape("a&b<c>d\"e'f"), "a&amp;b&lt;c&gt;d&quot;e&apos;f");
180 }
181
182 #[test]
183 fn xml_escape_no_change() {
184 assert_eq!(xml_escape("hello world"), "hello world");
185 }
186 }
187