feat: add feed commands

This commit is contained in:
mindfreq 2026-05-03 12:18:02 +02:00
parent 64b71087d8
commit 799bf18af7
8 changed files with 220 additions and 212 deletions

View file

@ -1,98 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<!-- Required feed metadata -->
<title>Example Tech Blog</title>
<link href="https://example.com/blog/feed/atom" rel="self" />
<link href="https://example.com/blog" rel="alternate" />
<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
<updated>2024-03-15T14:30:00Z</updated>
<!-- Optional but recommended -->
<subtitle>Latest posts about tech, programming, and web development</subtitle>
<author>
<name>Jane Developer</name>
<email>jane@example.com</email>
<uri>https://example.com/jane</uri>
</author>
<rights>© 2024 Example Tech Blog. All rights reserved.</rights>
<generator uri="https://example.com/cms" version="1.0">Example CMS</generator>
<icon>https://example.com/favicon.ico</icon>
<logo>https://example.com/logo.png</logo>
<!-- Entry 1 -->
<entry>
<title>Getting Started with Rust</title>
<link href="https://example.com/blog/rust-getting-started" />
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2024-03-15T14:30:00Z</updated>
<published>2024-03-14T09:00:00Z</published>
<author>
<name>Jane Developer</name>
<email>jane@example.com</email>
</author>
<category term="rust" scheme="https://example.com/tags" label="Rust Programming" />
<category term="tutorial" scheme="https://example.com/tags" />
<content type="html">
&lt;h1&gt;Getting Started with Rust&lt;/h1&gt;
&lt;p&gt;Rust is a systems programming language that runs blazingly fast...&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;fn main() {
println!("Hello, world!");
}
&lt;/code&gt;&lt;/pre&gt;
</content>
<summary>Learn the basics of Rust programming language in this beginner-friendly tutorial.</summary>
<rights>© 2024 Jane Developer</rights>
</entry>
<!-- Entry 2 -->
<entry>
<title>CSS Grid Layout Mastery</title>
<link href="https://example.com/blog/css-grid-mastery" />
<link href="https://example.com/blog/css-grid-mastery/pdf"
rel="alternate"
type="application/pdf"
title="PDF Version" />
<id>urn:uuid:d8f3e7a2-9b4c-4f8e-9a3c-1e4f7b9d2c5a</id>
<updated>2024-03-10T11:15:00Z</updated>
<published>2024-03-08T16:20:00Z</published>
<author>
<name>John Smith</name>
<uri>https://example.com/john-smith</uri>
</author>
<contributor>
<name>Maria Garcia</name>
<email>maria@example.com</email>
</contributor>
<content type="xhtml">
&lt;div xmlns="http://www.w3.org/1999/xhtml"&gt;
&lt;h1&gt;CSS Grid Layout Mastery&lt;/h1&gt;
&lt;p&gt;CSS Grid is a powerful layout system...&lt;/p&gt;
&lt;/div&gt;
</content>
</entry>
<!-- Entry 3 - Minimal example -->
<entry>
<title>New Version Released: v2.0.0</title>
<link href="https://example.com/blog/version-2-release" />
<id>tag:example.com,2024-03-01:/blog/version-2-release</id>
<updated>2024-03-01T08:00:00Z</updated>
<published>2024-03-01T08:00:00Z</published>
<author>
<name>Release Team</name>
</author>
<content type="text">
Version 2.0.0 is now available! Includes performance improvements and bug fixes.
</content>
</entry>
</feed>

View file

@ -1 +1,2 @@
[]
[
]

View file

@ -1 +1 @@
pub mod feeds;
pub mod feed;

View file

@ -0,0 +1,56 @@
use crate::client::CLIENT;
use crate::config::feed_config::Feed;
use crate::parser::{extract_feed_info, FeedEntry, FeedItem};
use crate::Error;
// ======================= Feed Config Command =======================
#[tauri::command]
pub async fn add_feed(url: &str) -> Result<Feed, Error> {
let html = CLIENT.get(url).send().await?.text().await?;
let feeds = extract_feed_info(&html, url);
match feeds.first() {
Some(feed) => {
let title = feed.title.as_deref().unwrap_or("Untitled Feed");
let icon = feed.favicon.as_deref().unwrap_or("favicon.ico");
Feed::add(title, &feed.url, &feed.feed_url, icon)
}
None => {
return Err(Error::MissingField(
"No RSS/Atom/JSON feed found at this URL".into(),
));
}
}
}
#[tauri::command]
pub async fn get_feeds() -> Result<Vec<Feed>, Error> {
let feeds = Feed::get_all()?;
Ok(feeds)
}
#[tauri::command]
pub async fn remove_feed(website_url: &str) -> Result<Feed, Error> {
Ok(Feed::remove(website_url)?)
}
// =======================================================
pub async fn get_feed_item(website_url: &str) -> Result<Vec<FeedItem>, Error> {
if let Ok(feed) = Feed::get(&website_url) {
let xml = CLIENT.get(&feed.feed_url).send().await?.text().await?;
FeedItem::from_feed(&xml)
} else {
return Err(Error::MissingField("no feed found in the settings.".into()));
}
}
pub async fn get_entry(website_url: &str, target_url: &str) -> Result<FeedEntry, Error> {
if let Ok(feed) = Feed::get(&website_url) {
let xml = CLIENT.get(&feed.feed_url).send().await?.text().await?;
FeedEntry::from_feed(&xml, &target_url)
} else {
return Err(Error::MissingField("no feed found in the settings.".into()));
}
}

View file

@ -1,28 +0,0 @@
use crate::Error;
use crate::client::CLIENT;
use crate::parser::{FeedItemDetail, FeedItem};
pub async fn get_summaries() -> Result<Vec<FeedItem>, Error> {
let xml = CLIENT
.get("https://blog.rust-lang.org/feed")
.send()
.await?
.text()
.await?;
let parser_xml = FeedItem::from_feed(&xml);
println!("{:?}", parser_xml);
parser_xml
}
pub async fn get_entry(taget_url: &str) -> Result<FeedItemDetail, Error> {
let xml = CLIENT
.get("https://blog.rust-lang.org/feed")
.send()
.await?
.text()
.await?;
let parser_xml = FeedItemDetail::from_feed(&xml, taget_url);
println!("{:?}", parser_xml);
parser_xml
}

View file

@ -2,33 +2,42 @@ use crate::Error;
use serde::{Deserialize, Serialize};
use std::fs;
pub mod feed_config {
use super::*;
#[derive(Serialize, Deserialize, Debug)]
struct Feed {
id: String,
title: String,
url: String,
icon: String,
pub struct Feed {
pub id: String,
pub title: String,
pub url: String,
pub feed_url: String,
pub icon: String,
}
impl Feed {
fn get(url: &str) -> Result<Feed, Error> {
pub fn get(url: &str) -> Result<Feed, Error> {
let feeds = Self::get_content()?;
feeds
.into_iter()
.find(|feed| feed.url == url)
.ok_or_else(|| Error::MissingField(format!("Feed with url '{}' not found", url)))
}
fn get_all() -> Result<Vec<Feed>, Error> {
pub fn get_all() -> Result<Vec<Feed>, Error> {
Self::get_content()
}
fn add(title: String, url: String, icon: String) -> Result<Feed, Error> {
pub fn add(title: &str, url: &str, feed_url: &str, icon: &str) -> Result<Feed, Error> {
let mut feeds = Self::get_content()?;
let new_feed = Self {
// Prevent Duplicate
feeds.retain(|feed| feed.url != url);
let new_feed = Feed {
id: uuid::Uuid::new_v4().to_string(),
title,
url,
icon,
title: title.to_string(),
url: url.to_string(),
feed_url: feed_url.to_string(),
icon: icon.to_string(),
};
feeds.push(new_feed);
let json = serde_json::to_string_pretty(&feeds)?;
@ -37,9 +46,10 @@ impl Feed {
Ok(feeds.pop().unwrap())
}
fn remove(url: &str) -> Result<Feed, Error> {
pub fn remove(url: &str) -> Result<Feed, Error> {
let mut feeds = Self::get_content()?;
let index = feeds.iter()
let index = feeds
.iter()
.position(|feed| feed.url == url)
.ok_or_else(|| Error::MissingField(format!("Feed with url '{}' not found", url)))?;
@ -48,7 +58,6 @@ impl Feed {
fs::write("feeds.json", json)?;
Ok(removed)
}
fn get_content() -> Result<Vec<Feed>, Error> {
@ -61,3 +70,4 @@ impl Feed {
Ok(serde_json::from_str(&data)?)
}
}
}

View file

@ -1,27 +1,24 @@
use feed_rs::parser::ParseFeedError;
pub mod client;
pub mod commands;
pub mod config;
pub mod parser;
use client::CLIENT;
use commands::feed::{add_feed, get_entry, get_feeds, get_feed_item};
pub async fn test_thing() -> Result<(), Error> {
match get_entry("https://blog.rust-lang.org/", "https://blog.rust-lang.org/2026/03/20/rust-challenges/").await {
Ok(feed) => println!("{:?}", feed),
Err(e) => eprintln!("add_feed failed: {}", e),
}
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![add_feed])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
@ -33,7 +30,7 @@ pub enum Error {
Io(#[from] std::io::Error),
#[error("Failed to parse feed: {0}")]
ParseFeed(#[from] ParseFeedError),
ParseFeed(#[from] feed_rs::parser::ParseFeedError),
#[error("Missing required field: {0}")]
MissingField(String),

View file

@ -4,7 +4,6 @@ use scraper::{Html, Selector};
use crate::Error;
#[derive(Debug)]
pub struct FeedItem {
pub title: String,
@ -42,9 +41,8 @@ impl FeedItem {
}
}
#[derive(Debug)]
pub struct FeedItemDetail {
pub struct FeedEntry {
pub id: String,
pub title: String,
pub url: String,
@ -55,7 +53,7 @@ pub struct FeedItemDetail {
pub authors: Vec<Person>,
}
impl FeedItemDetail {
impl FeedEntry {
pub fn from_feed(xml: &str, target_url: &str) -> Result<Self, Error> {
let feed = parser::parse(xml.as_bytes())?;
@ -95,26 +93,98 @@ impl FeedItemDetail {
}
}
Err(Error::MissingField(
format!("no entry found for url: {}", target_url)
))
Err(Error::MissingField(format!(
"no entry found for url: {}",
target_url
)))
}
}
#[derive(Debug, Clone)]
pub struct FeedInfo {
pub url: String,
pub feed_url: String,
pub title: Option<String>,
pub favicon: Option<String>,
}
pub fn extract_feed_urls(html: &str) -> Vec<String> {
pub fn extract_feed_info(html: &str, base_url: &str) -> Vec<FeedInfo> {
let document = Html::parse_document(html);
let selector = Selector::parse(
r#"link[rel="alternate"][type="application/rss+xml"],
link[rel="alternate"][type="application/atom+xml"],
link[rel="alternate"][type="application/feed+json"]"#
).unwrap();
let rss_selector =
Selector::parse(r#"link[rel="alternate"][type="application/rss+xml"]"#).unwrap();
let atom_selector =
Selector::parse(r#"link[rel="alternate"][type="application/atom+xml"]"#).unwrap();
let json_selector =
Selector::parse(r#"link[rel="alternate"][type="application/feed+json"]"#).unwrap();
document
.select(&selector)
.filter_map(|el| el.value().attr("href"))
.map(|href| href.to_string())
.collect()
let favicon = extract_favicon(&document, base_url);
let mut feeds: Vec<FeedInfo> = Vec::new();
for selector in [&rss_selector, &atom_selector, &json_selector] {
for el in document.select(selector) {
if let Some(href) = el.value().attr("href") {
let title = el.value().attr("title").map(String::from);
feeds.push(FeedInfo {
url: base_url.to_string(),
feed_url: resolve_url(base_url, href),
title,
favicon: favicon.clone(),
});
}
}
}
feeds
}
fn extract_favicon(document: &Html, base_url: &str) -> Option<String> {
// ترتيب الأولوية: Apple Touch Icon -> PNG Icon -> Standard Icon -> favicon.ico
let favicon_selectors = [
r#"link[rel="apple-touch-icon"]"#,
r#"link[rel="apple-touch-icon-precomposed"]"#,
r#"link[rel="icon"][type="image/png"]"#,
r#"link[rel="icon"][sizes="32x32"]"#,
r#"link[rel="icon"][sizes="any"]"#,
r#"link[rel="icon"]"#,
r#"link[rel="shortcut icon"]"#,
];
for selector_str in &favicon_selectors {
if let Ok(sel) = Selector::parse(selector_str) {
if let Some(el) = document.select(&sel).next() {
if let Some(href) = el.value().attr("href") {
return Some(resolve_url(base_url, href));
}
}
}
}
Some(format!("{}/favicon.ico", base_url.trim_end_matches('/')))
}
fn resolve_url(base: &str, href: &str) -> String {
if href.starts_with("http://") || href.starts_with("https://") {
href.to_string()
} else if href.starts_with("//") {
format!("https:{}", href)
} else if href.starts_with('/') {
// رابط مطلق نسبي
let base = if base.ends_with('/') {
&base[..base.len() - 1]
} else {
base
};
format!("{}{}", base, href)
} else {
// رابط نسبي
let base = if base.ends_with('/') {
base.to_string()
} else {
format!("{}/", base)
};
format!("{}{}", base, href)
}
}