opml import
This commit is contained in:
parent
98ff8be9d8
commit
16ae4fc201
17
Cargo.lock
generated
17
Cargo.lock
generated
@ -209,7 +209,7 @@ dependencies = [
|
|||||||
"derive_builder",
|
"derive_builder",
|
||||||
"diligent-date-parser",
|
"diligent-date-parser",
|
||||||
"never",
|
"never",
|
||||||
"quick-xml",
|
"quick-xml 0.37.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -830,7 +830,7 @@ checksum = "e4c0591d23efd0d595099af69a31863ac1823046b1b021e3b06ba3aae7e00991"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"mediatype",
|
"mediatype",
|
||||||
"quick-xml",
|
"quick-xml 0.37.2",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -2338,6 +2338,16 @@ dependencies = [
|
|||||||
"yansi",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.37.2"
|
version = "0.37.2"
|
||||||
@ -2667,7 +2677,7 @@ dependencies = [
|
|||||||
"atom_syndication",
|
"atom_syndication",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
"never",
|
"never",
|
||||||
"quick-xml",
|
"quick-xml 0.37.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2683,6 +2693,7 @@ dependencies = [
|
|||||||
"feed-rs",
|
"feed-rs",
|
||||||
"futures",
|
"futures",
|
||||||
"getrandom 0.2.15",
|
"getrandom 0.2.15",
|
||||||
|
"quick-xml 0.31.0",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rocket",
|
"rocket",
|
||||||
"rocket_db_pools",
|
"rocket_db_pools",
|
||||||
|
@ -25,3 +25,4 @@ getrandom = "0.2"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
|
quick-xml = { version = "0.31", features = ["serialize"] }
|
||||||
|
150
src/import.rs
150
src/import.rs
@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use quick_xml::de::from_reader;
|
||||||
use rocket::data::ToByteUnit;
|
use rocket::data::ToByteUnit;
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use rocket::serde::{Deserialize, Serialize};
|
use rocket::serde::{Deserialize, Serialize};
|
||||||
use rocket::{post, Data};
|
use rocket::{post, Data};
|
||||||
use rocket_db_pools::Connection;
|
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::user::AuthenticatedUser;
|
||||||
use crate::Db;
|
use crate::Db;
|
||||||
|
|
||||||
@ -21,11 +25,53 @@ pub struct ImportResponse {
|
|||||||
feeds_imported: Option<usize>,
|
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
|
/// Import feeds from an OPML file
|
||||||
#[post("/import/opml", data = "<file>")]
|
#[post("/import/opml", data = "<file>")]
|
||||||
pub async fn import_opml(
|
pub async fn import_opml(
|
||||||
mut _db: Connection<Db>,
|
mut db: Connection<Db>,
|
||||||
_user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
file: Data<'_>,
|
file: Data<'_>,
|
||||||
) -> Result<Json<ImportResponse>, Status> {
|
) -> Result<Json<ImportResponse>, Status> {
|
||||||
// Limit file size to 1MB
|
// Limit file size to 1MB
|
||||||
@ -40,13 +86,99 @@ pub async fn import_opml(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let bytes = file_data.value;
|
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 {
|
Ok(Json(ImportResponse {
|
||||||
success: true,
|
success: imported_count > 0,
|
||||||
message: "OPML file received successfully. Import not yet implemented.".to_string(),
|
message: format!("Successfully imported {} feeds", imported_count),
|
||||||
feeds_imported: None,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user