pub mod config;
pub mod metadata;
use std::collections::HashSet;
use self::config::AccountConfig;
use self::metadata::CommafeedMetadata;
use crate::error::FeedApiError;
use crate::feed_api::{FeedApi, FeedApiResult, FeedHeaderMap, Portal};
use crate::models::{
ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, Direction, Enclosure, FatArticle, FavIcon, Feed, FeedConversionResult, FeedID,
FeedMapping, FeedUpdateResult, Headline, LoginData, Marked, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, Read, StreamConversionResult,
SyncResult, Tag, TagID, Tagging, Url,
};
use crate::util;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use commafeed_api::{ApiError as CommafeedError, Category as CommafeedCategory, CommafeedApi, Entries, MarkRequest, StarRequest, TagRequest};
use feed_rs::parser;
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderValue};
impl From<CommafeedError> for FeedApiError {
fn from(error: CommafeedError) -> FeedApiError {
match error {
CommafeedError::Url(e) => FeedApiError::Url(e),
CommafeedError::Json { source, json } => FeedApiError::Json { source, json },
CommafeedError::Http(e) => FeedApiError::Network(e),
CommafeedError::Parse => FeedApiError::Api {
message: CommafeedError::Parse.to_string(),
},
}
}
}
pub struct Commafeed {
api: Option<CommafeedApi>,
portal: Box<dyn Portal>,
config: AccountConfig,
}
impl Commafeed {
fn parse_tree(toplevel: CommafeedCategory, base_url: &Url) -> FeedConversionResult {
let mut feeds = Vec::<Feed>::new();
let mut feed_mappings = Vec::<FeedMapping>::new();
let mut categories = Vec::<Category>::new();
let mut category_mappings = Vec::<CategoryMapping>::new();
Self::parse_category(
toplevel,
base_url,
&mut feeds,
&mut feed_mappings,
&mut categories,
&mut category_mappings,
);
FeedConversionResult {
feeds,
categories,
feed_mappings,
category_mappings,
}
}
fn parse_category(
comma_category: CommafeedCategory,
base_url: &Url,
feeds: &mut Vec<Feed>,
feed_mappings: &mut Vec<FeedMapping>,
categories: &mut Vec<Category>,
category_mappings: &mut Vec<CategoryMapping>,
) {
let category_id = if comma_category.id.as_str() == "all" {
NEWSFLASH_TOPLEVEL.clone()
} else {
CategoryID::new(comma_category.id.as_str())
};
for (index, feed) in comma_category.feeds.into_iter().enumerate() {
let feed_id = FeedID::new(&feed.subscription_id.to_string());
feeds.push(Feed {
feed_id: feed_id.clone(),
label: feed.name,
website: Url::parse(&feed.feed_link).ok(),
feed_url: Url::parse(&feed.feed_url).ok(),
icon_url: base_url.join(&feed.icon_url).map(Url::new).ok(),
error_count: feed.error_count,
error_message: feed.message,
});
feed_mappings.push(FeedMapping {
feed_id,
category_id: category_id.clone(),
sort_index: Some(index as i32),
});
}
for (index, category) in comma_category.children.into_iter().enumerate() {
let iter_category_id = CategoryID::new(&category.id);
categories.push(Category {
category_id: iter_category_id.clone(),
label: category.name.clone(),
});
category_mappings.push(CategoryMapping {
parent_id: category_id.clone(),
category_id: iter_category_id,
sort_index: Some(index as i32),
});
Self::parse_category(category, base_url, feeds, feed_mappings, categories, category_mappings);
}
}
fn convert_entries(entries: Entries, portal: &dyn Portal) -> StreamConversionResult {
let mut articles = Vec::new();
let mut taggings = Vec::new();
let mut enclosures = Vec::new();
for entry in entries.entries {
let article_id = ArticleID::new(&entry.id);
let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
let plain_text = if article_exists_locally {
None
} else {
entry.content.as_deref().map(util::html2text::html2text)
};
let summary = plain_text.as_deref().map(util::html2text::text2summary);
let thumbnail_url = if entry.media_thumbnail_url.is_some() {
entry.media_thumbnail_url.clone()
} else {
entry.content.as_deref().and_then(crate::util::thumbnail::extract_thumbnail)
};
if let Some(url) = entry.enclosure_url.and_then(|url| Url::parse(&url).ok()) {
enclosures.push(Enclosure {
article_id: article_id.clone(),
url,
mime_type: entry.enclosure_type,
title: entry.media_description,
position: None,
summary: None,
thumbnail_url: entry.media_thumbnail_url,
filesize: None,
width: None,
height: None,
duration: None,
framerate: None,
alternative: None,
is_default: false,
});
}
for tag in entry.tags {
taggings.push(Tagging {
article_id: article_id.clone(),
tag_id: TagID::new(&tag),
});
}
articles.push(FatArticle {
article_id,
title: Some(entry.title),
author: entry.author,
feed_id: FeedID::new(&entry.feed_id),
url: Url::parse(&entry.url).ok(),
date: DateTime::from_timestamp_millis(entry.date).unwrap_or(Utc::now()),
synced: Utc::now(),
updated: None,
html: entry.content,
summary,
direction: Some(if entry.rtl { Direction::RightToLeft } else { Direction::LeftToRight }),
unread: if entry.read { Read::Read } else { Read::Unread },
marked: if entry.starred { Marked::Marked } else { Marked::Unmarked },
scraped_content: None,
plain_text,
thumbnail_url,
});
}
StreamConversionResult {
articles,
headlines: Vec::new(),
taggings,
enclosures,
}
}
fn convert_tags(tags: Vec<String>) -> Vec<Tag> {
tags.into_iter()
.enumerate()
.map(|(i, t)| Tag {
tag_id: TagID::new(&t),
label: t,
color: None,
sort_index: Some(i as i32),
})
.collect::<Vec<_>>()
}
async fn fetch_articles(&self, category: &str, limit: u32, read: bool, client: &Client) -> FeedApiResult<StreamConversionResult> {
if let Some(api) = self.api.as_ref() {
let mut offset = 0;
let mut result = StreamConversionResult::new();
loop {
let entries = api
.get_category_entries(
category,
read,
None,
Some(offset),
Some(limit as i32),
None,
None,
None,
None,
None,
client,
)
.await?;
let done = !entries.has_more;
let converted_entries = Self::convert_entries(entries, self.portal.as_ref());
result.add(converted_entries);
if done {
break;
} else {
offset += limit as i32;
}
}
Ok(result)
} else {
Err(FeedApiError::Login)
}
}
}
#[async_trait]
impl FeedApi for Commafeed {
fn features(&self) -> FeedApiResult<PluginCapabilities> {
Ok(PluginCapabilities::ADD_REMOVE_FEEDS
| PluginCapabilities::SUPPORT_CATEGORIES
| PluginCapabilities::MODIFY_CATEGORIES
| PluginCapabilities::SUPPORT_SUBCATEGORIES
| 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.get_url() {
let url = url.trim_end_matches("rest/");
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> {
match &self.api {
None => Ok(false),
Some(api) => {
_ = api.get_profile(client).await?;
Ok(true)
}
}
}
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: CommafeedMetadata::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(password_data)) = data {
let api = if let Some(mut url_string) = password_data.url.clone() {
if !url_string.ends_with('/') {
url_string.push('/');
}
if !url_string.ends_with("rest/") {
url_string.push_str("rest/");
}
self.config.set_url(&url_string);
self.config.set_password(&password_data.password);
self.config.set_user_name(&password_data.user);
let url = Url::parse(&url_string)?;
let api = CommafeedApi::new(&url, &password_data.user, &password_data.password);
let profile = api.get_profile(client).await?;
tracing::debug!(%profile.name, "logged in");
api
} else {
tracing::error!("No URL set");
return Err(FeedApiError::Login);
};
if self.config.get_user_name().is_none() {
let profile = api.get_profile(client).await?;
self.config.set_user_name(&profile.name);
}
self.config.save()?;
self.api = Some(api);
return Ok(());
}
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.as_ref() {
let base_url = self.config.get_icon_base_url()?;
let tree = api.get_category_tree(client);
let tags = api.get_tags(client);
let unread_result = self.fetch_articles("all", 999, false, client);
let starred_result = self.fetch_articles("starred", 999, true, client);
let (tree, tags, unread_result, starred_result) = futures::join!(tree, tags, unread_result, starred_result);
let mut unread_result = unread_result?;
let starred_result = starred_result?;
let converted_tree = Self::parse_tree(tree?, &base_url);
let tags = Self::convert_tags(tags?);
unread_result.add(starred_result);
Ok(SyncResult {
feeds: util::vec_to_option(converted_tree.feeds),
categories: util::vec_to_option(converted_tree.categories),
feed_mappings: util::vec_to_option(converted_tree.feed_mappings),
category_mappings: util::vec_to_option(converted_tree.category_mappings),
tags: util::vec_to_option(tags),
taggings: util::vec_to_option(unread_result.taggings),
headlines: None,
articles: util::vec_to_option(unread_result.articles),
enclosures: util::vec_to_option(unread_result.enclosures),
})
} else {
Err(FeedApiError::Login)
}
}
async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
if let Some(api) = self.api.as_ref() {
let base_url = self.config.get_icon_base_url()?;
let max_count = self.portal.get_config().read().await.get_sync_amount();
let mut result = StreamConversionResult::new();
let tree = api.get_category_tree(client);
let tags = api.get_tags(client);
let unread_result = self.fetch_articles("all", max_count, false, client);
let starred_result = self.fetch_articles("starred", max_count, true, client);
let recent_entries = api.get_category_entries("all", true, None, None, Some(max_count as i32), None, None, None, None, None, client);
let (tree, tags, unread_result, starred_result, recent_entries) =
futures::join!(tree, tags, unread_result, starred_result, recent_entries);
let unread_result = unread_result?;
let starred_result = starred_result?;
let recent_entries = recent_entries?;
let converted_tree = Self::parse_tree(tree?, &base_url);
let tags = Self::convert_tags(tags?);
let converted_recent_entries = Self::convert_entries(recent_entries, self.portal.as_ref());
result.add(converted_recent_entries);
let local_unread_ids = self.portal.get_article_ids_unread_all()?;
let local_marked_ids = self.portal.get_article_ids_marked_all()?;
let local_unread_ids: HashSet<ArticleID> = local_unread_ids.into_iter().collect();
let local_marked_ids: HashSet<ArticleID> = local_marked_ids.into_iter().collect();
let remote_unread_ids: HashSet<ArticleID> = unread_result.articles.iter().map(|a| &a.article_id).cloned().collect();
let remote_starred_ids: HashSet<ArticleID> = starred_result.articles.iter().map(|a| &a.article_id).cloned().collect();
let mut should_mark_read_headlines = local_unread_ids
.difference(&remote_unread_ids)
.map(|id| Headline {
article_id: ArticleID::new(&id.to_string()),
unread: Read::Read,
marked: if remote_starred_ids.contains(id) {
Marked::Marked
} else {
Marked::Unmarked
},
})
.collect();
result.headlines.append(&mut should_mark_read_headlines);
let mut missing_unmarked_headlines = local_marked_ids
.difference(&remote_starred_ids)
.map(|id| Headline {
article_id: ArticleID::new(&id.to_string()),
marked: Marked::Unmarked,
unread: if remote_unread_ids.contains(id) { Read::Unread } else { Read::Read },
})
.collect();
result.headlines.append(&mut missing_unmarked_headlines);
Ok(SyncResult {
feeds: util::vec_to_option(converted_tree.feeds),
categories: util::vec_to_option(converted_tree.categories),
feed_mappings: util::vec_to_option(converted_tree.feed_mappings),
category_mappings: util::vec_to_option(converted_tree.category_mappings),
tags: util::vec_to_option(tags),
taggings: util::vec_to_option(result.taggings),
headlines: util::vec_to_option(result.headlines),
articles: util::vec_to_option(result.articles),
enclosures: util::vec_to_option(result.enclosures),
})
} else {
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.as_ref() {
let comma_feed_id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {feed_id}"),
})?;
let base_url = self.config.get_icon_base_url()?;
let feed = api.fetch_feed(comma_feed_id, client).await?;
let feed = Feed {
feed_id: feed_id.clone(),
label: feed.name,
website: Url::parse(&feed.feed_link).ok(),
feed_url: Url::parse(&feed.feed_url).ok(),
icon_url: base_url.join(&feed.icon_url).map(Url::new).ok(),
error_count: feed.error_count,
error_message: feed.message,
};
let entries = api
.get_feed_entries(feed_id.as_str(), true, None, None, None, None, None, None, client)
.await?;
let converted_entries = Self::convert_entries(entries, self.portal.as_ref());
Ok(FeedUpdateResult {
feed: Some(feed),
taggings: util::vec_to_option(converted_entries.taggings),
articles: util::vec_to_option(converted_entries.articles),
enclosures: util::vec_to_option(converted_entries.enclosures),
})
} else {
Err(FeedApiError::Login)
}
}
async fn set_article_read(&self, articles: &[ArticleID], read: Read, client: &Client) -> FeedApiResult<()> {
if let Some(api) = self.api.as_ref() {
let requests = articles
.iter()
.map(|id| MarkRequest {
id: id.as_str().into(),
read: read == Read::Read,
older_than: None,
keywords: None,
excluded_subscriptions: None,
})
.collect();
api.mark_multiple_entries_read(requests, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn set_article_marked(&self, articles: &[ArticleID], marked: Marked, client: &Client) -> FeedApiResult<()> {
if let Some(api) = self.api.as_ref() {
let requests = articles
.iter()
.map(|id| StarRequest {
id: id.as_str().into(),
feed_id: 0,
starred: marked == Marked::Marked,
})
.collect::<Vec<_>>();
for request in requests {
api.mark_entry_starred(request, 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.as_ref() {
for feed in feeds {
api.mark_feed_read(feed.as_str(), true, None, None, None, 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.as_ref() {
for category in categories {
api.mark_category_read(category.as_str(), true, None, None, None, 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.as_ref() {
let requests = articles
.iter()
.map(|id| MarkRequest {
id: id.as_str().into(),
read: true,
older_than: None,
keywords: None,
excluded_subscriptions: None,
})
.collect();
api.mark_multiple_entries_read(requests, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
if let Some(api) = self.api.as_ref() {
let requests = articles
.iter()
.map(|id| MarkRequest {
id: id.as_str().into(),
read: true,
older_than: None,
keywords: None,
excluded_subscriptions: None,
})
.collect();
api.mark_multiple_entries_read(requests, 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.as_ref() {
let feed = if let Some(title) = title.as_deref() {
let feed_id = api
.subscribe_to_feed(url.as_str(), title, category_id.as_ref().map(|id| id.to_string()).as_deref(), client)
.await?;
Feed {
feed_id: FeedID::new(&feed_id.to_string()),
label: title.to_owned(),
website: None,
feed_url: Some(url.clone()),
icon_url: None,
error_count: 0,
error_message: None,
}
} else {
let feed_id = api.subscribe_to_feed_simple(url.as_str(), client).await?;
let feed_response = client.get(url.as_str()).send().await?.error_for_status()?;
let result_bytes = feed_response
.bytes()
.await
.inspect_err(|error| tracing::error!(%url, %error, "Reading response as bytes failed"))?;
let parser = parser::Builder::new().base_uri(Some(url)).build();
let feed = parser.parse(result_bytes.as_ref())?;
let mut feed = Feed::from_feed_rs(&feed, title, url);
feed.feed_id = FeedID::new(&feed_id.to_string());
feed
};
let categories = self.portal.get_categories()?;
let category = categories.iter().find(|c| Some(&c.category_id) == category_id.as_ref()).cloned();
Ok((feed, category))
} else {
Err(FeedApiError::Login)
}
}
async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = self.api.as_ref() {
let id = id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {id}"),
})?;
api.unsubscribe_from_feed(id, 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.as_ref() {
let id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {feed_id}"),
})?;
api.modify_feed(id, None, Some(to.as_str()), None, 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.as_ref() {
let id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {feed_id}"),
})?;
api.modify_feed(id, 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.as_ref() {
let id = api.create_category(title, parent.map(|id| id.to_string()).as_deref(), client).await?;
Ok(CategoryID::new(&id.to_string()))
} else {
Err(FeedApiError::Login)
}
}
async fn remove_category(&self, id: &CategoryID, _remove_children: bool, client: &Client) -> FeedApiResult<()> {
if let Some(api) = self.api.as_ref() {
let id = id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {id}"),
})?;
api.delete_category(id, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn rename_category(&self, category_id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
if let Some(api) = self.api.as_ref() {
let id = category_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {category_id}"),
})?;
api.modify_category(id, Some(new_title), None, None, client).await?;
Ok(category_id.clone())
} else {
Err(FeedApiError::Login)
}
}
async fn move_category(&self, category_id: &CategoryID, parent: &CategoryID, client: &Client) -> FeedApiResult<()> {
if let Some(api) = self.api.as_ref() {
let id = category_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {category_id}"),
})?;
api.modify_category(id, None, Some(parent.as_str()), None, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
if let Some(api) = self.api.as_ref() {
api.import_opml(opml, client).await?;
Ok(())
} else {
Err(FeedApiError::Login)
}
}
async fn add_tag(&self, title: &str, _client: &Client) -> FeedApiResult<TagID> {
Ok(TagID::new(title))
}
async fn remove_tag(&self, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
let taggings = self.portal.get_taggings(None, Some(tag_id))?;
for tagging in taggings {
self.untag_article(&tagging.article_id, tag_id, client).await?;
}
Ok(())
}
async fn rename_tag(&self, tag_id: &TagID, new_title: &str, client: &Client) -> FeedApiResult<TagID> {
if let Some(api) = self.api.as_ref() {
let taggings = self.portal.get_taggings(None, Some(tag_id))?;
for tagging in taggings {
let entry_id = tagging.article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {}", tagging.article_id),
})?;
let article_taggings = self.portal.get_taggings(Some(&tagging.article_id), None)?;
let mut tags = article_taggings
.into_iter()
.filter_map(|tagging| {
if &tagging.tag_id != tag_id {
Some(tagging.tag_id.to_string())
} else {
None
}
})
.collect::<Vec<_>>();
tags.push(new_title.into());
let request = TagRequest { entry_id, tags };
api.set_tags(request, client).await?;
}
Ok(TagID::new(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.as_ref() {
let entry_id = article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {article_id}"),
})?;
let taggings = self.portal.get_taggings(Some(article_id), None)?;
let mut tags = taggings.into_iter().map(|tagging| tagging.tag_id.to_string()).collect::<Vec<_>>();
tags.push(tag_id.to_string());
let request = TagRequest { entry_id, tags };
api.set_tags(request, 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.as_ref() {
let entry_id = article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
message: format!("Failed to parse id {article_id}"),
})?;
let taggings = self.portal.get_taggings(Some(article_id), None)?;
let tags = taggings
.into_iter()
.filter_map(|tagging| {
if &tagging.tag_id != tag_id {
Some(tagging.tag_id.to_string())
} else {
None
}
})
.collect::<Vec<_>>();
let request = TagRequest { entry_id, tags };
api.set_tags(request, 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)
}
}