pub mod config;
pub mod metadata;
use self::config::AccountConfig;
use self::metadata::FeverMetadata;
use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
use crate::models::{
self, ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, FatArticle, FavIcon, Feed, FeedID, FeedMapping, FeedUpdateResult, Headline,
LoginData, Marked, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, Read, SyncResult, TagID, Url,
};
use crate::util;
use crate::util::favicons::EXPIRES_AFTER_DAYS;
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as base64_std;
use chrono::{Duration, Utc};
use fever_api::FeverApi;
use fever_api::error::ApiError as FeverApiError;
use fever_api::models::{Feed as FeverFeed, FeedsGroups, Group as FeverCategory, Item as FeverEntry, ItemStatus};
use futures::future;
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use std::collections::HashMap;
use std::collections::HashSet;
use std::convert::TryInto;
use std::sync::Arc;
impl From<FeverApiError> for FeedApiError {
fn from(error: FeverApiError) -> FeedApiError {
match error {
FeverApiError::Url(e) => FeedApiError::Url(e),
FeverApiError::Json { source, json } => FeedApiError::Json { source, json },
FeverApiError::Http(e) => FeedApiError::Network(e),
FeverApiError::Fever(fever_error) => FeedApiError::Api {
message: format!("Fever Error (code {})\nMessage: {}", fever_error.error_code, fever_error.error_message),
},
FeverApiError::Input => FeedApiError::Api {
message: FeverApiError::Input.to_string(),
},
FeverApiError::Token => FeedApiError::Api {
message: FeverApiError::Token.to_string(),
},
FeverApiError::Parse => FeedApiError::Api {
message: FeverApiError::Parse.to_string(),
},
FeverApiError::Unauthorized => FeedApiError::Auth,
FeverApiError::Unknown => FeedApiError::Unknown,
}
}
}
pub struct Fever {
api: Option<Arc<FeverApi>>,
portal: Arc<Box<dyn Portal>>,
logged_in: bool,
config: AccountConfig,
}
impl Fever {
fn convert_category(category: FeverCategory, sort_index: Option<i32>) -> (Category, CategoryMapping) {
let FeverCategory { id, title } = category;
let category_id = CategoryID::new(&id.to_string());
let category = Category {
category_id: category_id.clone(),
label: title,
};
let category_mapping = CategoryMapping {
parent_id: NEWSFLASH_TOPLEVEL.clone(),
category_id,
sort_index,
};
(category, category_mapping)
}
fn convert_category_vec(mut categories: Vec<FeverCategory>) -> (Vec<Category>, Vec<CategoryMapping>) {
categories
.drain(..)
.enumerate()
.map(|(i, c)| Self::convert_category(c, Some(i as i32)))
.unzip()
}
fn convert_feed(feed: FeverFeed) -> Feed {
let FeverFeed {
id,
favicon_id: _,
title,
url,
site_url,
is_spark: _,
last_updated_on_time: _,
} = feed;
Feed {
feed_id: FeedID::new(&id.to_string()),
label: title,
website: site_url.and_then(|url| Url::parse(&url).ok()),
feed_url: Url::parse(&url).ok(),
icon_url: None,
error_count: 0,
error_message: None,
}
}
fn convert_feed_vec(mut feeds: Vec<FeverFeed>, feeds_groups: Vec<FeedsGroups>) -> (Vec<Feed>, Vec<FeedMapping>) {
let mut group_mapping = HashMap::new();
for group in feeds_groups {
for feed_id in group.feed_ids {
group_mapping.insert(feed_id, group.group_id);
}
}
let mut mappings: Vec<FeedMapping> = Vec::new();
let feeds = feeds
.drain(..)
.enumerate()
.map(|(i, f)| {
mappings.push(FeedMapping {
feed_id: FeedID::new(&f.id.to_string()),
category_id: group_mapping
.get(&f.id)
.map(|id| CategoryID::new(&id.to_string()))
.unwrap_or_else(|| NEWSFLASH_TOPLEVEL.clone()),
sort_index: Some(i as i32),
});
Self::convert_feed(f)
})
.collect();
(feeds, mappings)
}
fn convert_entry(entry: FeverEntry, portal: Arc<Box<dyn Portal>>) -> FatArticle {
let FeverEntry {
id,
feed_id,
title,
author,
html,
url,
is_saved,
is_read,
created_on_time,
} = entry;
let article_id = ArticleID::new(&id.to_string());
let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
let plain_text = if article_exists_locally {
None
} else {
Some(util::html2text::html2text(&html))
};
let summary = plain_text.as_deref().map(util::html2text::text2summary);
let thumbnail_url = crate::util::thumbnail::extract_thumbnail(&html);
FatArticle {
article_id,
title: match escaper::decode_html(&title) {
Ok(title) => Some(title),
Err(error) => {
tracing::warn!(?error.kind, %error.position, "Failed to decode html");
Some(title)
}
},
author: if author.is_empty() { None } else { Some(author) },
feed_id: FeedID::new(&feed_id.to_string()),
url: Url::parse(&url).ok(),
date: util::timestamp_to_datetime(created_on_time),
synced: Utc::now(),
updated: None,
summary,
html: Some(html),
scraped_content: None,
direction: None,
unread: if is_read { models::Read::Read } else { models::Read::Unread },
marked: if is_saved { models::Marked::Marked } else { models::Marked::Unmarked },
plain_text,
thumbnail_url,
}
}
fn convert_entry_vec(entries: Vec<FeverEntry>, feed_ids: &HashSet<FeedID>, portal: Arc<Box<dyn Portal>>) -> Vec<FatArticle> {
entries
.into_iter()
.filter_map(|e| {
let feed_ids = feed_ids.clone();
let portal = portal.clone();
let feed_id = FeedID::new(&e.feed_id.to_string());
if feed_ids.contains(&feed_id) || e.is_saved {
Some(Self::convert_entry(e, portal))
} else {
None
}
})
.collect()
}
pub async fn get_articles(
&self,
api: &Arc<FeverApi>,
item_ids: Vec<u64>,
client: &Client,
feeds: &[Feed],
) -> Result<Vec<FatArticle>, FeverApiError> {
let batch_size: usize = 50;
let mut tasks = Vec::new();
let feed_ids: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
let item_id_chunks = item_ids.chunks(batch_size);
for chunk in item_id_chunks {
let feed_ids = feed_ids.clone();
let client = client.clone();
let chunk = chunk.to_vec();
let api = api.clone();
let portal = self.portal.clone();
let task = tokio::spawn(async move {
let entries = api.get_items_with(chunk.to_vec(), &client).await?;
let converted_articles = Self::convert_entry_vec(entries.items, &feed_ids, portal);
Ok::<Vec<FatArticle>, FeedApiError>(converted_articles)
});
tasks.push(task);
}
let articles = future::join_all(tasks)
.await
.into_iter()
.filter_map(|res| if let Ok(Ok(v)) = res { Some(v) } else { None })
.flatten()
.collect();
Ok(articles)
}
fn ids_to_fever_ids(ids: &[ArticleID]) -> Vec<u64> {
ids.iter().filter_map(|article_id| article_id.to_string().parse::<u64>().ok()).collect()
}
}
#[async_trait]
impl FeedApi for Fever {
fn features(&self) -> FeedApiResult<PluginCapabilities> {
Ok(PluginCapabilities::SUPPORT_CATEGORIES)
}
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.get_url() {
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.get_user_name()
}
async fn get_login_data(&self) -> Option<LoginData> {
if self.has_user_configured().unwrap_or(false) {
let user = self.config.get_user_name();
let password = self.config.get_password();
if let (Some(user), Some(password)) = (user, password) {
return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
id: FeverMetadata::get_id(),
url: self.config.get_url(),
user,
password,
basic_auth: None, })));
}
}
None
}
async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
if let LoginData::Direct(DirectLogin::Password(data)) = data
&& let Some(url_string) = data.url.clone()
{
let url = Url::parse(&url_string)?;
let api = if let Some(basic_auth) = &data.basic_auth {
FeverApi::new_with_http_auth(&url, &data.user, &data.password, &basic_auth.user, basic_auth.password.as_deref())
} else {
FeverApi::new(&url, &data.user, &data.password)
};
self.config.set_url(&url_string);
self.config.set_password(&data.password);
self.config.set_user_name(&data.user);
self.config
.set_http_user_name(data.basic_auth.as_ref().map(|auth| auth.user.clone()).as_deref());
self.config.set_http_password(data.basic_auth.and_then(|auth| auth.password).as_deref());
self.config.write()?;
let valid = api.valid_credentials(client).await?;
if valid {
self.api = Some(Arc::new(api));
self.logged_in = true;
return Ok(());
}
}
self.logged_in = false;
self.api = None;
Err(FeedApiError::Login)
}
async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
self.config.delete()?;
Ok(())
}
async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
if let Some(api) = &self.api {
let categories = api.get_groups(client);
let feeds = api.get_feeds(client);
let starred_ids = api.get_saved_items(client);
let unread_ids = api.get_unread_items(client);
let entries = api.get_items(client);
let (categories, feeds, starred_ids, unread_ids, entries) = futures::try_join!(categories, feeds, starred_ids, unread_ids, entries)?;
let (categories, category_mappings) = Self::convert_category_vec(categories.groups);
let (feeds, feed_mappings) = Self::convert_feed_vec(feeds.feeds, feeds.feeds_groups);
let feed_ids: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
let mut articles: Vec<FatArticle> = Vec::new();
let mut starred = self.get_articles(api, starred_ids.saved_item_ids, client, &feeds).await?;
articles.append(&mut starred);
let mut unread = self.get_articles(api, unread_ids.unread_item_ids, client, &feeds).await?;
articles.append(&mut unread);
let mut read_articles = Self::convert_entry_vec(entries.items, &feed_ids, self.portal.clone());
articles.append(&mut read_articles);
return Ok(SyncResult {
feeds: util::vec_to_option(feeds),
categories: util::vec_to_option(categories),
feed_mappings: util::vec_to_option(feed_mappings),
category_mappings: util::vec_to_option(category_mappings),
tags: None,
taggings: None,
headlines: None,
articles: util::vec_to_option(articles),
enclosures: None,
});
}
Err(FeedApiError::Login)
}
async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
if let Some(api) = &self.api {
let categories = api.get_groups(client);
let feeds = api.get_feeds(client);
let fever_unread_ids = api.get_unread_items(client);
let fever_marked_ids = api.get_saved_items(client);
let (categories, feeds, fever_unread_ids, fever_marked_ids) = futures::try_join!(categories, feeds, fever_unread_ids, fever_marked_ids)?;
let (categories, category_mappings) = Self::convert_category_vec(categories.groups);
let (feeds, feed_mappings) = Self::convert_feed_vec(feeds.feeds, feeds.feeds_groups);
let feed_ids: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
let mut articles: Vec<FatArticle> = Vec::new();
let mut headlines: Vec<Headline> = Vec::new();
let local_unread_ids = self.portal.get_article_ids_unread_all()?;
let local_unread_ids = Self::ids_to_fever_ids(&local_unread_ids);
let local_unread_ids = local_unread_ids.into_iter().collect();
let local_marked_ids = self.portal.get_article_ids_marked_all()?;
let local_marked_ids = Self::ids_to_fever_ids(&local_marked_ids);
let local_marked_ids = local_marked_ids.into_iter().collect();
let fever_unread_ids: HashSet<u64> = fever_unread_ids.unread_item_ids.into_iter().collect();
let fever_marked_ids: HashSet<u64> = fever_marked_ids.saved_item_ids.into_iter().collect();
let missing_unread_ids = fever_unread_ids.difference(&local_unread_ids).cloned().collect();
let missing_marked_ids = fever_marked_ids.difference(&local_marked_ids).cloned().collect();
let missing_unread_articles = self.get_articles(api, missing_unread_ids, client, &feeds);
let missing_marked_articles = self.get_articles(api, missing_marked_ids, client, &feeds);
let entries = api.get_items(client);
let (mut missing_unread_articles, mut missing_marked_articles, latest_entries) =
futures::try_join!(missing_unread_articles, missing_marked_articles, entries)?;
let mut latest_articles = Self::convert_entry_vec(latest_entries.items, &feed_ids, self.portal.clone());
articles.append(&mut missing_unread_articles);
articles.append(&mut missing_marked_articles);
articles.append(&mut latest_articles);
let mut should_mark_read_headlines = local_unread_ids
.difference(&fever_unread_ids)
.cloned()
.map(|id| Headline {
article_id: ArticleID::new(&id.to_string()),
unread: Read::Read,
marked: if fever_marked_ids.contains(&id) {
Marked::Marked
} else {
Marked::Unmarked
},
})
.collect();
headlines.append(&mut should_mark_read_headlines);
let mut mark_headlines = fever_marked_ids
.iter()
.map(|id| Headline {
article_id: ArticleID::new(&id.to_string()),
marked: Marked::Marked,
unread: if fever_unread_ids.contains(id) { Read::Unread } else { Read::Read },
})
.collect();
headlines.append(&mut mark_headlines);
let mut missing_unmarked_headlines = local_marked_ids
.difference(&fever_marked_ids)
.cloned()
.map(|id| Headline {
article_id: ArticleID::new(&id.to_string()),
marked: Marked::Unmarked,
unread: if fever_unread_ids.contains(&id) { Read::Unread } else { Read::Read },
})
.collect();
headlines.append(&mut missing_unmarked_headlines);
return Ok(SyncResult {
feeds: util::vec_to_option(feeds),
categories: util::vec_to_option(categories),
feed_mappings: util::vec_to_option(feed_mappings),
category_mappings: util::vec_to_option(category_mappings),
tags: None,
taggings: None,
headlines: util::vec_to_option(headlines),
articles: util::vec_to_option(articles),
enclosures: None,
});
}
Err(FeedApiError::Login)
}
async fn fetch_feed(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
Err(FeedApiError::Unsupported)
}
async fn set_article_read(&self, articles: &[ArticleID], read: Read, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let entries = Self::ids_to_fever_ids(articles);
let status = match read {
models::Read::Read => ItemStatus::Read,
models::Read::Unread => ItemStatus::Unread,
};
for entry in entries {
api.mark_item(status, entry, client).await?;
}
return Ok(());
}
Err(FeedApiError::Login)
}
async fn set_article_marked(&self, articles: &[ArticleID], marked: Marked, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
for article in articles {
if let Ok(entry_id) = article.as_str().parse::<i64>() {
match marked {
models::Marked::Marked => api.mark_item(ItemStatus::Saved, entry_id.try_into().unwrap(), client).await?,
models::Marked::Unmarked => api.mark_item(ItemStatus::Unsaved, entry_id.try_into().unwrap(), client).await?,
}
};
}
return Ok(());
}
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();
for feed in feeds {
let id = feed.to_string().parse::<i64>().unwrap();
api.mark_feed(ItemStatus::Read, id, last_sync, client).await?;
}
return Ok(());
}
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();
for category in categories {
let id = category.to_string().parse::<i64>().unwrap();
api.mark_group(ItemStatus::Read, id, last_sync, client).await?;
}
return Ok(());
}
Err(FeedApiError::Login)
}
async fn set_tag_read(&self, _tags: &[TagID], _articles: &[ArticleID], _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
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();
api.mark_group(ItemStatus::Read, 0, last_sync.timestamp(), client).await?;
return Ok(());
}
Err(FeedApiError::Login)
}
async fn add_feed(
&self,
_url: &Url,
_title: Option<String>,
_category_id: Option<CategoryID>,
_client: &Client,
) -> FeedApiResult<(Feed, Option<Category>)> {
Err(FeedApiError::Unsupported)
}
async fn remove_feed(&self, _id: &FeedID, _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
async fn move_feed(&self, _feed_id: &FeedID, _from: &CategoryID, _to: &CategoryID, _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
async fn rename_feed(&self, _feed_id: &FeedID, _new_title: &str, _client: &Client) -> FeedApiResult<FeedID> {
Err(FeedApiError::Unsupported)
}
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> {
Err(FeedApiError::Unsupported)
}
async fn remove_category(&self, _id: &CategoryID, _remove_children: bool, _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
async fn rename_category(&self, _id: &CategoryID, _new_title: &str, _client: &Client) -> FeedApiResult<CategoryID> {
Err(FeedApiError::Unsupported)
}
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<()> {
Err(FeedApiError::Unsupported)
}
async fn add_tag(&self, _title: &str, _client: &Client) -> FeedApiResult<TagID> {
Err(FeedApiError::Unsupported)
}
async fn remove_tag(&self, _id: &TagID, _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
async fn rename_tag(&self, _id: &TagID, _new_title: &str, _client: &Client) -> FeedApiResult<TagID> {
Err(FeedApiError::Unsupported)
}
async fn tag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
async fn untag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
Err(FeedApiError::Unsupported)
}
async fn get_favicon(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
if let Some(api) = &self.api {
let fever_feed_id = feed_id.to_string().parse::<u64>().map_err(|e| FeedApiError::Api {
message: format!("Failed to parse feed id {e}"),
})?;
let feeds = api.get_feeds(client).await?;
let mut favicon_id = 0;
for feed in feeds.feeds {
if feed.id == fever_feed_id {
favicon_id = feed.favicon_id;
}
}
let favicon_set = api.get_favicons(client).await?.favicons;
if let Some(favicon) = favicon_set.get(&favicon_id)
&& let Some(start) = favicon.data.find(',')
{
let data = base64_std.decode(&favicon.data[start + 1..]).map_err(|_| FeedApiError::Encryption)?;
let favicon = FavIcon {
feed_id: feed_id.clone(),
expires: Utc::now() + Duration::try_days(EXPIRES_AFTER_DAYS).unwrap(),
format: Some(favicon.mime_type.clone()),
etag: None,
source_url: None,
data: Some(data),
};
return Ok(favicon);
}
}
Err(FeedApiError::Login)
}
}