mod config;
pub mod metadata;
mod oauth;
use std::collections::HashSet;
use std::sync::Arc;
use self::config::AccountConfig;
use self::metadata::InoreaderMetadata;
use self::oauth::InoreaderOAuth;
use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
use crate::models::{
self, ArticleID, Category, CategoryID, FavIcon, Feed, FeedID, FeedUpdateResult, Headline, LoginData, Marked, OAuthData, 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, util};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use greader_api::models::{AuthInput, InoreaderAuthInput, ItemId, StreamType};
use greader_api::{ApiError, AuthData, GReaderApi};
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use tokio::sync::RwLock;
pub struct Inoreader {
api: Option<GReaderApi>,
portal: Arc<Box<dyn Portal>>,
logged_in: bool,
config: Arc<RwLock<AccountConfig>>,
}
impl Inoreader {
async fn login_inoreader(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
let LoginData::OAuth(data) = data else { return Err(FeedApiError::Login) };
let oauth = InoreaderOAuth::new();
let url = Url::parse(&data.url)?;
let auth_code = oauth.parse_redirected_url(&url)?;
self.config.write().await.set_custom_api_secret(data.custom_api_secret.as_ref());
let (client_id, client_secret) = if let Some(user_api_secret) = data.custom_api_secret {
(user_api_secret.client_id, user_api_secret.client_secret)
} else {
(oauth.client_id, oauth.client_secret)
};
let api = GReaderApi::new(&oauth.base_uri, AuthData::Uninitialized);
let auth_data = api
.login(
&AuthInput::Inoreader(InoreaderAuthInput {
auth_code,
redirect_url: oauth.redirect_uri,
client_id,
client_secret,
}),
client,
)
.await?;
let auth_data = match auth_data {
AuthData::Inoreader(auth_data) => auth_data,
_ => return Err(FeedApiError::Login),
};
self.config.write().await.set_access_token(&auth_data.access_token);
self.config.write().await.set_refresh_token(&auth_data.refresh_token);
self.config.write().await.set_token_expires(&auth_data.expires_at.timestamp().to_string());
let user = api.user_info(client).await?;
self.config.write().await.set_user_name(&user.user_name);
self.config.write().await.set_user_id(&user.user_id);
self.config.write().await.write()?;
self.api = Some(api);
Ok(())
}
async fn is_token_expired(&self) -> Result<bool, ApiError> {
let timestamp = self.config.write().await.get_token_expires().ok_or(ApiError::TokenExpired)?;
let timestamp = timestamp.parse::<i64>().map_err(|_| ApiError::TokenExpired)?;
let expires_at: DateTime<Utc> = util::timestamp_to_datetime(timestamp);
let expires_in = expires_at.signed_duration_since(Utc::now());
Ok(expires_in.num_seconds() <= 60)
}
async fn refresh_token(&self, api: &GReaderApi, client: &Client) -> FeedApiResult<()> {
let response = api.inoreader_refresh_token(client).await?;
let token_expires = response.expires_at;
self.config.write().await.set_access_token(&response.access_token);
self.config.write().await.set_token_expires(&token_expires.timestamp().to_string());
self.config.write().await.write()?;
Ok(())
}
async fn check_and_update_token(&self, api: &GReaderApi, client: &Client) -> FeedApiResult<()> {
if self.is_token_expired().await? {
self.refresh_token(api, client).await?;
}
Ok(())
}
}
#[async_trait]
impl FeedApi for Inoreader {
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> {
let res = client.head("https://www.inoreader.com/").send().await?;
Ok(res.status().is_success())
}
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 let Ok(true) = self.has_user_configured() {
return Some(LoginData::OAuth(OAuthData {
id: InoreaderMetadata::get_id(),
url: String::new(),
custom_api_secret: self.config.read().await.get_custom_api_secret(),
}));
}
None
}
async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
if let Err(error) = self.login_inoreader(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 {
self.check_and_update_token(api, client).await?;
let stream_preferences = api.preference_stream_list(client);
let feeds = api.subscription_list(client);
let tags = api.tag_list(client);
let (stream_preferences, feeds, tags) = futures::try_join!(stream_preferences, feeds, tags)?;
let user_id = self.config.read().await.get_user_id();
let (categories, category_mappings) =
GReaderUtil::convert_category_vec(feeds.subscriptions.clone(), Some(&tags), Some(&stream_preferences), user_id.as_deref());
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, Some(&stream_preferences));
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)?;
let mut result = StreamConversionResult::new();
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 {
self.check_and_update_token(api, client).await?;
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp();
let stream_preferences = api.preference_stream_list(client);
let feeds = api.subscription_list(client);
let tags = api.tag_list(client);
let (stream_preferences, feeds, tags) = futures::try_join!(stream_preferences, feeds, tags)?;
let user_id = self.config.read().await.get_user_id();
let (categories, category_mappings) =
GReaderUtil::convert_category_vec(feeds.subscriptions.clone(), Some(&tags), Some(&stream_preferences), user_id.as_deref());
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, Some(&stream_preferences));
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 inoreader_unread_ids = GReaderUtil::get_article_ids(api, client, Some(TAG_READING_LIST), Some(Read::Unread), None, None);
let inoreader_marked_ids = GReaderUtil::get_article_ids(api, client, Some(TAG_STARRED_STR), None, None, None);
let (new_unread, new_marked, inoreader_unread_ids, inoreader_marked_ids) =
futures::try_join!(new_unread, new_marked, inoreader_unread_ids, inoreader_marked_ids)?;
result.add(new_unread);
result.add(new_marked);
let inoreader_unread_ids: HashSet<ArticleID> = inoreader_unread_ids
.into_iter()
.map(|item_id| {
let ItemId { id } = item_id;
ArticleID::from_owned(id)
})
.collect();
let inoreader_marked_ids: HashSet<ArticleID> = inoreader_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(&inoreader_unread_ids)
.cloned()
.map(|id| {
let marked = if inoreader_marked_ids.contains(&id) {
Marked::Marked
} else {
Marked::Unmarked
};
Headline {
article_id: id,
unread: Read::Read,
marked,
}
})
.collect();
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(&inoreader_marked_ids)
.map(|id| Headline {
article_id: id.clone(),
marked: Marked::Marked,
unread: if inoreader_unread_ids.contains(id) { Read::Unread } else { Read::Read },
})
.collect();
result.headlines.append(&mut mark_headlines);
let mut missing_unmarked_headlines = local_marked_ids
.difference(&inoreader_marked_ids)
.cloned()
.map(|id| {
let unread = if inoreader_unread_ids.contains(&id) { Read::Unread } else { Read::Read };
Headline {
article_id: id,
marked: Marked::Unmarked,
unread,
}
})
.collect();
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);
let feeds = api.subscription_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 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 {
self.check_and_update_token(api, client).await?;
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 {
self.check_and_update_token(api, client).await?;
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 {
self.check_and_update_token(api, client).await?;
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
let mut mark_read_futures = Vec::new();
for feed in feeds {
mark_read_futures.push(api.mark_all_as_read(feed.as_str(), Some(last_sync), client));
}
futures::future::try_join_all(mark_read_futures).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 {
self.check_and_update_token(api, client).await?;
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
let mut mark_read_futures = Vec::new();
for category in categories {
mark_read_futures.push(api.mark_all_as_read(category.as_str(), Some(last_sync), client));
}
futures::future::try_join_all(mark_read_futures).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 {
self.check_and_update_token(api, client).await?;
let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
let mut mark_read_futures = Vec::new();
for tag in tags {
mark_read_futures.push(api.mark_all_as_read(tag.as_str(), Some(last_sync), client));
}
futures::future::try_join_all(mark_read_futures).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
self.set_article_read(articles, Read::Read, client).await
}
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 {
self.check_and_update_token(api, client).await?;
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,
_ => {
let msg = "Expected Single Feed";
tracing::warn!("{msg}");
return Err(FeedApiError::Api { message: msg.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 user_id = self.config.read().await.get_user_id();
let (categories, _category_mappings) = GReaderUtil::convert_category_vec(feeds.subscriptions, None, None, user_id.as_deref());
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 {
self.check_and_update_token(api, client).await?;
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 {
self.check_and_update_token(api, client).await?;
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 {
self.check_and_update_token(api, client).await?;
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 user_id = self.config.read().await.get_user_id();
let category_id = GReaderUtil::generate_tag_id(user_id.as_deref(), 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 {
self.check_and_update_token(api, client).await?;
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 {
self.check_and_update_token(api, client).await?;
api.tag_rename(StreamType::Category, id.as_str(), new_title, client).await?;
let user_id = self.config.read().await.get_user_id();
Ok(CategoryID::new(&GReaderUtil::generate_tag_id(user_id.as_deref(), new_title)))
} 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 {
self.check_and_update_token(api, client).await?;
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 user_id = self.config.read().await.get_user_id();
let tag_id = GReaderUtil::generate_tag_id(user_id.as_deref(), 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 {
self.check_and_update_token(api, client).await?;
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 {
self.check_and_update_token(api, client).await?;
api.tag_rename(StreamType::Stream, id.as_str(), new_title, client).await?;
let user_id = self.config.read().await.get_user_id();
Ok(TagID::new(&GReaderUtil::generate_tag_id(user_id.as_deref(), new_title)))
} else {
Err(FeedApiError::Login)
}
}
async fn tag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
self.check_and_update_token(api, client).await?;
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 {
self.check_and_update_token(api, client).await?;
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)
}
}