mod config;
pub mod metadata;
use std::collections::HashSet;
use std::sync::Arc;
use self::config::AccountConfig;
use self::metadata::NextcloudMetadata;
use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
use crate::models::{
self, ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, Direction, Enclosure, FatArticle, FavIcon, Feed, FeedID, FeedMapping,
FeedUpdateResult, LoginData, Marked, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, Read, StreamConversionResult, SyncResult, TagID, Url,
};
use crate::util;
use async_trait::async_trait;
use chrono::Utc;
use futures::future;
use nextcloud_news_api::models::{Feed as NcFeed, Folder, Item, ItemType};
use nextcloud_news_api::{ApiError as NextcloudError, NextcloudNewsApi};
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue};
use semver::Version;
use tokio::sync::RwLock;
impl From<NextcloudError> for FeedApiError {
fn from(error: NextcloudError) -> FeedApiError {
match error {
NextcloudError::Url(e) => FeedApiError::Url(e),
NextcloudError::Json { source, json } => FeedApiError::Json { source, json },
NextcloudError::Http(e) => FeedApiError::Network(e),
NextcloudError::Parse => FeedApiError::Api {
message: NextcloudError::Parse.to_string(),
},
NextcloudError::Input => FeedApiError::Api {
message: NextcloudError::Input.to_string(),
},
NextcloudError::Unauthorized => FeedApiError::Auth,
NextcloudError::Unknown => FeedApiError::Unknown,
}
}
}
pub struct Nextcloud {
api: Option<NextcloudNewsApi>,
portal: Arc<Box<dyn Portal>>,
logged_in: bool,
config: AccountConfig,
}
impl Nextcloud {
fn ids_to_nc_ids<T: ToString>(ids: &[T]) -> Vec<i64> {
ids.iter().filter_map(|id| id.to_string().parse::<i64>().ok()).collect()
}
fn id_to_nc_id<T: ToString>(id: &T) -> Option<i64> {
id.to_string().parse::<i64>().ok()
}
fn convert_folder_vec(mut categories: Vec<Folder>) -> (Vec<Category>, Vec<CategoryMapping>) {
categories
.drain(..)
.enumerate()
.map(|(i, c)| Self::convert_folder(c, Some(i as i32)))
.unzip()
}
fn convert_folder(folder: Folder, sort_index: Option<i32>) -> (Category, CategoryMapping) {
let Folder { id, name } = folder;
let category_id = CategoryID::new(&id.to_string());
let category = Category {
category_id: category_id.clone(),
label: name,
};
let category_mapping = CategoryMapping {
parent_id: NEWSFLASH_TOPLEVEL.clone(),
category_id,
sort_index,
};
(category, category_mapping)
}
fn convert_feed(feed: NcFeed) -> Feed {
let NcFeed {
id,
url,
title,
favicon_link,
added: _,
folder_id: _,
unread_count: _,
ordering: _,
link,
pinned: _,
update_error_count,
last_update_error,
} = feed;
Feed {
feed_id: FeedID::new(&id.to_string()),
label: title,
website: link.and_then(|link| Url::parse(&link).ok()),
feed_url: Url::parse(&url).ok(),
icon_url: favicon_link.and_then(|url| Url::parse(&url).ok()),
error_count: update_error_count as i32,
error_message: last_update_error,
}
}
fn convert_feed_vec(mut feeds: Vec<NcFeed>) -> (Vec<Feed>, Vec<FeedMapping>) {
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: f
.folder_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)
}
async fn convert_item_vec(items: Vec<Item>, feed_ids: &HashSet<FeedID>, portal: Arc<Box<dyn Portal>>) -> StreamConversionResult {
let enclosures: Arc<RwLock<Vec<Enclosure>>> = Arc::new(RwLock::new(Vec::new()));
let tasks = items
.into_iter()
.map(|i| {
let feed_ids = feed_ids.clone();
let portal = portal.clone();
let enclosures = enclosures.clone();
tokio::spawn(async move {
if feed_ids.contains(&FeedID::new(&i.feed_id.to_string())) || i.starred {
let (article, enclousre) = Self::convert_item(i, portal);
if let Some(enclosure) = enclousre {
enclosures.write().await.push(enclosure);
}
Some(article)
} else {
None
}
})
})
.collect::<Vec<_>>();
let articles = future::join_all(tasks).await.into_iter().filter_map(|res| res.ok().flatten()).collect();
StreamConversionResult {
articles,
headlines: Vec::new(),
taggings: Vec::new(),
enclosures: Arc::into_inner(enclosures).map(|e| e.into_inner()).unwrap_or_default(),
}
}
fn convert_item(item: Item, portal: Arc<Box<dyn Portal>>) -> (FatArticle, Option<Enclosure>) {
let Item {
id,
guid: _,
guid_hash: _,
url,
title,
author,
pub_date,
body,
enclosure_mime,
enclosure_link,
media_thumbnail,
media_description,
feed_id,
unread,
starred,
rtl,
last_modified: _,
fingerprint: _,
} = item;
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(&body))
};
let summary = plain_text.as_deref().map(util::html2text::text2summary);
let thumbnail_url = if let Some(media_thumbnail) = &media_thumbnail {
Some(media_thumbnail.clone())
} else {
crate::util::thumbnail::extract_thumbnail(&body)
};
let article = FatArticle {
article_id: article_id.clone(),
title,
author,
feed_id: FeedID::new(&feed_id.to_string()),
url: url.and_then(|url| Url::parse(&url).ok()),
date: util::timestamp_to_datetime(pub_date),
synced: Utc::now(),
updated: None,
summary,
html: Some(body),
direction: Some(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }),
unread: if unread { Read::Unread } else { Read::Read },
marked: if starred { Marked::Marked } else { Marked::Unmarked },
scraped_content: None,
plain_text,
thumbnail_url,
};
let enclosure = enclosure_link.and_then(|enc_url| {
Url::parse(&enc_url).ok().map(|url| Enclosure {
article_id,
url,
mime_type: enclosure_mime,
title: None,
position: None,
summary: media_description,
thumbnail_url: media_thumbnail,
filesize: None,
width: None,
height: None,
duration: None,
framerate: None,
alternative: None,
is_default: false,
})
});
(article, enclosure)
}
}
#[async_trait]
impl FeedApi for Nextcloud {
fn features(&self) -> FeedApiResult<PluginCapabilities> {
Ok(PluginCapabilities::ADD_REMOVE_FEEDS | PluginCapabilities::SUPPORT_CATEGORIES | PluginCapabilities::MODIFY_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(api) = &self.api {
let _version = api.get_version(client).await?;
Ok(true)
} 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 username = self.config.get_user_name();
let password = self.config.get_password();
if let (Some(username), Some(password)) = (username, password) {
return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
id: NextcloudMetadata::get_id(),
url: self.config.get_url(),
user: username,
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 = NextcloudNewsApi::new(&url, data.user.clone(), data.password.clone())?;
let nextcloud_news_api::models::Version { version: version_string } = api.get_version(client).await?;
let semver = Version::parse(&version_string).map_err(|_| {
tracing::error!(%version_string,"Failed to parse version string: {version_string}");
FeedApiError::Login
})?;
let min_version = Version::new(18, 1, 1);
if semver < min_version {
tracing::error!("Nextcloud News app is version {semver}. Minimal required version is {min_version}.");
return Err(FeedApiError::UnsupportedVersion {
min_supported: min_version,
found: Some(semver),
});
}
self.config.set_url(&url_string);
self.config.set_password(&data.password);
self.config.set_user_name(&data.user);
self.config.write()?;
self.api = Some(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()
}
async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
if let Some(api) = &self.api {
let folders = api.get_folders(client);
let feeds = api.get_feeds(client);
let unread_items = api.get_items(client, -1, None, None, None, Some(false), None);
let starred_items = api.get_items(client, -1, None, Some(ItemType::Starred), None, None, None);
let (folders, feeds, unread_items, starred_items) = futures::try_join!(folders, feeds, unread_items, starred_items)?;
let (categories, category_mappings) = Nextcloud::convert_folder_vec(folders);
let (feeds, feed_mappings) = Nextcloud::convert_feed_vec(feeds);
let feed_id_set: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
let mut articles: Vec<FatArticle> = Vec::new();
let mut enclosures: Vec<Enclosure> = Vec::new();
let mut unread = Self::convert_item_vec(unread_items, &feed_id_set, self.portal.clone()).await;
articles.append(&mut unread.articles);
enclosures.append(&mut unread.enclosures);
let mut starred = Self::convert_item_vec(starred_items, &feed_id_set, self.portal.clone()).await;
articles.append(&mut starred.articles);
enclosures.append(&mut starred.enclosures);
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: util::vec_to_option(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() as u64;
let folders = api.get_folders(client);
let feeds = api.get_feeds(client);
let updated_items = api.get_updated_items(client, last_sync, None, None);
let (folders, feeds, updated_items) = futures::try_join!(folders, feeds, updated_items)?;
let (categories, category_mappings) = Nextcloud::convert_folder_vec(folders);
let (feeds, mappings) = Nextcloud::convert_feed_vec(feeds);
let feed_id_set: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
let conversion_result = Self::convert_item_vec(updated_items, &feed_id_set, self.portal.clone()).await;
return Ok(SyncResult {
feeds: util::vec_to_option(feeds),
categories: util::vec_to_option(categories),
feed_mappings: util::vec_to_option(mappings),
category_mappings: util::vec_to_option(category_mappings),
tags: None,
taggings: None,
headlines: None,
articles: util::vec_to_option(conversion_result.articles),
enclosures: util::vec_to_option(conversion_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 feeds = api.get_feeds(client).await?;
let (feeds, _mappings) = Nextcloud::convert_feed_vec(feeds);
let feed = feeds.iter().find(|feed| &feed.feed_id == feed_id).cloned();
let mut feed_id_set: HashSet<FeedID> = HashSet::new();
feed_id_set.insert(feed_id.clone());
let nextcloud_feed_id = feed_id.as_str().parse::<u64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {feed_id}"),
})?;
let items = api.get_items(client, -1, None, None, Some(nextcloud_feed_id), None, None).await?;
let conversion_result = Self::convert_item_vec(items, &feed_id_set, self.portal.clone()).await;
Ok(FeedUpdateResult {
feed,
taggings: None,
articles: util::vec_to_option(conversion_result.articles),
enclosures: util::vec_to_option(conversion_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 nc_ids = Self::ids_to_nc_ids(articles);
match read {
Read::Read => api.mark_items_read(client, nc_ids).await?,
Read::Unread => api.mark_items_unread(client, nc_ids).await?,
}
return Ok(());
}
Err(FeedApiError::Login)
}
async fn set_article_marked(&self, articles: &[ArticleID], marked: models::Marked, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
let nc_ids = Self::ids_to_nc_ids(articles);
match marked {
Marked::Marked => api.mark_items_starred(client, nc_ids).await?,
Marked::Unmarked => api.mark_items_unstarred(client, nc_ids).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 nc_ids = Self::ids_to_nc_ids(feeds);
let newest_unread_article_id = articles.iter().filter_map(Self::id_to_nc_id).max().unwrap_or(i64::MAX);
let mut futures = Vec::new();
for feed_id in nc_ids {
futures.push(api.mark_feed(client, feed_id, newest_unread_article_id));
}
let results = futures::future::join_all(futures).await;
let result: Result<Vec<()>, FeedApiError> = results.into_iter().map(|res| res.map_err(FeedApiError::from)).collect();
let _ = result?;
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 nc_ids = Self::ids_to_nc_ids(categories);
let newest_unread_article_id = articles.iter().filter_map(Self::id_to_nc_id).max().unwrap_or(i64::MAX);
let mut futures = Vec::new();
for folder_id in nc_ids {
futures.push(api.mark_folder(client, folder_id, newest_unread_article_id));
}
let results = futures::future::join_all(futures).await;
let result: Result<Vec<()>, FeedApiError> = results.into_iter().map(|res| res.map_err(FeedApiError::from)).collect();
let _ = result?;
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 newest_unread_article_id = articles.iter().filter_map(Self::id_to_nc_id).max().unwrap_or(i64::MAX);
let _ = api.mark_all_items_read(client, newest_unread_article_id).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>)> {
if let Some(api) = &self.api {
let folder_id = category_id.and_then(|id| Self::id_to_nc_id(&id));
let feed = api.create_feed(client, url.as_str(), folder_id).await?;
if let Some(title) = title {
api.rename_feed(client, feed.id, &title).await?;
}
let category = api
.get_folders(client)
.await?
.iter()
.find(|f| Some(f.id) == folder_id)
.map(|f| Self::convert_folder(f.clone(), None))
.map(|(c, _m)| c);
return Ok((Self::convert_feed(feed), category));
}
Err(FeedApiError::Login)
}
async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
api.delete_feed(client, Self::id_to_nc_id(id).unwrap()).await?;
return Ok(());
}
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.move_feed(client, Self::id_to_nc_id(feed_id).unwrap(), Some(Self::id_to_nc_id(to).unwrap()))
.await?;
return Ok(());
}
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.rename_feed(client, Self::id_to_nc_id(feed_id).unwrap(), new_title).await?;
return Ok(feed_id.clone());
}
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 {
let folder = api.create_folder(client, title).await?;
return Ok(CategoryID::new(&folder.id.to_string()));
}
Err(FeedApiError::Login)
}
async fn remove_category(&self, id: &CategoryID, _remove_children: bool, client: &Client) -> FeedApiResult<()> {
if let Some(api) = &self.api {
api.delete_folder(client, Self::id_to_nc_id(id).unwrap()).await?;
return Ok(());
}
Err(FeedApiError::Login)
}
async fn rename_category(&self, id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
if let Some(api) = &self.api {
api.rename_folder(client, Self::id_to_nc_id(id).unwrap(), new_title).await?;
return Ok(id.clone());
}
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<()> {
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> {
Err(FeedApiError::Unsupported)
}
}