Skip to main content

news_flash/feed_api_implementations/commafeed/
mod.rs

1pub mod config;
2pub mod metadata;
3
4use std::collections::HashSet;
5
6use self::config::AccountConfig;
7use self::metadata::CommafeedMetadata;
8use crate::error::FeedApiError;
9use crate::feed_api::{FeedApi, FeedApiResult, FeedHeaderMap, Portal};
10use crate::models::{
11    ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, Direction, Enclosure, FatArticle, FavIcon, Feed, FeedConversionResult, FeedID,
12    FeedMapping, FeedUpdateResult, Headline, LoginData, Marked, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, Read, StreamConversionResult,
13    SyncResult, Tag, TagID, Tagging, Url,
14};
15use crate::util;
16use async_trait::async_trait;
17use chrono::{DateTime, Utc};
18use commafeed_api::{ApiError as CommafeedError, Category as CommafeedCategory, CommafeedApi, Entries, MarkRequest, StarRequest, TagRequest};
19use feed_rs::parser;
20use reqwest::Client;
21use reqwest::header::{HeaderMap, HeaderValue};
22
23impl From<CommafeedError> for FeedApiError {
24    fn from(error: CommafeedError) -> FeedApiError {
25        match error {
26            CommafeedError::Url(e) => FeedApiError::Url(e),
27            CommafeedError::Json { source, json } => FeedApiError::Json { source, json },
28            CommafeedError::Http(e) => FeedApiError::Network(e),
29            CommafeedError::Parse => FeedApiError::Api {
30                message: CommafeedError::Parse.to_string(),
31            },
32        }
33    }
34}
35
36pub struct Commafeed {
37    api: Option<CommafeedApi>,
38    portal: Box<dyn Portal>,
39    config: AccountConfig,
40}
41
42impl Commafeed {
43    fn parse_tree(toplevel: CommafeedCategory, base_url: &Url) -> FeedConversionResult {
44        let mut feeds = Vec::<Feed>::new();
45        let mut feed_mappings = Vec::<FeedMapping>::new();
46        let mut categories = Vec::<Category>::new();
47        let mut category_mappings = Vec::<CategoryMapping>::new();
48
49        Self::parse_category(
50            toplevel,
51            base_url,
52            &mut feeds,
53            &mut feed_mappings,
54            &mut categories,
55            &mut category_mappings,
56        );
57
58        FeedConversionResult {
59            feeds,
60            categories,
61            feed_mappings,
62            category_mappings,
63        }
64    }
65
66    fn parse_category(
67        comma_category: CommafeedCategory,
68        base_url: &Url,
69        feeds: &mut Vec<Feed>,
70        feed_mappings: &mut Vec<FeedMapping>,
71        categories: &mut Vec<Category>,
72        category_mappings: &mut Vec<CategoryMapping>,
73    ) {
74        let category_id = if comma_category.id.as_str() == "all" {
75            NEWSFLASH_TOPLEVEL.clone()
76        } else {
77            CategoryID::new(comma_category.id.as_str())
78        };
79
80        for (index, feed) in comma_category.feeds.into_iter().enumerate() {
81            let feed_id = FeedID::new(&feed.subscription_id.to_string());
82
83            feeds.push(Feed {
84                feed_id: feed_id.clone(),
85                label: feed.name,
86                website: Url::parse(&feed.feed_link).ok(),
87                feed_url: Url::parse(&feed.feed_url).ok(),
88                icon_url: base_url.join(&feed.icon_url).map(Url::new).ok(),
89                error_count: feed.error_count,
90                error_message: feed.message,
91            });
92
93            feed_mappings.push(FeedMapping {
94                feed_id,
95                category_id: category_id.clone(),
96                sort_index: Some(index as i32),
97            });
98        }
99
100        for (index, category) in comma_category.children.into_iter().enumerate() {
101            let iter_category_id = CategoryID::new(&category.id);
102
103            categories.push(Category {
104                category_id: iter_category_id.clone(),
105                label: category.name.clone(),
106            });
107
108            category_mappings.push(CategoryMapping {
109                parent_id: category_id.clone(),
110                category_id: iter_category_id,
111                sort_index: Some(index as i32),
112            });
113
114            Self::parse_category(category, base_url, feeds, feed_mappings, categories, category_mappings);
115        }
116    }
117
118    fn convert_entries(entries: Entries, portal: &dyn Portal) -> StreamConversionResult {
119        let mut articles = Vec::new();
120        let mut taggings = Vec::new();
121        let mut enclosures = Vec::new();
122
123        for entry in entries.entries {
124            let article_id = ArticleID::new(&entry.id);
125            let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
126
127            let plain_text = if article_exists_locally {
128                None
129            } else {
130                entry.content.as_deref().map(util::html2text::html2text)
131            };
132            let summary = plain_text.as_deref().map(util::html2text::text2summary);
133            let thumbnail_url = if entry.media_thumbnail_url.is_some() {
134                entry.media_thumbnail_url.clone()
135            } else {
136                entry.content.as_deref().and_then(crate::util::thumbnail::extract_thumbnail)
137            };
138
139            if let Some(url) = entry.enclosure_url.and_then(|url| Url::parse(&url).ok()) {
140                enclosures.push(Enclosure {
141                    article_id: article_id.clone(),
142                    url,
143                    mime_type: entry.enclosure_type,
144                    title: entry.media_description,
145                    position: None,
146                    summary: None,
147                    thumbnail_url: entry.media_thumbnail_url,
148                    filesize: None,
149                    width: None,
150                    height: None,
151                    duration: None,
152                    framerate: None,
153                    alternative: None,
154                    is_default: false,
155                });
156            }
157
158            for tag in entry.tags {
159                taggings.push(Tagging {
160                    article_id: article_id.clone(),
161                    tag_id: TagID::new(&tag),
162                });
163            }
164
165            articles.push(FatArticle {
166                article_id,
167                title: Some(entry.title),
168                author: entry.author,
169                feed_id: FeedID::new(&entry.feed_id),
170                url: Url::parse(&entry.url).ok(),
171                date: DateTime::from_timestamp_millis(entry.date).unwrap_or(Utc::now()),
172                synced: Utc::now(),
173                updated: None,
174                html: entry.content,
175                summary,
176                direction: Some(if entry.rtl { Direction::RightToLeft } else { Direction::LeftToRight }),
177                unread: if entry.read { Read::Read } else { Read::Unread },
178                marked: if entry.starred { Marked::Marked } else { Marked::Unmarked },
179                scraped_content: None,
180                plain_text,
181                thumbnail_url,
182            });
183        }
184
185        StreamConversionResult {
186            articles,
187            headlines: Vec::new(),
188            taggings,
189            enclosures,
190        }
191    }
192
193    fn convert_tags(tags: Vec<String>) -> Vec<Tag> {
194        tags.into_iter()
195            .enumerate()
196            .map(|(i, t)| Tag {
197                tag_id: TagID::new(&t),
198                label: t,
199                color: None,
200                sort_index: Some(i as i32),
201            })
202            .collect::<Vec<_>>()
203    }
204
205    async fn fetch_articles(&self, category: &str, limit: u32, read: bool, client: &Client) -> FeedApiResult<StreamConversionResult> {
206        if let Some(api) = self.api.as_ref() {
207            let mut offset = 0;
208            let mut result = StreamConversionResult::new();
209
210            loop {
211                let entries = api
212                    .get_category_entries(
213                        category,
214                        read,
215                        None,
216                        Some(offset),
217                        Some(limit as i32),
218                        None,
219                        None,
220                        None,
221                        None,
222                        None,
223                        client,
224                    )
225                    .await?;
226                let done = !entries.has_more;
227
228                let converted_entries = Self::convert_entries(entries, self.portal.as_ref());
229                result.add(converted_entries);
230
231                if done {
232                    break;
233                } else {
234                    offset += limit as i32;
235                }
236            }
237
238            Ok(result)
239        } else {
240            Err(FeedApiError::Login)
241        }
242    }
243}
244
245#[async_trait]
246impl FeedApi for Commafeed {
247    fn features(&self) -> FeedApiResult<PluginCapabilities> {
248        Ok(PluginCapabilities::ADD_REMOVE_FEEDS
249            | PluginCapabilities::SUPPORT_CATEGORIES
250            | PluginCapabilities::MODIFY_CATEGORIES
251            | PluginCapabilities::SUPPORT_SUBCATEGORIES
252            | PluginCapabilities::SUPPORT_TAGS)
253    }
254
255    fn has_user_configured(&self) -> FeedApiResult<bool> {
256        Ok(self.api.is_some())
257    }
258
259    async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
260        if let Some(url) = self.config.get_url() {
261            let url = url.trim_end_matches("rest/");
262            let res = client.head(url).send().await?;
263            Ok(res.status().is_success())
264        } else {
265            Err(FeedApiError::Login)
266        }
267    }
268
269    async fn is_logged_in(&self, client: &Client) -> FeedApiResult<bool> {
270        match &self.api {
271            None => Ok(false),
272            Some(api) => {
273                _ = api.get_profile(client).await?;
274                Ok(true)
275            }
276        }
277    }
278
279    async fn user_name(&self) -> Option<String> {
280        self.config.get_user_name()
281    }
282
283    async fn get_login_data(&self) -> Option<LoginData> {
284        if self.has_user_configured().unwrap_or(false) {
285            let username = self.config.get_user_name();
286            let password = self.config.get_password();
287
288            if let (Some(username), Some(password)) = (username, password) {
289                return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
290                    id: CommafeedMetadata::get_id(),
291                    url: self.config.get_url(),
292                    user: username,
293                    password,
294                    basic_auth: None,
295                })));
296            }
297        }
298
299        None
300    }
301
302    async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
303        if let LoginData::Direct(DirectLogin::Password(password_data)) = data {
304            let api = if let Some(mut url_string) = password_data.url.clone() {
305                if !url_string.ends_with('/') {
306                    url_string.push('/');
307                }
308                if !url_string.ends_with("rest/") {
309                    url_string.push_str("rest/");
310                }
311
312                self.config.set_url(&url_string);
313                self.config.set_password(&password_data.password);
314                self.config.set_user_name(&password_data.user);
315
316                let url = Url::parse(&url_string)?;
317                let api = CommafeedApi::new(&url, &password_data.user, &password_data.password);
318                let profile = api.get_profile(client).await?;
319
320                tracing::debug!(%profile.name, "logged in");
321
322                api
323            } else {
324                tracing::error!("No URL set");
325                return Err(FeedApiError::Login);
326            };
327
328            if self.config.get_user_name().is_none() {
329                let profile = api.get_profile(client).await?;
330                self.config.set_user_name(&profile.name);
331            }
332
333            self.config.save()?;
334            self.api = Some(api);
335            return Ok(());
336        }
337
338        self.api = None;
339        Err(FeedApiError::Login)
340    }
341
342    async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
343        self.config.delete()?;
344        Ok(())
345    }
346
347    async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
348        if let Some(api) = self.api.as_ref() {
349            let base_url = self.config.get_icon_base_url()?;
350
351            let tree = api.get_category_tree(client);
352            let tags = api.get_tags(client);
353
354            let unread_result = self.fetch_articles("all", 999, false, client);
355            let starred_result = self.fetch_articles("starred", 999, true, client);
356
357            let (tree, tags, unread_result, starred_result) = futures::join!(tree, tags, unread_result, starred_result);
358
359            let mut unread_result = unread_result?;
360            let starred_result = starred_result?;
361
362            let converted_tree = Self::parse_tree(tree?, &base_url);
363            let tags = Self::convert_tags(tags?);
364
365            unread_result.add(starred_result);
366
367            Ok(SyncResult {
368                feeds: util::vec_to_option(converted_tree.feeds),
369                categories: util::vec_to_option(converted_tree.categories),
370                feed_mappings: util::vec_to_option(converted_tree.feed_mappings),
371                category_mappings: util::vec_to_option(converted_tree.category_mappings),
372                tags: util::vec_to_option(tags),
373                taggings: util::vec_to_option(unread_result.taggings),
374                headlines: None,
375                articles: util::vec_to_option(unread_result.articles),
376                enclosures: util::vec_to_option(unread_result.enclosures),
377            })
378        } else {
379            Err(FeedApiError::Login)
380        }
381    }
382
383    async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
384        if let Some(api) = self.api.as_ref() {
385            let base_url = self.config.get_icon_base_url()?;
386            let max_count = self.portal.get_config().read().await.get_sync_amount();
387
388            let mut result = StreamConversionResult::new();
389
390            let tree = api.get_category_tree(client);
391            let tags = api.get_tags(client);
392
393            let unread_result = self.fetch_articles("all", max_count, false, client);
394            let starred_result = self.fetch_articles("starred", max_count, true, client);
395            let recent_entries = api.get_category_entries("all", true, None, None, Some(max_count as i32), None, None, None, None, None, client);
396
397            let (tree, tags, unread_result, starred_result, recent_entries) =
398                futures::join!(tree, tags, unread_result, starred_result, recent_entries);
399
400            let unread_result = unread_result?;
401            let starred_result = starred_result?;
402            let recent_entries = recent_entries?;
403
404            let converted_tree = Self::parse_tree(tree?, &base_url);
405            let tags = Self::convert_tags(tags?);
406
407            let converted_recent_entries = Self::convert_entries(recent_entries, self.portal.as_ref());
408            result.add(converted_recent_entries);
409
410            // get local IDs
411            let local_unread_ids = self.portal.get_article_ids_unread_all()?;
412            let local_marked_ids = self.portal.get_article_ids_marked_all()?;
413
414            let local_unread_ids: HashSet<ArticleID> = local_unread_ids.into_iter().collect();
415            let local_marked_ids: HashSet<ArticleID> = local_marked_ids.into_iter().collect();
416
417            let remote_unread_ids: HashSet<ArticleID> = unread_result.articles.iter().map(|a| &a.article_id).cloned().collect();
418            let remote_starred_ids: HashSet<ArticleID> = starred_result.articles.iter().map(|a| &a.article_id).cloned().collect();
419
420            // mark remotely read article as read
421            let mut should_mark_read_headlines = local_unread_ids
422                .difference(&remote_unread_ids)
423                .map(|id| Headline {
424                    article_id: ArticleID::new(&id.to_string()),
425                    unread: Read::Read,
426                    marked: if remote_starred_ids.contains(id) {
427                        Marked::Marked
428                    } else {
429                        Marked::Unmarked
430                    },
431                })
432                .collect();
433            result.headlines.append(&mut should_mark_read_headlines);
434
435            // unmark remotly unstarred articles locally
436            let mut missing_unmarked_headlines = local_marked_ids
437                .difference(&remote_starred_ids)
438                .map(|id| Headline {
439                    article_id: ArticleID::new(&id.to_string()),
440                    marked: Marked::Unmarked,
441                    unread: if remote_unread_ids.contains(id) { Read::Unread } else { Read::Read },
442                })
443                .collect();
444            result.headlines.append(&mut missing_unmarked_headlines);
445
446            Ok(SyncResult {
447                feeds: util::vec_to_option(converted_tree.feeds),
448                categories: util::vec_to_option(converted_tree.categories),
449                feed_mappings: util::vec_to_option(converted_tree.feed_mappings),
450                category_mappings: util::vec_to_option(converted_tree.category_mappings),
451                tags: util::vec_to_option(tags),
452                taggings: util::vec_to_option(result.taggings),
453                headlines: util::vec_to_option(result.headlines),
454                articles: util::vec_to_option(result.articles),
455                enclosures: util::vec_to_option(result.enclosures),
456            })
457        } else {
458            Err(FeedApiError::Login)
459        }
460    }
461
462    async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
463        if let Some(api) = self.api.as_ref() {
464            let comma_feed_id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
465                message: format!("Failed to parse id {feed_id}"),
466            })?;
467
468            let base_url = self.config.get_icon_base_url()?;
469            let feed = api.fetch_feed(comma_feed_id, client).await?;
470            let feed = Feed {
471                feed_id: feed_id.clone(),
472                label: feed.name,
473                website: Url::parse(&feed.feed_link).ok(),
474                feed_url: Url::parse(&feed.feed_url).ok(),
475                icon_url: base_url.join(&feed.icon_url).map(Url::new).ok(),
476                error_count: feed.error_count,
477                error_message: feed.message,
478            };
479
480            let entries = api
481                .get_feed_entries(feed_id.as_str(), true, None, None, None, None, None, None, client)
482                .await?;
483            let converted_entries = Self::convert_entries(entries, self.portal.as_ref());
484
485            Ok(FeedUpdateResult {
486                feed: Some(feed),
487                taggings: util::vec_to_option(converted_entries.taggings),
488                articles: util::vec_to_option(converted_entries.articles),
489                enclosures: util::vec_to_option(converted_entries.enclosures),
490            })
491        } else {
492            Err(FeedApiError::Login)
493        }
494    }
495
496    async fn set_article_read(&self, articles: &[ArticleID], read: Read, client: &Client) -> FeedApiResult<()> {
497        if let Some(api) = self.api.as_ref() {
498            let requests = articles
499                .iter()
500                .map(|id| MarkRequest {
501                    id: id.as_str().into(),
502                    read: read == Read::Read,
503                    older_than: None,
504                    keywords: None,
505                    excluded_subscriptions: None,
506                })
507                .collect();
508            api.mark_multiple_entries_read(requests, client).await?;
509            Ok(())
510        } else {
511            Err(FeedApiError::Login)
512        }
513    }
514
515    async fn set_article_marked(&self, articles: &[ArticleID], marked: Marked, client: &Client) -> FeedApiResult<()> {
516        if let Some(api) = self.api.as_ref() {
517            let requests = articles
518                .iter()
519                .map(|id| StarRequest {
520                    id: id.as_str().into(),
521                    feed_id: 0,
522                    starred: marked == Marked::Marked,
523                })
524                .collect::<Vec<_>>();
525
526            for request in requests {
527                api.mark_entry_starred(request, client).await?;
528            }
529            Ok(())
530        } else {
531            Err(FeedApiError::Login)
532        }
533    }
534
535    async fn set_feed_read(&self, feeds: &[FeedID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
536        if let Some(api) = self.api.as_ref() {
537            for feed in feeds {
538                api.mark_feed_read(feed.as_str(), true, None, None, None, client).await?;
539            }
540            Ok(())
541        } else {
542            Err(FeedApiError::Login)
543        }
544    }
545
546    async fn set_category_read(&self, categories: &[CategoryID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
547        if let Some(api) = self.api.as_ref() {
548            for category in categories {
549                api.mark_category_read(category.as_str(), true, None, None, None, client).await?;
550            }
551            Ok(())
552        } else {
553            Err(FeedApiError::Login)
554        }
555    }
556
557    async fn set_tag_read(&self, _tags: &[TagID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
558        if let Some(api) = self.api.as_ref() {
559            let requests = articles
560                .iter()
561                .map(|id| MarkRequest {
562                    id: id.as_str().into(),
563                    read: true,
564                    older_than: None,
565                    keywords: None,
566                    excluded_subscriptions: None,
567                })
568                .collect();
569            api.mark_multiple_entries_read(requests, client).await?;
570            Ok(())
571        } else {
572            Err(FeedApiError::Login)
573        }
574    }
575
576    async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
577        if let Some(api) = self.api.as_ref() {
578            let requests = articles
579                .iter()
580                .map(|id| MarkRequest {
581                    id: id.as_str().into(),
582                    read: true,
583                    older_than: None,
584                    keywords: None,
585                    excluded_subscriptions: None,
586                })
587                .collect();
588            api.mark_multiple_entries_read(requests, client).await?;
589            Ok(())
590        } else {
591            Err(FeedApiError::Login)
592        }
593    }
594
595    async fn add_feed(
596        &self,
597        url: &Url,
598        title: Option<String>,
599        category_id: Option<CategoryID>,
600        client: &Client,
601    ) -> FeedApiResult<(Feed, Option<Category>)> {
602        if let Some(api) = self.api.as_ref() {
603            let feed = if let Some(title) = title.as_deref() {
604                let feed_id = api
605                    .subscribe_to_feed(url.as_str(), title, category_id.as_ref().map(|id| id.to_string()).as_deref(), client)
606                    .await?;
607                Feed {
608                    feed_id: FeedID::new(&feed_id.to_string()),
609                    label: title.to_owned(),
610                    website: None,
611                    feed_url: Some(url.clone()),
612                    icon_url: None,
613                    error_count: 0,
614                    error_message: None,
615                }
616            } else {
617                let feed_id = api.subscribe_to_feed_simple(url.as_str(), client).await?;
618                let feed_response = client.get(url.as_str()).send().await?.error_for_status()?;
619                let result_bytes = feed_response
620                    .bytes()
621                    .await
622                    .inspect_err(|error| tracing::error!(%url, %error, "Reading response as bytes failed"))?;
623
624                let parser = parser::Builder::new().base_uri(Some(url)).build();
625                let feed = parser.parse(result_bytes.as_ref())?;
626                let mut feed = Feed::from_feed_rs(&feed, title, url);
627                feed.feed_id = FeedID::new(&feed_id.to_string());
628                feed
629            };
630
631            let categories = self.portal.get_categories()?;
632            let category = categories.iter().find(|c| Some(&c.category_id) == category_id.as_ref()).cloned();
633
634            Ok((feed, category))
635        } else {
636            Err(FeedApiError::Login)
637        }
638    }
639
640    async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
641        if let Some(api) = self.api.as_ref() {
642            let id = id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
643                message: format!("Failed to parse id {id}"),
644            })?;
645            api.unsubscribe_from_feed(id, client).await?;
646            Ok(())
647        } else {
648            Err(FeedApiError::Login)
649        }
650    }
651
652    async fn move_feed(&self, feed_id: &FeedID, _from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
653        if let Some(api) = self.api.as_ref() {
654            let id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
655                message: format!("Failed to parse id {feed_id}"),
656            })?;
657            api.modify_feed(id, None, Some(to.as_str()), None, client).await?;
658            Ok(())
659        } else {
660            Err(FeedApiError::Login)
661        }
662    }
663
664    async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
665        if let Some(api) = self.api.as_ref() {
666            let id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
667                message: format!("Failed to parse id {feed_id}"),
668            })?;
669            api.modify_feed(id, Some(new_title), None, None, client).await?;
670            Ok(feed_id.clone())
671        } else {
672            Err(FeedApiError::Login)
673        }
674    }
675
676    async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
677        Err(FeedApiError::Unsupported)
678    }
679
680    async fn add_category<'a>(&self, title: &str, parent: Option<&'a CategoryID>, client: &Client) -> FeedApiResult<CategoryID> {
681        if let Some(api) = self.api.as_ref() {
682            let id = api.create_category(title, parent.map(|id| id.to_string()).as_deref(), client).await?;
683            Ok(CategoryID::new(&id.to_string()))
684        } else {
685            Err(FeedApiError::Login)
686        }
687    }
688
689    async fn remove_category(&self, id: &CategoryID, _remove_children: bool, client: &Client) -> FeedApiResult<()> {
690        if let Some(api) = self.api.as_ref() {
691            let id = id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
692                message: format!("Failed to parse id {id}"),
693            })?;
694            api.delete_category(id, client).await?;
695            Ok(())
696        } else {
697            Err(FeedApiError::Login)
698        }
699    }
700
701    async fn rename_category(&self, category_id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
702        if let Some(api) = self.api.as_ref() {
703            let id = category_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
704                message: format!("Failed to parse id {category_id}"),
705            })?;
706            api.modify_category(id, Some(new_title), None, None, client).await?;
707            Ok(category_id.clone())
708        } else {
709            Err(FeedApiError::Login)
710        }
711    }
712
713    async fn move_category(&self, category_id: &CategoryID, parent: &CategoryID, client: &Client) -> FeedApiResult<()> {
714        if let Some(api) = self.api.as_ref() {
715            let id = category_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
716                message: format!("Failed to parse id {category_id}"),
717            })?;
718            api.modify_category(id, None, Some(parent.as_str()), None, client).await?;
719            Ok(())
720        } else {
721            Err(FeedApiError::Login)
722        }
723    }
724
725    async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
726        if let Some(api) = self.api.as_ref() {
727            api.import_opml(opml, client).await?;
728            Ok(())
729        } else {
730            Err(FeedApiError::Login)
731        }
732    }
733
734    async fn add_tag(&self, title: &str, _client: &Client) -> FeedApiResult<TagID> {
735        Ok(TagID::new(title))
736    }
737
738    async fn remove_tag(&self, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
739        let taggings = self.portal.get_taggings(None, Some(tag_id))?;
740        for tagging in taggings {
741            self.untag_article(&tagging.article_id, tag_id, client).await?;
742        }
743
744        Ok(())
745    }
746
747    async fn rename_tag(&self, tag_id: &TagID, new_title: &str, client: &Client) -> FeedApiResult<TagID> {
748        if let Some(api) = self.api.as_ref() {
749            let taggings = self.portal.get_taggings(None, Some(tag_id))?;
750
751            for tagging in taggings {
752                let entry_id = tagging.article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
753                    message: format!("Failed to parse id {}", tagging.article_id),
754                })?;
755
756                let article_taggings = self.portal.get_taggings(Some(&tagging.article_id), None)?;
757                let mut tags = article_taggings
758                    .into_iter()
759                    .filter_map(|tagging| {
760                        if &tagging.tag_id != tag_id {
761                            Some(tagging.tag_id.to_string())
762                        } else {
763                            None
764                        }
765                    })
766                    .collect::<Vec<_>>();
767                tags.push(new_title.into());
768
769                let request = TagRequest { entry_id, tags };
770                api.set_tags(request, client).await?;
771            }
772
773            Ok(TagID::new(new_title))
774        } else {
775            Err(FeedApiError::Login)
776        }
777    }
778
779    async fn tag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
780        if let Some(api) = self.api.as_ref() {
781            let entry_id = article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
782                message: format!("Failed to parse id {article_id}"),
783            })?;
784
785            let taggings = self.portal.get_taggings(Some(article_id), None)?;
786            let mut tags = taggings.into_iter().map(|tagging| tagging.tag_id.to_string()).collect::<Vec<_>>();
787            tags.push(tag_id.to_string());
788
789            let request = TagRequest { entry_id, tags };
790            api.set_tags(request, client).await?;
791            Ok(())
792        } else {
793            Err(FeedApiError::Login)
794        }
795    }
796
797    async fn untag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
798        if let Some(api) = self.api.as_ref() {
799            let entry_id = article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
800                message: format!("Failed to parse id {article_id}"),
801            })?;
802
803            let taggings = self.portal.get_taggings(Some(article_id), None)?;
804            let tags = taggings
805                .into_iter()
806                .filter_map(|tagging| {
807                    if &tagging.tag_id != tag_id {
808                        Some(tagging.tag_id.to_string())
809                    } else {
810                        None
811                    }
812                })
813                .collect::<Vec<_>>();
814
815            let request = TagRequest { entry_id, tags };
816            api.set_tags(request, client).await?;
817            Ok(())
818        } else {
819            Err(FeedApiError::Login)
820        }
821    }
822
823    async fn get_favicon(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
824        Err(FeedApiError::Unsupported)
825    }
826}