feat: add feed commands
This commit is contained in:
parent
64b71087d8
commit
799bf18af7
8 changed files with 220 additions and 212 deletions
|
|
@ -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">
|
|
||||||
<h1>Getting Started with Rust</h1>
|
|
||||||
<p>Rust is a systems programming language that runs blazingly fast...</p>
|
|
||||||
<pre><code>fn main() {
|
|
||||||
println!("Hello, world!");
|
|
||||||
}
|
|
||||||
</code></pre>
|
|
||||||
</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">
|
|
||||||
<div xmlns="http://www.w3.org/1999/xhtml">
|
|
||||||
<h1>CSS Grid Layout Mastery</h1>
|
|
||||||
<p>CSS Grid is a powerful layout system...</p>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
[]
|
[
|
||||||
|
]
|
||||||
|
|
@ -1 +1 @@
|
||||||
pub mod feeds;
|
pub mod feed;
|
||||||
|
|
|
||||||
56
src-tauri/src/commands/feed.rs
Normal file
56
src-tauri/src/commands/feed.rs
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -2,62 +2,72 @@ use crate::Error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
pub mod feed_config {
|
||||||
struct Feed {
|
use super::*;
|
||||||
id: String,
|
|
||||||
title: String,
|
|
||||||
url: String,
|
|
||||||
icon: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Feed {
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
fn get(url: &str) -> Result<Feed, Error> {
|
pub struct Feed {
|
||||||
let feeds = Self::get_content()?;
|
pub id: String,
|
||||||
feeds
|
pub title: String,
|
||||||
.into_iter()
|
pub url: String,
|
||||||
.find(|feed| feed.url == url)
|
pub feed_url: String,
|
||||||
.ok_or_else(|| Error::MissingField(format!("Feed with url '{}' not found", url)))
|
pub icon: String,
|
||||||
}
|
|
||||||
fn get_all() -> Result<Vec<Feed>, Error> {
|
|
||||||
Self::get_content()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add(title: String, url: String, icon: String) -> Result<Feed, Error> {
|
impl Feed {
|
||||||
let mut feeds = Self::get_content()?;
|
pub fn get(url: &str) -> Result<Feed, Error> {
|
||||||
let new_feed = Self {
|
let feeds = Self::get_content()?;
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
feeds
|
||||||
title,
|
.into_iter()
|
||||||
url,
|
.find(|feed| feed.url == url)
|
||||||
icon,
|
.ok_or_else(|| Error::MissingField(format!("Feed with url '{}' not found", url)))
|
||||||
};
|
}
|
||||||
feeds.push(new_feed);
|
pub fn get_all() -> Result<Vec<Feed>, Error> {
|
||||||
let json = serde_json::to_string_pretty(&feeds)?;
|
Self::get_content()
|
||||||
fs::write("feeds.json", json)?;
|
|
||||||
|
|
||||||
Ok(feeds.pop().unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove(url: &str) -> Result<Feed, Error> {
|
|
||||||
let mut feeds = Self::get_content()?;
|
|
||||||
let index = feeds.iter()
|
|
||||||
.position(|feed| feed.url == url)
|
|
||||||
.ok_or_else(|| Error::MissingField(format!("Feed with url '{}' not found", url)))?;
|
|
||||||
|
|
||||||
let removed = feeds.remove(index);
|
|
||||||
let json = serde_json::to_string_pretty(&feeds)?;
|
|
||||||
fs::write("feeds.json", json)?;
|
|
||||||
|
|
||||||
Ok(removed)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_content() -> Result<Vec<Feed>, Error> {
|
|
||||||
let path = std::path::Path::new("feeds.json");
|
|
||||||
if !path.exists() {
|
|
||||||
fs::write(path, "[]")?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = fs::read_to_string(&path)?;
|
pub fn add(title: &str, url: &str, feed_url: &str, icon: &str) -> Result<Feed, Error> {
|
||||||
Ok(serde_json::from_str(&data)?)
|
let mut feeds = Self::get_content()?;
|
||||||
|
|
||||||
|
// Prevent Duplicate
|
||||||
|
feeds.retain(|feed| feed.url != url);
|
||||||
|
|
||||||
|
let new_feed = Feed {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
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)?;
|
||||||
|
fs::write("feeds.json", json)?;
|
||||||
|
|
||||||
|
Ok(feeds.pop().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(url: &str) -> Result<Feed, Error> {
|
||||||
|
let mut feeds = Self::get_content()?;
|
||||||
|
let index = feeds
|
||||||
|
.iter()
|
||||||
|
.position(|feed| feed.url == url)
|
||||||
|
.ok_or_else(|| Error::MissingField(format!("Feed with url '{}' not found", url)))?;
|
||||||
|
|
||||||
|
let removed = feeds.remove(index);
|
||||||
|
let json = serde_json::to_string_pretty(&feeds)?;
|
||||||
|
fs::write("feeds.json", json)?;
|
||||||
|
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_content() -> Result<Vec<Feed>, Error> {
|
||||||
|
let path = std::path::Path::new("feeds.json");
|
||||||
|
if !path.exists() {
|
||||||
|
fs::write(path, "[]")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = fs::read_to_string(&path)?;
|
||||||
|
Ok(serde_json::from_str(&data)?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,24 @@
|
||||||
use feed_rs::parser::ParseFeedError;
|
|
||||||
|
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod parser;
|
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> {
|
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/
|
Ok(())
|
||||||
#[tauri::command]
|
|
||||||
fn greet(name: &str) -> String {
|
|
||||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.invoke_handler(tauri::generate_handler![add_feed])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
@ -33,7 +30,7 @@ pub enum Error {
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
#[error("Failed to parse feed: {0}")]
|
#[error("Failed to parse feed: {0}")]
|
||||||
ParseFeed(#[from] ParseFeedError),
|
ParseFeed(#[from] feed_rs::parser::ParseFeedError),
|
||||||
|
|
||||||
#[error("Missing required field: {0}")]
|
#[error("Missing required field: {0}")]
|
||||||
MissingField(String),
|
MissingField(String),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ use scraper::{Html, Selector};
|
||||||
|
|
||||||
use crate::Error;
|
use crate::Error;
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct FeedItem {
|
pub struct FeedItem {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -42,9 +41,8 @@ impl FeedItem {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct FeedItemDetail {
|
pub struct FeedEntry {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
|
@ -55,7 +53,7 @@ pub struct FeedItemDetail {
|
||||||
pub authors: Vec<Person>,
|
pub authors: Vec<Person>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeedItemDetail {
|
impl FeedEntry {
|
||||||
pub fn from_feed(xml: &str, target_url: &str) -> Result<Self, Error> {
|
pub fn from_feed(xml: &str, target_url: &str) -> Result<Self, Error> {
|
||||||
let feed = parser::parse(xml.as_bytes())?;
|
let feed = parser::parse(xml.as_bytes())?;
|
||||||
|
|
||||||
|
|
@ -95,26 +93,98 @@ impl FeedItemDetail {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(Error::MissingField(
|
Err(Error::MissingField(format!(
|
||||||
format!("no entry found for url: {}", target_url)
|
"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_info(html: &str, base_url: &str) -> Vec<FeedInfo> {
|
||||||
pub fn extract_feed_urls(html: &str) -> Vec<String> {
|
|
||||||
let document = Html::parse_document(html);
|
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();
|
|
||||||
|
|
||||||
document
|
let rss_selector =
|
||||||
.select(&selector)
|
Selector::parse(r#"link[rel="alternate"][type="application/rss+xml"]"#).unwrap();
|
||||||
.filter_map(|el| el.value().attr("href"))
|
let atom_selector =
|
||||||
.map(|href| href.to_string())
|
Selector::parse(r#"link[rel="alternate"][type="application/atom+xml"]"#).unwrap();
|
||||||
.collect()
|
let json_selector =
|
||||||
}
|
Selector::parse(r#"link[rel="alternate"][type="application/feed+json"]"#).unwrap();
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue