Skip to main content

news_flash/feed_api_implementations/inoreader/
mod.rs

1mod config;
2pub mod metadata;
3mod oauth;
4
5use std::collections::HashSet;
6use std::sync::Arc;
7
8use self::config::AccountConfig;
9use self::metadata::InoreaderMetadata;
10use self::oauth::InoreaderOAuth;
11use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
12use crate::models::{
13    self, ArticleID, Category, CategoryID, FavIcon, Feed, FeedID, FeedUpdateResult, Headline, LoginData, Marked, OAuthData, PluginCapabilities, Read,
14    StreamConversionResult, SyncResult, TagID, Url,
15};
16use crate::util::greader::{ArticleQuery, GReaderUtil, TAG_READ_STR, TAG_READING_LIST, TAG_STARRED_STR};
17use crate::{ParsedUrl, feed_parser, util};
18use async_trait::async_trait;
19use chrono::{DateTime, Utc};
20use greader_api::models::{AuthInput, InoreaderAuthInput, ItemId, StreamType};
21use greader_api::{ApiError, AuthData, GReaderApi};
22use reqwest::Client;
23use reqwest::header::{HeaderMap, HeaderValue};
24use tokio::sync::RwLock;
25
26pub struct Inoreader {
27    api: Option<GReaderApi>,
28    portal: Arc<Box<dyn Portal>>,
29    logged_in: bool,
30    config: Arc<RwLock<AccountConfig>>,
31}
32
33impl Inoreader {
34    async fn login_inoreader(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
35        let LoginData::OAuth(data) = data else { return Err(FeedApiError::Login) };
36
37        let oauth = InoreaderOAuth::new();
38
39        let url = Url::parse(&data.url)?;
40        let auth_code = oauth.parse_redirected_url(&url)?;
41
42        self.config.write().await.set_custom_api_secret(data.custom_api_secret.as_ref());
43
44        let (client_id, client_secret) = if let Some(user_api_secret) = data.custom_api_secret {
45            // load previous custom API secrets from config
46            (user_api_secret.client_id, user_api_secret.client_secret)
47        } else {
48            // no custom API secrets: use the default one
49            (oauth.client_id, oauth.client_secret)
50        };
51
52        let api = GReaderApi::new(&oauth.base_uri, AuthData::Uninitialized);
53        let auth_data = api
54            .login(
55                &AuthInput::Inoreader(InoreaderAuthInput {
56                    auth_code,
57                    redirect_url: oauth.redirect_uri,
58                    client_id,
59                    client_secret,
60                }),
61                client,
62            )
63            .await?;
64
65        let auth_data = match auth_data {
66            AuthData::Inoreader(auth_data) => auth_data,
67            _ => return Err(FeedApiError::Login),
68        };
69
70        self.config.write().await.set_access_token(&auth_data.access_token);
71        self.config.write().await.set_refresh_token(&auth_data.refresh_token);
72        self.config.write().await.set_token_expires(&auth_data.expires_at.timestamp().to_string());
73
74        let user = api.user_info(client).await?;
75        self.config.write().await.set_user_name(&user.user_name);
76        self.config.write().await.set_user_id(&user.user_id);
77        self.config.write().await.write()?;
78        self.api = Some(api);
79
80        Ok(())
81    }
82
83    async fn is_token_expired(&self) -> Result<bool, ApiError> {
84        let timestamp = self.config.write().await.get_token_expires().ok_or(ApiError::TokenExpired)?;
85        let timestamp = timestamp.parse::<i64>().map_err(|_| ApiError::TokenExpired)?;
86
87        let expires_at: DateTime<Utc> = util::timestamp_to_datetime(timestamp);
88        let expires_in = expires_at.signed_duration_since(Utc::now());
89        Ok(expires_in.num_seconds() <= 60)
90    }
91
92    async fn refresh_token(&self, api: &GReaderApi, client: &Client) -> FeedApiResult<()> {
93        let response = api.inoreader_refresh_token(client).await?;
94        let token_expires = response.expires_at;
95        self.config.write().await.set_access_token(&response.access_token);
96        self.config.write().await.set_token_expires(&token_expires.timestamp().to_string());
97        self.config.write().await.write()?;
98        Ok(())
99    }
100
101    async fn check_and_update_token(&self, api: &GReaderApi, client: &Client) -> FeedApiResult<()> {
102        if self.is_token_expired().await? {
103            self.refresh_token(api, client).await?;
104        }
105
106        Ok(())
107    }
108}
109
110#[async_trait]
111impl FeedApi for Inoreader {
112    fn features(&self) -> FeedApiResult<PluginCapabilities> {
113        Ok(PluginCapabilities::ADD_REMOVE_FEEDS
114            | PluginCapabilities::SUPPORT_CATEGORIES
115            | PluginCapabilities::MODIFY_CATEGORIES
116            | PluginCapabilities::SUPPORT_TAGS)
117    }
118
119    fn has_user_configured(&self) -> FeedApiResult<bool> {
120        Ok(self.api.is_some())
121    }
122
123    async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
124        let res = client.head("https://www.inoreader.com/").send().await?;
125        Ok(res.status().is_success())
126    }
127
128    async fn is_logged_in(&self, _client: &Client) -> FeedApiResult<bool> {
129        Ok(self.logged_in)
130    }
131
132    async fn user_name(&self) -> Option<String> {
133        self.config.read().await.get_user_name()
134    }
135
136    async fn get_login_data(&self) -> Option<LoginData> {
137        if let Ok(true) = self.has_user_configured() {
138            return Some(LoginData::OAuth(OAuthData {
139                id: InoreaderMetadata::get_id(),
140                url: String::new(),
141                custom_api_secret: self.config.read().await.get_custom_api_secret(),
142            }));
143        }
144
145        None
146    }
147
148    async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
149        if let Err(error) = self.login_inoreader(data, client).await {
150            tracing::error!(%error, "Failed to log in");
151            self.api = None;
152            self.logged_in = false;
153            Err(error)
154        } else {
155            self.logged_in = true;
156            Ok(())
157        }
158    }
159
160    async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
161        self.config.read().await.delete()?;
162        Ok(())
163    }
164
165    async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
166        if let Some(api) = &self.api {
167            self.check_and_update_token(api, client).await?;
168
169            let stream_preferences = api.preference_stream_list(client);
170            let feeds = api.subscription_list(client);
171            let tags = api.tag_list(client);
172
173            let (stream_preferences, feeds, tags) = futures::try_join!(stream_preferences, feeds, tags)?;
174
175            let user_id = self.config.read().await.get_user_id();
176            let (categories, category_mappings) =
177                GReaderUtil::convert_category_vec(feeds.subscriptions.clone(), Some(&tags), Some(&stream_preferences), user_id.as_deref());
178            let tags = GReaderUtil::convert_tag_list(tags, &categories);
179            let tag_ids: HashSet<TagID> = tags.iter().map(|f| f.tag_id.clone()).collect();
180            let (feeds, feed_mappings) = GReaderUtil::convert_feed_vec(feeds.subscriptions, Some(&stream_preferences));
181
182            let unread = GReaderUtil::get_articles(
183                api,
184                client,
185                self.portal.clone(),
186                ArticleQuery {
187                    stream_id: Some(TAG_READING_LIST),
188                    read: Some(Read::Unread),
189                    marked: None,
190                    tag_ids: &tag_ids,
191                    limit: None,
192                    last_sync: None,
193                },
194            );
195
196            let starred = GReaderUtil::get_articles(
197                api,
198                client,
199                self.portal.clone(),
200                ArticleQuery {
201                    stream_id: Some(TAG_STARRED_STR),
202                    read: None,
203                    marked: None,
204                    tag_ids: &tag_ids,
205                    limit: None,
206                    last_sync: None,
207                },
208            );
209
210            let latest = GReaderUtil::get_articles(
211                api,
212                client,
213                self.portal.clone(),
214                ArticleQuery {
215                    stream_id: None,
216                    read: Some(Read::Read),
217                    marked: Some(Marked::Unmarked),
218                    tag_ids: &tag_ids,
219                    limit: Some(100),
220                    last_sync: None,
221                },
222            );
223
224            let (unread, starred, latest) = futures::try_join!(unread, starred, latest)?;
225
226            let mut result = StreamConversionResult::new();
227            result.add(unread);
228            result.add(starred);
229            result.add(latest);
230
231            return Ok(SyncResult {
232                feeds: crate::util::vec_to_option(feeds),
233                categories: crate::util::vec_to_option(categories),
234                feed_mappings: crate::util::vec_to_option(feed_mappings),
235                category_mappings: crate::util::vec_to_option(category_mappings),
236                tags: crate::util::vec_to_option(tags),
237                taggings: crate::util::vec_to_option(result.taggings),
238                headlines: None,
239                articles: crate::util::vec_to_option(result.articles),
240                enclosures: crate::util::vec_to_option(result.enclosures),
241            });
242        }
243        Err(FeedApiError::Login)
244    }
245
246    async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
247        if let Some(api) = &self.api {
248            self.check_and_update_token(api, client).await?;
249
250            let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp();
251
252            let stream_preferences = api.preference_stream_list(client);
253            let feeds = api.subscription_list(client);
254            let tags = api.tag_list(client);
255
256            let (stream_preferences, feeds, tags) = futures::try_join!(stream_preferences, feeds, tags)?;
257
258            let user_id = self.config.read().await.get_user_id();
259            let (categories, category_mappings) =
260                GReaderUtil::convert_category_vec(feeds.subscriptions.clone(), Some(&tags), Some(&stream_preferences), user_id.as_deref());
261            let tags = GReaderUtil::convert_tag_list(tags, &categories);
262            let tag_ids: HashSet<TagID> = tags.iter().map(|f| f.tag_id.clone()).collect();
263            let (feeds, feed_mappings) = GReaderUtil::convert_feed_vec(feeds.subscriptions, Some(&stream_preferences));
264
265            let mut result = StreamConversionResult::new();
266
267            // get all new articles since last sync
268            let new_unread = GReaderUtil::get_articles(
269                api,
270                client,
271                self.portal.clone(),
272                ArticleQuery {
273                    stream_id: Some(TAG_READING_LIST),
274                    read: Some(Read::Unread),
275                    marked: None,
276                    tag_ids: &tag_ids,
277                    limit: None,
278                    last_sync: Some(last_sync),
279                },
280            );
281
282            // get all new starred since last sync
283            let new_marked = GReaderUtil::get_articles(
284                api,
285                client,
286                self.portal.clone(),
287                ArticleQuery {
288                    stream_id: Some(TAG_STARRED_STR),
289                    read: None,
290                    marked: None,
291                    tag_ids: &tag_ids,
292                    limit: None,
293                    last_sync: Some(last_sync),
294                },
295            );
296
297            // unread article ids
298            let inoreader_unread_ids = GReaderUtil::get_article_ids(api, client, Some(TAG_READING_LIST), Some(Read::Unread), None, None);
299
300            // marked (saved/starred) article ids
301            let inoreader_marked_ids = GReaderUtil::get_article_ids(api, client, Some(TAG_STARRED_STR), None, None, None);
302
303            let (new_unread, new_marked, inoreader_unread_ids, inoreader_marked_ids) =
304                futures::try_join!(new_unread, new_marked, inoreader_unread_ids, inoreader_marked_ids)?;
305
306            result.add(new_unread);
307            result.add(new_marked);
308
309            let inoreader_unread_ids: HashSet<ArticleID> = inoreader_unread_ids
310                .into_iter()
311                .map(|item_id| {
312                    let ItemId { id } = item_id;
313                    ArticleID::from_owned(id)
314                })
315                .collect();
316
317            let inoreader_marked_ids: HashSet<ArticleID> = inoreader_marked_ids
318                .into_iter()
319                .map(|item_id| {
320                    let ItemId { id } = item_id;
321                    ArticleID::from_owned(id)
322                })
323                .collect();
324
325            // get local unread
326            let local_unread_ids = self.portal.get_article_ids_unread_all()?;
327            let local_unread_ids = local_unread_ids.into_iter().collect::<HashSet<_>>();
328
329            // mark remotely read article as read
330            let mut should_mark_read_headlines = local_unread_ids
331                .difference(&inoreader_unread_ids)
332                .cloned()
333                .map(|id| {
334                    let marked = if inoreader_marked_ids.contains(&id) {
335                        Marked::Marked
336                    } else {
337                        Marked::Unmarked
338                    };
339
340                    Headline {
341                        article_id: id,
342                        unread: Read::Read,
343                        marked,
344                    }
345                })
346                .collect();
347            result.headlines.append(&mut should_mark_read_headlines);
348
349            // get local marked
350            let local_marked_ids = self.portal.get_article_ids_marked_all()?;
351            let local_marked_ids = local_marked_ids.into_iter().collect::<HashSet<_>>();
352
353            // mark remotly starred articles locally
354            let mut mark_headlines = local_marked_ids
355                .difference(&inoreader_marked_ids)
356                .map(|id| Headline {
357                    article_id: id.clone(),
358                    marked: Marked::Marked,
359                    unread: if inoreader_unread_ids.contains(id) { Read::Unread } else { Read::Read },
360                })
361                .collect();
362            result.headlines.append(&mut mark_headlines);
363
364            // unmark remotly unstarred articles locally
365            let mut missing_unmarked_headlines = local_marked_ids
366                .difference(&inoreader_marked_ids)
367                .cloned()
368                .map(|id| {
369                    let unread = if inoreader_unread_ids.contains(&id) { Read::Unread } else { Read::Read };
370
371                    Headline {
372                        article_id: id,
373                        marked: Marked::Unmarked,
374                        unread,
375                    }
376                })
377                .collect();
378            result.headlines.append(&mut missing_unmarked_headlines);
379
380            return Ok(SyncResult {
381                feeds: crate::util::vec_to_option(feeds),
382                categories: crate::util::vec_to_option(categories),
383                feed_mappings: crate::util::vec_to_option(feed_mappings),
384                category_mappings: crate::util::vec_to_option(category_mappings),
385                tags: crate::util::vec_to_option(tags),
386                taggings: crate::util::vec_to_option(result.taggings),
387                headlines: crate::util::vec_to_option(result.headlines),
388                articles: crate::util::vec_to_option(result.articles),
389                enclosures: crate::util::vec_to_option(result.enclosures),
390            });
391        }
392        Err(FeedApiError::Login)
393    }
394
395    async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
396        if let Some(api) = &self.api {
397            let tags = api.tag_list(client);
398            let feeds = api.subscription_list(client);
399
400            let (feeds, tags) = futures::try_join!(feeds, tags)?;
401
402            let (categories, _category_mappings) = GReaderUtil::convert_category_vec(feeds.subscriptions.clone(), Some(&tags), None, None);
403            let tags = GReaderUtil::convert_tag_list(tags, &categories);
404            let tag_ids: HashSet<TagID> = tags.iter().map(|f| f.tag_id.clone()).collect();
405            let (feeds, _feed_mappings) = GReaderUtil::convert_feed_vec(feeds.subscriptions, None);
406            let feed = feeds.iter().find(|feed| &feed.feed_id == feed_id).cloned();
407
408            let result = GReaderUtil::get_articles(
409                api,
410                client,
411                self.portal.clone(),
412                ArticleQuery {
413                    stream_id: Some(feed_id.as_str()),
414                    read: None,
415                    marked: None,
416                    tag_ids: &tag_ids,
417                    limit: Some(20),
418                    last_sync: None,
419                },
420            )
421            .await?;
422
423            return Ok(FeedUpdateResult {
424                feed,
425                taggings: crate::util::vec_to_option(result.taggings),
426                articles: crate::util::vec_to_option(result.articles),
427                enclosures: crate::util::vec_to_option(result.enclosures),
428            });
429        } else {
430            Err(FeedApiError::Login)
431        }
432    }
433
434    async fn set_article_read(&self, articles: &[ArticleID], read: models::Read, client: &Client) -> FeedApiResult<()> {
435        if let Some(api) = &self.api {
436            self.check_and_update_token(api, client).await?;
437
438            let item_ids: Vec<_> = articles.iter().map(|id| id.as_str()).collect();
439            let (add_tag, remove_tag) = match read {
440                Read::Read => (Some(TAG_READ_STR), None),
441                Read::Unread => (None, Some(TAG_READ_STR)),
442            };
443            api.tag_edit(&item_ids, add_tag, remove_tag, client).await?;
444            Ok(())
445        } else {
446            Err(FeedApiError::Login)
447        }
448    }
449
450    async fn set_article_marked(&self, articles: &[ArticleID], marked: models::Marked, client: &Client) -> FeedApiResult<()> {
451        if let Some(api) = &self.api {
452            self.check_and_update_token(api, client).await?;
453
454            let item_ids: Vec<_> = articles.iter().map(|id| id.as_str()).collect();
455            let (add_tag, remove_tag) = match marked {
456                Marked::Marked => (Some(TAG_STARRED_STR), None),
457                Marked::Unmarked => (None, Some(TAG_STARRED_STR)),
458            };
459            api.tag_edit(&item_ids, add_tag, remove_tag, client).await?;
460            Ok(())
461        } else {
462            Err(FeedApiError::Login)
463        }
464    }
465
466    async fn set_feed_read(&self, feeds: &[FeedID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
467        if let Some(api) = &self.api {
468            self.check_and_update_token(api, client).await?;
469
470            let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
471            let mut mark_read_futures = Vec::new();
472
473            for feed in feeds {
474                mark_read_futures.push(api.mark_all_as_read(feed.as_str(), Some(last_sync), client));
475            }
476
477            futures::future::try_join_all(mark_read_futures).await?;
478            Ok(())
479        } else {
480            Err(FeedApiError::Login)
481        }
482    }
483
484    async fn set_category_read(&self, categories: &[CategoryID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
485        if let Some(api) = &self.api {
486            self.check_and_update_token(api, client).await?;
487
488            let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
489            let mut mark_read_futures = Vec::new();
490
491            for category in categories {
492                mark_read_futures.push(api.mark_all_as_read(category.as_str(), Some(last_sync), client));
493            }
494
495            futures::future::try_join_all(mark_read_futures).await?;
496            Ok(())
497        } else {
498            Err(FeedApiError::Login)
499        }
500    }
501
502    async fn set_tag_read(&self, tags: &[TagID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
503        if let Some(api) = &self.api {
504            self.check_and_update_token(api, client).await?;
505
506            let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp_micros() as u64;
507            let mut mark_read_futures = Vec::new();
508
509            for tag in tags {
510                mark_read_futures.push(api.mark_all_as_read(tag.as_str(), Some(last_sync), client));
511            }
512
513            futures::future::try_join_all(mark_read_futures).await?;
514            Ok(())
515        } else {
516            Err(FeedApiError::Login)
517        }
518    }
519
520    async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
521        self.set_article_read(articles, Read::Read, client).await
522    }
523
524    async fn add_feed(
525        &self,
526        url: &Url,
527        title: Option<String>,
528        category_id: Option<CategoryID>,
529        client: &Client,
530    ) -> FeedApiResult<(Feed, Option<Category>)> {
531        if let Some(api) = &self.api {
532            self.check_and_update_token(api, client).await?;
533
534            let feed_id = format!("feed/{}", &url.as_str());
535            let category_str = category_id.clone().map(|id| id.as_str().to_owned());
536
537            api.subscription_create(url, title.as_deref(), category_str.as_deref(), client).await?;
538
539            let feed_id = FeedID::new(&feed_id);
540            let semaphore = self.portal.get_download_semaphore();
541            let result = feed_parser::download_and_parse_feed(url, &feed_id, title, semaphore, client).await;
542
543            if result.is_err() {
544                tracing::warn!("parsing went wrong -> remove feed from freshrss account");
545                self.remove_feed(&feed_id, client).await?;
546            }
547
548            let feed = match result? {
549                ParsedUrl::SingleFeed(feed) => feed,
550                _ => {
551                    let msg = "Expected Single Feed";
552                    tracing::warn!("{msg}");
553                    return Err(FeedApiError::Api { message: msg.into() });
554                }
555            };
556
557            // return category in case a new one got created
558            let local_categories = self.portal.get_categories()?;
559            let category = if !local_categories.iter().any(|c| Some(&c.category_id) == category_id.as_ref()) {
560                let feeds = api.subscription_list(client).await?;
561                let user_id = self.config.read().await.get_user_id();
562                let (categories, _category_mappings) = GReaderUtil::convert_category_vec(feeds.subscriptions, None, None, user_id.as_deref());
563                categories.iter().find(|c| Some(&c.category_id) == category_id.as_ref()).cloned()
564            } else {
565                None
566            };
567
568            return Ok((*feed, category));
569        }
570        Err(FeedApiError::Login)
571    }
572
573    async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
574        if let Some(api) = &self.api {
575            self.check_and_update_token(api, client).await?;
576
577            let feed_id_str = id.as_str();
578            api.subscription_delete(feed_id_str, client).await?;
579            Ok(())
580        } else {
581            Err(FeedApiError::Login)
582        }
583    }
584
585    async fn move_feed(&self, feed_id: &FeedID, from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
586        if let Some(api) = &self.api {
587            self.check_and_update_token(api, client).await?;
588
589            api.subscription_edit(feed_id.as_str(), None, Some(from.as_str()), Some(to.as_str()), client)
590                .await?;
591            Ok(())
592        } else {
593            Err(FeedApiError::Login)
594        }
595    }
596
597    async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
598        if let Some(api) = &self.api {
599            self.check_and_update_token(api, client).await?;
600
601            api.subscription_edit(feed_id.as_str(), Some(new_title), None, None, client).await?;
602            Ok(feed_id.clone())
603        } else {
604            Err(FeedApiError::Login)
605        }
606    }
607
608    async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
609        Err(FeedApiError::Unsupported)
610    }
611
612    async fn add_category<'a>(&self, title: &str, parent: Option<&'a CategoryID>, _client: &Client) -> FeedApiResult<CategoryID> {
613        if let Some(_api) = &self.api {
614            if parent.is_some() {
615                return Err(FeedApiError::Unsupported);
616            }
617
618            // only generate id
619            // useing id as if it would exist will create category
620            let user_id = self.config.read().await.get_user_id();
621            let category_id = GReaderUtil::generate_tag_id(user_id.as_deref(), title);
622            Ok(CategoryID::new(&category_id))
623        } else {
624            Err(FeedApiError::Login)
625        }
626    }
627
628    async fn remove_category(&self, id: &CategoryID, remove_children: bool, client: &Client) -> FeedApiResult<()> {
629        if let Some(api) = &self.api {
630            self.check_and_update_token(api, client).await?;
631
632            if remove_children {
633                let mappings = self.portal.get_feed_mappings()?;
634
635                let feed_ids = mappings
636                    .iter()
637                    .filter(|m| &m.category_id == id)
638                    .map(|m| m.feed_id.clone())
639                    .collect::<Vec<FeedID>>();
640
641                for feed_id in feed_ids {
642                    self.remove_feed(&feed_id, client).await?;
643                }
644            }
645
646            api.tag_delete(StreamType::Category, id.as_str(), client).await?;
647            Ok(())
648        } else {
649            Err(FeedApiError::Login)
650        }
651    }
652
653    async fn rename_category(&self, id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
654        if let Some(api) = &self.api {
655            self.check_and_update_token(api, client).await?;
656
657            api.tag_rename(StreamType::Category, id.as_str(), new_title, client).await?;
658
659            let user_id = self.config.read().await.get_user_id();
660            Ok(CategoryID::new(&GReaderUtil::generate_tag_id(user_id.as_deref(), new_title)))
661        } else {
662            Err(FeedApiError::Login)
663        }
664    }
665
666    async fn move_category(&self, _id: &CategoryID, _parent: &CategoryID, _client: &Client) -> FeedApiResult<()> {
667        Err(FeedApiError::Unsupported)
668    }
669
670    async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
671        if let Some(api) = &self.api {
672            self.check_and_update_token(api, client).await?;
673
674            api.import(opml.to_owned(), client).await?;
675            Ok(())
676        } else {
677            Err(FeedApiError::Login)
678        }
679    }
680
681    async fn add_tag(&self, title: &str, _client: &Client) -> FeedApiResult<TagID> {
682        if let Some(_api) = &self.api {
683            // only generate id
684            // useing id as if it would exist will create tag
685            let user_id = self.config.read().await.get_user_id();
686            let tag_id = GReaderUtil::generate_tag_id(user_id.as_deref(), title);
687            Ok(TagID::new(&tag_id))
688        } else {
689            Err(FeedApiError::Login)
690        }
691    }
692
693    async fn remove_tag(&self, id: &TagID, client: &Client) -> FeedApiResult<()> {
694        if let Some(api) = &self.api {
695            self.check_and_update_token(api, client).await?;
696            api.tag_delete(StreamType::Stream, id.as_str(), client).await?;
697            Ok(())
698        } else {
699            Err(FeedApiError::Login)
700        }
701    }
702
703    async fn rename_tag(&self, id: &TagID, new_title: &str, client: &Client) -> FeedApiResult<TagID> {
704        if let Some(api) = &self.api {
705            self.check_and_update_token(api, client).await?;
706            api.tag_rename(StreamType::Stream, id.as_str(), new_title, client).await?;
707
708            let user_id = self.config.read().await.get_user_id();
709            Ok(TagID::new(&GReaderUtil::generate_tag_id(user_id.as_deref(), new_title)))
710        } else {
711            Err(FeedApiError::Login)
712        }
713    }
714
715    async fn tag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
716        if let Some(api) = &self.api {
717            self.check_and_update_token(api, client).await?;
718            api.tag_edit(&[article_id.as_str()], Some(tag_id.as_str()), None, client).await?;
719            Ok(())
720        } else {
721            Err(FeedApiError::Login)
722        }
723    }
724
725    async fn untag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
726        if let Some(api) = &self.api {
727            self.check_and_update_token(api, client).await?;
728            api.tag_edit(&[article_id.as_str()], None, Some(tag_id.as_str()), client).await?;
729            Ok(())
730        } else {
731            Err(FeedApiError::Login)
732        }
733    }
734
735    async fn get_favicon(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
736        Err(FeedApiError::Unsupported)
737    }
738}