mod config;
pub mod metadata;
use std::collections::HashSet;
use self::config::AccountConfig;
use self::metadata::FreshRssMetadata;
use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
use crate::models::{
self, ArticleID, Category, CategoryID, DirectLogin, FavIcon, Feed, FeedID, FeedUpdateResult, Headline, LoginData, Marked, PasswordLogin,
PluginCapabilities, Read, StreamConversionResult, SyncResult, TagID, Url,
};
use crate::util::greader::{ArticleQuery, GReaderUtil, TAG_READ_STR, TAG_READING_LIST, TAG_STARRED_STR};
use crate::{ParsedUrl, feed_parser};
use async_trait::async_trait;
use greader_api::models::{AuthInput, GoogleAuthInput, ItemId, StreamType};
use greader_api::{AuthData, GReaderApi};
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct FreshRss {
api: Option<GReaderApi>,
portal: Arc<Box<dyn Portal>>,
logged_in: bool,
config: RwLock<AccountConfig>,
}
impl FreshRss {
async fn login_freshrss(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
if let LoginData::Direct(DirectLogin::Password(data)) = data
&& let Some(url_string) = data.url
{
let url_string = if !url_string.ends_with("api/greader.php") && !url_string.ends_with("api/greader.php/") {
if url_string.ends_with('/') {
format!("{url_string}api/greader.php/")
} else {
format!("{url_string}/api/greader.php/")
}
} else if url_string.ends_with("api/greader.php") {
format!("{url_string}/")
} else {
url_string
};
let url = Url::parse(&url_string)?;
let api = GReaderApi::new(&url, AuthData::Uninitialized);
let auth_data = api
.login(
&AuthInput::Google(GoogleAuthInput {
username: data.user.clone(),
password: data.password.clone(),
}),
client,
)
.await?;
let auth_data = match auth_data {
AuthData::Google(auth_data) => auth_data,
_ => return Err(FeedApiError::Login),
};
self.config.write().await.set_url(&url_string);
self.config.write().await.set_password(&data.password);
self.config.write().await.set_user_name(&data.user);
self.config.write().await.set_auth_token(&auth_data.auth_token.unwrap());
if let Some(post_token) = auth_data.post_token {
self.config.write().await.set_post_token(&post_token.token);
}
self.config.read().await.write()?;
self.api = Some(api);
self.logged_in = true;
return Ok(());
}
Err(FeedApiError::Login)
}
}
#[async_trait]
impl FeedApi for FreshRss {
fn features(&self) -> FeedApiResult<PluginCapabilities> {
Ok(PluginCapabilities::ADD_REMOVE_FEEDS
| PluginCapabilities::SUPPORT_CATEGORIES
| PluginCapabilities::MODIFY_CATEGORIES
| PluginCapabilities::SUPPORT_TAGS)
}
fn has_user_configured(&self) -> FeedApiResult<bool> {
Ok(self.api.is_some())
}
async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
if let Some(url) = self.config.read().await.get_url() {
let url = url.trim_end_matches("api/greader.php/");
let res = client.head(url).send().await?;
Ok(res.status().is_success())
} else {
Err(FeedApiError::Login)
}
}
async fn is_logged_in(&self, _client: &Client) -> FeedApiResult<bool> {
Ok(self.logged_in)
}
async fn user_name(&self) -> Option<String> {
self.config.read().await.get_user_name()
}
async fn get_login_data(&self) -> Option<LoginData> {
if self.has_user_configured().unwrap_or(false) {
let username = self.config.read().await.get_user_name();
let password = self.config.read().await.get_password();
if let (Some(username), Some(password)) = (username, password) {
return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
id: FreshRssMetadata::get_id(),
url: self.config.read().await.get_url(),
user: username,
password,
basic_auth: None,
})));
}
}
None
}
async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
if let Err(error) = self.login_freshrss(data, client).await {
tracing::error!(%error, "Failed to log in");
self.api = None;
self.logged_in = false;
Err(error)
} else {
self.logged_in = true;
Ok(())
}
}
async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
self.config.read().await.delete()?;
Ok(())
}
async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
if let Some(api) = &self.api {
let feeds = api.subscription_list(client);
let tags = api.tag_list(client);
let (feeds, tags) = futures::try_join!(feeds, tags)?;
let (categories, category_mappings) = GReaderUtil::convert_category_vec(feeds.subscriptions.clone(), Some(&tags), None, None);
let tags = GReaderUtil::convert_tag_list(tags, &categories);
let tag_ids: HashSet<TagID> = tags.iter().map(|f| f.tag_id.clone()).collect();
let (feeds, feed_mappings) = GReaderUtil::convert_feed_vec(feeds.subscriptions, None);
let mut result = StreamConversionResult::new();
let unread = GReaderUtil::get_articles(
api,
client,
self.portal.clone(),
ArticleQuery {
stream_id: Some(TAG_READING_LIST),
read: Some(Read::Unread),
marked: None,
tag_ids: &tag_ids,
limit: None,
last_sync: None,
},
);
let starred = GReaderUtil::get_articles(
api,
client,
self.portal.clone(),
ArticleQuery {
stream_id: Some(TAG_STARRED_STR),
read: None,
marked: None,
tag_ids: &tag_ids,
limit: None,
last_sync: None,
},
);
let latest = GReaderUtil::get_articles(
api,
client,
self.portal.clone(),
ArticleQuery {
stream_id: None,
read: Some(Read::Read),
marked: Some(Marked::Unmarked),
tag_ids: &tag_ids,
limit: Some(100),
last_sync: None,
},
);
let (unread, starred, latest) = futures::try_join!(unread, starred, latest)?;
result.add(unread);
result.add(starred);
result.add(latest);
return Ok(SyncResult {
feeds: crate::util::vec_to_option(feeds),
categories: crate::util::vec_to_option(categories),
feed_mappings: crate::util::vec_to_option(feed_mappings),
category_mappings: crate::util::vec_to_option(category_mappings),
tags: crate::util::vec_to_option(tags),
taggings: crate::util::vec_to_option(result.taggings),
headlines: None,
articles: crate::util::vec_to_option(result.articles),
enclosures: crate::util::vec_to_option(result.enclosures),
});
}
Err(FeedApiError::Login)
}
async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
if let Some(api) = &self.api {
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp();
let feeds = api.subscription_list(client);
let tags = api.tag_list(client);
let (feeds, tags) = futures::try_join!(feeds, tags)?;
let (categories, category_mappings) = GReaderUtil::convert_category_vec(feeds.subscriptions.clone(), Some(&tags), None, None);
let tags = GReaderUtil::convert_tag_list(tags, &categories);
let tag_ids: HashSet<TagID> = tags.iter().map(|f| f.tag_id.clone()).collect();
let (feeds, feed_mappings) = GReaderUtil::convert_feed_vec(feeds.subscriptions, None);
let mut result = StreamConversionResult::new();
let new_unread = GReaderUtil::get_articles(
api,
client,
self.portal.clone(),
ArticleQuery {
stream_id: Some(TAG_READING_LIST),
read: Some(Read::Unread),
marked: None,
tag_ids: &tag_ids,
limit: None,
last_sync: Some(last_sync),
},
);
let new_marked = GReaderUtil::get_articles(
api,
client,
self.portal.clone(),
ArticleQuery {
stream_id: Some(TAG_STARRED_STR),
read: None,
marked: None,
tag_ids: &tag_ids,
limit: None,
last_sync: Some(last_sync),
},
);
let freshrss_unread_ids = GReaderUtil::get_article_ids(api, client, Some(TAG_READING_LIST), Some(Read::Unread), None, Some(10000));
let freshrss_marked_ids = GReaderUtil::get_article_ids(api, client, Some(TAG_STARRED_STR), None, None, Some(10000));
let (new_unread, new_marked, freshrss_unread_ids, freshrss_marked_ids) =
futures::try_join!(new_unread, new_marked, freshrss_unread_ids, freshrss_marked_ids)?;
result.add(new_unread);
result.add(new_marked);
let freshrss_unread_ids: HashSet<ArticleID> = freshrss_unread_ids
.into_iter()
.map(|item_id| {
let ItemId { id } = item_id;
ArticleID::from_owned(id)
})
.collect();
let freshrss_marked_ids: HashSet<ArticleID> = freshrss_marked_ids
.into_iter()
.map(|item_id| {
let ItemId { id } = item_id;
ArticleID::from_owned(id)
})
.collect();
let local_unread_ids = self.portal.get_article_ids_unread_all()?;
let local_unread_ids = local_unread_ids.into_iter().collect::<HashSet<_>>();
let mut should_mark_read_headlines = local_unread_ids
.difference(&freshrss_unread_ids)
.cloned()
.map(|id| {
let marked = if freshrss_marked_ids.contains(&id) {
Marked::Marked
} else {
Marked::Unmarked
};
Headline {
article_id: id,
unread: Read::Read,
marked,
}
})
.collect::<Vec<_>>();
result.headlines.append(&mut should_mark_read_headlines);
let local_marked_ids = self.portal.get_article_ids_marked_all()?;
let local_marked_ids = local_marked_ids.into_iter().collect::<HashSet<_>>();
let mut mark_headlines = local_marked_ids
.difference(&freshrss_marked_ids)
.map(|id| Headline {
article_id: id.clone(),
marked: Marked::Marked,
unread: if freshrss_unread_ids.contains(id) { Read::Unread } else { Read::Read },
})
.collect::<Vec<_>>();
result.headlines.append(&mut mark_headlines);
let mut missing_unmarked_headlines = local_marked_ids
.difference(&freshrss_marked_ids)
.cloned()
.map(|id| {
let unread = if freshrss_unread_ids.contains(&id) { Read::Unread } else { Read::Read };
Headline {
article_id: id,
marked: Marked::Unmarked,
unread,
}
})
.collect::<Vec<_>>();
result.headlines.append(&mut missing_unmarked_headlines);
return Ok(SyncResult {
feeds: crate::util::vec_to_option(feeds),
categories: crate::util::vec_to_option(categories),
feed_mappings: crate::util::vec_to_option(feed_mappings),
category_mappings: crate::util::vec_to_option(category_mappings),
tags: crate::util::vec_to_option(tags),
taggings: crate::util::vec_to_option(result.taggings),
headlines: crate::util::vec_to_option(result.headlines),
articles: crate::util::vec_to_option(result.articles),
enclosures: crate::util::vec_to_option(result.enclosures),
});
}
Err(FeedApiError::Login)
}
async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
if let Some(api) = &self.api {
let tags = api.tag_list(client).await?;
let feeds = api.subscription_list(client).await?;
let (categories, _category_mappings) = GReaderUtil::convert_category_vec(feeds.subscriptions.clone(), Some(&tags), None, None);
let tags = GReaderUtil::convert_tag_list(tags, &categories);
let tag_ids: HashSet<TagID> = tags.iter().map(|f| f.tag_id.clone()).collect();
let (feeds, _feed_mappings) = GReaderUtil::convert_feed_vec(feeds.subscriptions, None);
let feed = feeds.iter().find(|feed| &feed.feed_id == feed_id).cloned();
let result = GReaderUtil::get_articles(
api,
client,
self.portal.clone(),
ArticleQuery {
stream_id: Some(feed_id.as_str()),
read: None,
marked: None,
tag_ids: &tag_ids,
limit: Some(20),
last_sync: None,
},
)
.await?;
return Ok(FeedUpdateResult {
feed,
taggings: crate::util::vec_to_option(result.taggings),
articles: crate::util::vec_to_option(result.articles),
enclosures: crate::util::vec_to_option(result.enclosures),
});
} else {
Err(FeedApiError::Login)
}
}
async fn set_article_read(&self, articles: &[ArticleID], read: models::Read, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let item_ids: Vec<_> = articles.iter().map(|id| id.as_str()).collect();
let (add_tag, remove_tag) = match read {
Read::Read => (Some(TAG_READ_STR), None),
Read::Unread => (None, Some(TAG_READ_STR)),
};
api.tag_edit(&item_ids, add_tag, remove_tag, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn set_article_marked(&self, articles: &[ArticleID], marked: models::Marked, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let item_ids: Vec<_> = articles.iter().map(|id| id.as_str()).collect();
let (add_tag, remove_tag) = match marked {
Marked::Marked => (Some(TAG_STARRED_STR), None),
Marked::Unmarked => (None, Some(TAG_STARRED_STR)),
};
api.tag_edit(&item_ids, add_tag, remove_tag, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn set_feed_read(&self, feeds: &[FeedID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
for feed in feeds {
api.mark_all_as_read(feed.as_str(), Some(last_sync), client).await?;
}
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn set_category_read(&self, categories: &[CategoryID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
for category in categories {
api.mark_all_as_read(category.as_str(), Some(last_sync), client).await?;
}
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn set_tag_read(&self, tags: &[TagID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
for tag in tags {
api.mark_all_as_read(tag.as_str(), Some(last_sync), client).await?;
}
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn set_all_read(&self, _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
api.mark_all_as_read(TAG_READING_LIST, Some(last_sync), client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn add_feed(
&self,
url: &Url,
title: Option<String>,
category_id: Option<CategoryID>,
client: &Client,
) -> FeedApiResult<(Feed, Option<Category>)> {
if let Some(api) = &self.api {
let feed_id = format!("feed/{}", &url.as_str());
let category_str = category_id.clone().map(|id| id.as_str().to_owned());
api.subscription_create(url, title.as_deref(), category_str.as_deref(), client).await?;
let feed_id = FeedID::new(&feed_id);
let semaphore = self.portal.get_download_semaphore();
let result = feed_parser::download_and_parse_feed(url, &feed_id, title, semaphore, client).await;
if result.is_err() {
tracing::warn!("parsing went wrong -> remove feed from freshrss account");
self.remove_feed(&feed_id, client).await?;
}
let feed = match result? {
ParsedUrl::SingleFeed(feed) => feed,
_ => {
tracing::warn!("Expected Single Feed");
return Err(FeedApiError::Api {
message: "Expected Single Feed".into(),
});
}
};
let local_categories = self.portal.get_categories()?;
let category = if !local_categories.iter().any(|c| Some(&c.category_id) == category_id.as_ref()) {
let feeds = api.subscription_list(client).await?;
let (categories, _category_mappings) = GReaderUtil::convert_category_vec(feeds.subscriptions, None, None, None);
categories.iter().find(|c| Some(&c.category_id) == category_id.as_ref()).cloned()
} else {
None
};
return Ok((*feed, category));
}
Err(FeedApiError::Login)
}
async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let feed_id_str = id.as_str();
api.subscription_delete(feed_id_str, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn move_feed(&self, feed_id: &FeedID, from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
api.subscription_edit(feed_id.as_str(), None, Some(from.as_str()), Some(to.as_str()), client)
.await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
if let Some(api) = &self.api {
api.subscription_edit(feed_id.as_str(), Some(new_title), None, None, client).await?;
Ok(feed_id.clone())
} else {
Err(FeedApiError::Login)
}
}
async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
async fn add_category<'a>(&self, title: &str, parent: Option<&'a CategoryID>, _client: &Client) -> FeedApiResult<CategoryID> {
if let Some(_api) = &self.api {
if parent.is_some() {
return Err(FeedApiError::Unsupported);
}
let category_id = GReaderUtil::generate_tag_id(None, title);
Ok(CategoryID::new(&category_id))
} else {
Err(FeedApiError::Login)
}
}
async fn remove_category(&self, id: &CategoryID, remove_children: bool, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
if remove_children {
let mappings = self.portal.get_feed_mappings()?;
let feed_ids = mappings
.iter()
.filter(|m| &m.category_id == id)
.map(|m| m.feed_id.clone())
.collect::<Vec<FeedID>>();
for feed_id in feed_ids {
self.remove_feed(&feed_id, client).await?;
}
}
api.tag_delete(StreamType::Category, id.as_str(), client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn rename_category(&self, id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
if let Some(api) = &self.api {
let new_id = GReaderUtil::generate_tag_id(None, new_title);
api.tag_rename(StreamType::Stream, id.as_str(), &new_id, client).await?;
Ok(CategoryID::new(&new_id))
} else {
Err(FeedApiError::Login)
}
}
async fn move_category(&self, _id: &CategoryID, _parent: &CategoryID, _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
api.import(opml.to_owned(), client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn add_tag(&self, title: &str, _client: &Client) -> FeedApiResult<TagID> {
if let Some(_api) = &self.api {
let tag_id = GReaderUtil::generate_tag_id(None, title);
Ok(TagID::new(&tag_id))
} else {
Err(FeedApiError::Login)
}
}
async fn remove_tag(&self, id: &TagID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
api.tag_delete(StreamType::Stream, id.as_str(), client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn rename_tag(&self, id: &TagID, new_title: &str, client: &Client) -> FeedApiResult<TagID> {
if let Some(api) = &self.api {
let new_tag_id = GReaderUtil::generate_tag_id(None, new_title);
api.tag_rename(StreamType::Stream, id.as_str(), &new_tag_id, client).await?;
Ok(TagID::new(&new_tag_id))
} else {
Err(FeedApiError::Login)
}
}
async fn tag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
api.tag_edit(&[article_id.as_str()], Some(tag_id.as_str()), None, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn untag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
api.tag_edit(&[article_id.as_str()], None, Some(tag_id.as_str()), client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn get_favicon(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
Err(FeedApiError::Unsupported)
}
}