opml import

This commit is contained in:
Greg Shuflin 2025-02-15 18:27:05 -08:00
parent 98ff8be9d8
commit 16ae4fc201
3 changed files with 156 additions and 12 deletions

17
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

View File

@ -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<usize>,
}
#[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<OpmlOutline>,
}
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
struct OpmlOutline {
#[serde(rename = "@type", default)]
outline_type: Option<String>,
#[serde(rename = "@text", default)]
text: Option<String>,
#[serde(rename = "@title", default)]
title: Option<String>,
#[serde(rename = "@xmlUrl", default)]
xml_url: Option<String>,
#[serde(rename = "@htmlUrl", default)]
html_url: Option<String>,
#[serde(rename = "outline", default)]
outlines: Vec<OpmlOutline>,
}
impl OpmlOutline {
fn is_feed(&self) -> bool {
self.xml_url.is_some()
}
fn get_title(&self) -> Option<String> {
self.title.clone().or_else(|| self.text.clone())
}
}
/// Import feeds from an OPML file
#[post("/import/opml", data = "<file>")]
pub async fn import_opml(
mut _db: Connection<Db>,
_user: AuthenticatedUser,
mut db: Connection<Db>,
user: AuthenticatedUser,
file: Data<'_>,
) -> Result<Json<ImportResponse>, 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(|| "<Unknown>".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<String>, Option<String>)>,
) {
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);
}
}
}