diff --git a/Cargo.lock b/Cargo.lock index 77887af..39d1f46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,7 +209,7 @@ dependencies = [ "derive_builder", "diligent-date-parser", "never", - "quick-xml", + "quick-xml 0.37.2", ] [[package]] @@ -830,7 +830,7 @@ checksum = "e4c0591d23efd0d595099af69a31863ac1823046b1b021e3b06ba3aae7e00991" dependencies = [ "chrono", "mediatype", - "quick-xml", + "quick-xml 0.37.2", "regex", "serde", "serde_json", @@ -2338,6 +2338,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.37.2" @@ -2667,7 +2677,7 @@ dependencies = [ "atom_syndication", "derive_builder", "never", - "quick-xml", + "quick-xml 0.37.2", ] [[package]] @@ -2683,6 +2693,7 @@ dependencies = [ "feed-rs", "futures", "getrandom 0.2.15", + "quick-xml 0.31.0", "reqwest", "rocket", "rocket_db_pools", diff --git a/Cargo.toml b/Cargo.toml index edb0c10..74c0e87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ getrandom = "0.2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } futures = "0.3.31" +quick-xml = { version = "0.31", features = ["serialize"] } diff --git a/src/import.rs b/src/import.rs index 0aeb5c8..4d767d2 100644 --- a/src/import.rs +++ b/src/import.rs @@ -2,14 +2,18 @@ use std::io::Cursor; +use quick_xml::de::from_reader; use rocket::data::ToByteUnit; use rocket::http::Status; use rocket::serde::json::Json; use rocket::serde::{Deserialize, Serialize}; use rocket::{post, Data}; use rocket_db_pools::Connection; -use tracing::error; +use tracing::{error, info}; +use url::Url; +use crate::feed_utils::fetch_feed; +use crate::feeds::Feed; use crate::user::AuthenticatedUser; use crate::Db; @@ -21,11 +25,53 @@ pub struct ImportResponse { feeds_imported: Option, } +#[derive(Debug, Deserialize)] +#[serde(crate = "rocket::serde")] +#[serde(rename = "opml")] +struct Opml { + #[serde(rename = "body")] + body: OpmlBody, +} + +#[derive(Debug, Deserialize)] +#[serde(crate = "rocket::serde")] +struct OpmlBody { + #[serde(rename = "outline", default)] + outlines: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(crate = "rocket::serde")] +struct OpmlOutline { + #[serde(rename = "@type", default)] + outline_type: Option, + #[serde(rename = "@text", default)] + text: Option, + #[serde(rename = "@title", default)] + title: Option, + #[serde(rename = "@xmlUrl", default)] + xml_url: Option, + #[serde(rename = "@htmlUrl", default)] + html_url: Option, + #[serde(rename = "outline", default)] + outlines: Vec, +} + +impl OpmlOutline { + fn is_feed(&self) -> bool { + self.xml_url.is_some() + } + + fn get_title(&self) -> Option { + self.title.clone().or_else(|| self.text.clone()) + } +} + /// Import feeds from an OPML file #[post("/import/opml", data = "")] pub async fn import_opml( - mut _db: Connection, - _user: AuthenticatedUser, + mut db: Connection, + user: AuthenticatedUser, file: Data<'_>, ) -> Result, Status> { // Limit file size to 1MB @@ -40,13 +86,99 @@ pub async fn import_opml( } let bytes = file_data.value; - let _cursor = Cursor::new(bytes); + let cursor = Cursor::new(bytes); + + // Parse OPML + let opml: Opml = from_reader(cursor).map_err(|e| { + error!("Failed to parse OPML: {}", e); + Status::UnprocessableEntity + })?; + + // Extract and validate feeds + let mut feeds_to_import = Vec::new(); + extract_feeds(&opml.body.outlines, String::new(), &mut feeds_to_import); + + if feeds_to_import.is_empty() { + return Ok(Json(ImportResponse { + success: false, + message: "No valid feeds found in OPML file".to_string(), + feeds_imported: Some(0), + })); + } + + // Import feeds + let mut imported_count = 0; + for (url, title, category) in feeds_to_import { + // Validate URL + if let Ok(parsed_url) = Url::parse(&url) { + // Try to fetch feed data + match fetch_feed(&parsed_url).await { + Ok(feed_data) => { + // Use the feed title or the one from OPML + let name = feed_data + .title + .map(|t| t.content) + .or(title) + .unwrap_or_else(|| { + parsed_url + .host_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| "".to_string()) + }); + + // Create and save the feed + let mut feed = Feed::new(name, parsed_url, user.user_id); + if let Some(cat) = category { + feed.categorization = vec![cat]; + } + + if let Err(e) = feed.write_to_database(&mut **db).await { + error!("Failed to import feed {}: {}", url, e); + continue; + } + + imported_count += 1; + info!("Imported feed: {}", feed.name); + } + Err(_e) => { + error!("Failed to fetch feed {url}"); + continue; + } + } + } + } - // TODO: Parse OPML and import feeds - // For now just return a placeholder response Ok(Json(ImportResponse { - success: true, - message: "OPML file received successfully. Import not yet implemented.".to_string(), - feeds_imported: None, + success: imported_count > 0, + message: format!("Successfully imported {} feeds", imported_count), + feeds_imported: Some(imported_count), })) } + +fn extract_feeds( + outlines: &[OpmlOutline], + current_category: String, + feeds: &mut Vec<(String, Option, Option)>, +) { + for outline in outlines { + if outline.is_feed() { + if let Some(url) = &outline.xml_url { + feeds.push(( + url.clone(), + outline.get_title(), + if current_category.is_empty() { + None + } else { + Some(current_category.clone()) + }, + )); + } + } else { + // This is a category/folder + let new_category = outline + .get_title() + .unwrap_or_else(|| "Uncategorized".to_string()); + extract_feeds(&outline.outlines, new_category, feeds); + } + } +}