Skip to main content

news_flash/feed_api_implementations/miniflux/
mod.rs

1pub mod config;
2pub mod metadata;
3
4use self::config::AccountConfig;
5use self::metadata::MinifluxMetadata;
6use crate::feed_api::FeedHeaderMap;
7use crate::models::{
8    self, ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, Enclosure, FatArticle, FavIcon, Feed, FeedID, FeedMapping, FeedUpdateResult,
9    Headline, LoginData, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, StreamConversionResult, SyncResult, TagID, TokenLogin, Url,
10};
11use crate::util::favicons::EXPIRES_AFTER_DAYS;
12use crate::util::{self, html2text};
13use crate::{
14    feed_api::{FeedApi, FeedApiError, FeedApiResult, Portal},
15    models::{Marked, Read},
16};
17use async_trait::async_trait;
18use base64::Engine;
19use base64::engine::general_purpose::STANDARD as base64_std;
20use chrono::{DateTime, Duration, Utc};
21use futures::future;
22use miniflux_api::models::{Category as MinifluxCategory, Entry as MinifluxArticle, EntryStatus, Feed as MinifluxFeed, OrderBy, OrderDirection};
23use miniflux_api::{ApiError as MinifluxApiError, MinifluxApi};
24use reqwest::Client;
25use reqwest::header::{HeaderMap, HeaderValue};
26use std::collections::HashSet;
27use std::convert::{From, TryInto};
28use std::sync::Arc;
29use tokio::sync::RwLock;
30
31const DEFAULT_CATEGORY: &str = "New Category";
32
33impl From<MinifluxApiError> for FeedApiError {
34    fn from(error: MinifluxApiError) -> FeedApiError {
35        match error {
36            MinifluxApiError::Url(e) => FeedApiError::Url(e),
37            MinifluxApiError::Json { source, json } => FeedApiError::Json { source, json },
38            MinifluxApiError::Http(e) => FeedApiError::Network(e),
39            MinifluxApiError::Miniflux(e) => FeedApiError::Api {
40                message: format!("Miniflux Error: {}", e.error_message),
41            },
42            MinifluxApiError::Parse => FeedApiError::Api {
43                message: MinifluxApiError::Parse.to_string(),
44            },
45        }
46    }
47}
48
49pub struct ArticleQuery {
50    pub status: Option<EntryStatus>,
51    pub before: Option<i64>,
52    pub after: Option<i64>,
53    pub before_entry_id: Option<i64>,
54    pub after_entry_id: Option<i64>,
55    pub starred: Option<bool>,
56}
57
58const UNREAD_QUERY: ArticleQuery = ArticleQuery {
59    status: Some(EntryStatus::Unread),
60    before: None,
61    after: None,
62    before_entry_id: None,
63    after_entry_id: None,
64    starred: None,
65};
66
67const STARRED_QUERY: ArticleQuery = ArticleQuery {
68    status: None,
69    before: None,
70    after: None,
71    before_entry_id: None,
72    after_entry_id: None,
73    starred: Some(true),
74};
75
76pub struct Miniflux {
77    api: Option<MinifluxApi>,
78    portal: Arc<Box<dyn Portal>>,
79    logged_in: bool,
80    config: AccountConfig,
81}
82
83impl Miniflux {
84    fn convert_category_vec(mut categories: Vec<MinifluxCategory>) -> (Vec<Category>, Vec<CategoryMapping>) {
85        categories
86            .drain(..)
87            .enumerate()
88            .map(|(i, c)| Miniflux::convert_category(c, Some(i as i32)))
89            .unzip()
90    }
91
92    fn convert_category(category: MinifluxCategory, sort_index: Option<i32>) -> (Category, CategoryMapping) {
93        let MinifluxCategory { id, user_id: _, title } = category;
94        let category_id = CategoryID::new(&id.to_string());
95
96        let category = Category {
97            category_id: category_id.clone(),
98            label: title,
99        };
100        let category_mapping = CategoryMapping {
101            parent_id: NEWSFLASH_TOPLEVEL.clone(),
102            category_id,
103            sort_index,
104        };
105
106        (category, category_mapping)
107    }
108
109    fn convert_feed(feed: MinifluxFeed) -> Feed {
110        let MinifluxFeed {
111            id,
112            user_id: _,
113            title,
114            site_url,
115            feed_url,
116            rewrite_rules: _,
117            scraper_rules: _,
118            crawler: _,
119            checked_at: _,
120            etag_header: _,
121            last_modified_header: _,
122            parsing_error_count,
123            parsing_error_message,
124            category: _,
125            icon: _,
126        } = feed;
127
128        Feed {
129            feed_id: FeedID::new(&id.to_string()),
130            label: title,
131            website: Url::parse(&site_url).ok(),
132            feed_url: Url::parse(&feed_url).ok(),
133            icon_url: None,
134            error_count: parsing_error_count as i32,
135            error_message: if !parsing_error_message.is_empty() {
136                Some(parsing_error_message)
137            } else {
138                None
139            },
140        }
141    }
142
143    fn convert_feed_vec(mut feeds: Vec<MinifluxFeed>) -> (Vec<Feed>, Vec<FeedMapping>) {
144        let mut mappings: Vec<FeedMapping> = Vec::new();
145        let feeds = feeds
146            .drain(..)
147            .enumerate()
148            .map(|(i, f)| {
149                mappings.push(FeedMapping {
150                    feed_id: FeedID::new(&f.id.to_string()),
151                    category_id: CategoryID::new(&f.category.id.to_string()),
152                    sort_index: Some(i as i32),
153                });
154
155                Miniflux::convert_feed(f)
156            })
157            .collect();
158
159        (feeds, mappings)
160    }
161
162    fn convert_entry(entry: MinifluxArticle, portal: Arc<Box<dyn Portal>>) -> (FatArticle, Vec<Enclosure>) {
163        let MinifluxArticle {
164            id,
165            user_id: _,
166            feed_id,
167            title,
168            url,
169            comments_url: _,
170            author,
171            content,
172            hash: _,
173            published_at,
174            created_at: _,
175            changed_at: _,
176            status,
177            starred,
178            feed: _,
179            reading_time: _,
180            enclosures,
181        } = entry;
182
183        let article_id = ArticleID::new(&id.to_string());
184
185        let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
186
187        let plain_text = if article_exists_locally {
188            None
189        } else {
190            Some(html2text::html2text(&content))
191        };
192
193        let summary = plain_text.as_deref().map(util::html2text::text2summary);
194
195        let mut thumbnail_url = enclosures.iter().find_map(|e| {
196            let is_image_type = e.mime_type.starts_with("image/");
197            let is_image_href = e.url.ends_with(".jpeg") || e.url.ends_with(".jpg") || e.url.ends_with(".png");
198
199            if is_image_type || is_image_href { Some(e.url.clone()) } else { None }
200        });
201
202        if thumbnail_url.is_none() {
203            thumbnail_url = crate::util::thumbnail::extract_thumbnail(&content);
204        }
205
206        let mut enclosures = enclosures
207            .into_iter()
208            .filter_map(|miniflux_enclosure| {
209                Url::parse(&miniflux_enclosure.url).ok().map(|url| Enclosure {
210                    article_id: article_id.clone(),
211                    url,
212                    mime_type: Some(miniflux_enclosure.mime_type),
213                    title: None,
214                    position: None,
215                    summary: None,
216                    thumbnail_url: None,
217                    filesize: if miniflux_enclosure.size > 0 {
218                        Some(miniflux_enclosure.size as i32)
219                    } else {
220                        None
221                    },
222                    width: None,
223                    height: None,
224                    duration: None,
225                    framerate: None,
226                    alternative: None,
227                    is_default: false,
228                })
229            })
230            .collect::<Vec<_>>();
231
232        let has_video = enclosures.iter().any(Enclosure::is_video);
233        let first_image_url = enclosures
234            .iter()
235            .find(|enclosure| enclosure.is_image())
236            .map(|enclosure| enclosure.url.to_string());
237
238        if let (true, Some(first_image_url)) = (has_video, first_image_url) {
239            tracing::debug!(?first_image_url, "has video + first image url");
240            enclosures = enclosures
241                .into_iter()
242                .filter_map(|mut enclosure| {
243                    if enclosure.is_video() {
244                        enclosure.thumbnail_url = Some(first_image_url.clone());
245                        Some(enclosure)
246                    } else if enclosure.is_image() {
247                        None
248                    } else {
249                        Some(enclosure)
250                    }
251                })
252                .collect();
253        }
254
255        let article = FatArticle {
256            article_id,
257            title: Some(title),
258            author: if author.is_empty() { None } else { Some(author) },
259            feed_id: FeedID::new(&feed_id.to_string()),
260            url: Url::parse(&url).ok(),
261            date: match DateTime::parse_from_rfc3339(&published_at) {
262                Ok(date) => date.with_timezone(&Utc),
263                Err(_) => Utc::now(),
264            },
265            synced: Utc::now(),
266            updated: None,
267            summary,
268            html: Some(content),
269            direction: None,
270            unread: match status.as_str().try_into() {
271                Ok(status) => match status {
272                    EntryStatus::Read => models::Read::Read,
273                    _ => models::Read::Unread,
274                },
275                Err(_) => models::Read::Unread,
276            },
277            marked: if starred { models::Marked::Marked } else { models::Marked::Unmarked },
278            scraped_content: None,
279            plain_text,
280            thumbnail_url,
281        };
282
283        (article, enclosures)
284    }
285
286    async fn convert_entry_vec(entries: Vec<MinifluxArticle>, portal: Arc<Box<dyn Portal>>) -> StreamConversionResult {
287        let enclosures: Arc<RwLock<Vec<Enclosure>>> = Arc::new(RwLock::new(Vec::new()));
288        let tasks = entries
289            .into_iter()
290            .map(|e| {
291                let portal = portal.clone();
292                let enclosures = enclosures.clone();
293
294                tokio::spawn(async move {
295                    let (article, mut converted_enclousres) = Self::convert_entry(e, portal);
296                    enclosures.write().await.append(&mut converted_enclousres);
297                    article
298                })
299            })
300            .collect::<Vec<_>>();
301
302        let articles = future::join_all(tasks).await.into_iter().filter_map(|res| res.ok()).collect();
303
304        StreamConversionResult {
305            articles,
306            headlines: Vec::new(),
307            taggings: Vec::new(),
308            enclosures: Arc::into_inner(enclosures).map(|e| e.into_inner()).unwrap_or_default(),
309        }
310    }
311
312    pub async fn get_articles(&self, query: ArticleQuery, client: &Client) -> FeedApiResult<StreamConversionResult> {
313        if let Some(api) = &self.api {
314            let batch_size: i64 = 100;
315            let mut offset: Option<i64> = None;
316            let mut articles: Vec<FatArticle> = Vec::new();
317            let mut enclosures: Vec<Enclosure> = Vec::new();
318
319            loop {
320                let entries = api
321                    .get_entries(
322                        query.status,
323                        offset,
324                        Some(batch_size),
325                        Some(OrderBy::PublishedAt),
326                        Some(OrderDirection::Desc),
327                        query.before,
328                        query.after,
329                        query.before_entry_id,
330                        query.after_entry_id,
331                        query.starred,
332                        client,
333                    )
334                    .await?;
335
336                let entry_count = entries.len();
337                let mut converted = Miniflux::convert_entry_vec(entries, self.portal.clone()).await;
338                articles.append(&mut converted.articles);
339                enclosures.append(&mut converted.enclosures);
340
341                if entry_count < batch_size as usize {
342                    break;
343                }
344
345                offset = match offset {
346                    Some(offset) => Some(offset + batch_size),
347                    None => Some(batch_size),
348                };
349            }
350            return Ok(StreamConversionResult {
351                articles,
352                headlines: Vec::new(),
353                taggings: Vec::new(),
354                enclosures,
355            });
356        }
357        Err(FeedApiError::Login)
358    }
359
360    fn article_ids_to_i64(ids: &[ArticleID]) -> Vec<i64> {
361        ids.iter().filter_map(|id| Self::article_id_to_i64(id).ok()).collect()
362    }
363
364    fn article_id_to_i64(id: &ArticleID) -> Result<i64, FeedApiError> {
365        id.as_str().parse::<i64>().map_err(|_| {
366            tracing::error!(%id, "Failed to parse ID");
367            FeedApiError::Unknown
368        })
369    }
370
371    fn feed_id_to_i64(id: &FeedID) -> Result<i64, FeedApiError> {
372        id.as_str().parse::<i64>().map_err(|_| {
373            tracing::error!(%id, "Failed to parse ID");
374            FeedApiError::Unknown
375        })
376    }
377
378    fn category_id_to_i64(id: &CategoryID) -> Result<i64, FeedApiError> {
379        id.as_str().parse::<i64>().map_err(|_| {
380            tracing::error!(%id, "Failed to parse ID");
381            FeedApiError::Unknown
382        })
383    }
384}
385
386#[async_trait]
387impl FeedApi for Miniflux {
388    fn features(&self) -> FeedApiResult<PluginCapabilities> {
389        Ok(PluginCapabilities::ADD_REMOVE_FEEDS | PluginCapabilities::SUPPORT_CATEGORIES | PluginCapabilities::MODIFY_CATEGORIES)
390    }
391
392    fn has_user_configured(&self) -> FeedApiResult<bool> {
393        Ok(self.api.is_some())
394    }
395
396    async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
397        if let Some(api) = &self.api {
398            api.healthcheck(client).await?;
399            Ok(true)
400        } else {
401            Err(FeedApiError::Login)
402        }
403    }
404
405    async fn is_logged_in(&self, _client: &Client) -> FeedApiResult<bool> {
406        Ok(self.logged_in)
407    }
408
409    async fn user_name(&self) -> Option<String> {
410        self.config.get_user_name()
411    }
412
413    async fn get_login_data(&self) -> Option<LoginData> {
414        if let Ok(true) = self.has_user_configured() {
415            if let (Some(username), Some(password)) = (self.config.get_user_name(), self.config.get_password()) {
416                return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
417                    id: MinifluxMetadata::get_id(),
418                    url: self.config.get_url(),
419                    user: username,
420                    password,
421                    basic_auth: None, // miniflux authentication already uses basic auth
422                })));
423            } else if let Some(token) = self.config.get_token() {
424                return Some(LoginData::Direct(DirectLogin::Token(TokenLogin {
425                    id: MinifluxMetadata::get_id(),
426                    url: self.config.get_url(),
427                    token,
428                    basic_auth: None,
429                })));
430            }
431        }
432
433        None
434    }
435
436    async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
437        if let LoginData::Direct(simple_login_data) = data {
438            let api = match simple_login_data {
439                DirectLogin::Password(password_data) => {
440                    if let Some(url_string) = password_data.url.clone() {
441                        self.config.set_url(&url_string);
442                        self.config.set_password(&password_data.password);
443                        self.config.set_user_name(&password_data.user);
444                        self.config.clear_token();
445
446                        let url = Url::parse(&url_string)?;
447                        MinifluxApi::new(&url, password_data.user.clone(), password_data.password)
448                    } else {
449                        tracing::error!("No URL set");
450                        return Err(FeedApiError::Login);
451                    }
452                }
453                DirectLogin::Token(token_data) => {
454                    if let Some(url_string) = token_data.url.clone() {
455                        self.config.set_url(&url_string);
456                        self.config.set_token(&token_data.token);
457                        self.config.clear_user_name();
458                        self.config.clear_password();
459
460                        let url = Url::parse(&url_string)?;
461                        MinifluxApi::new_from_token(&url, token_data.token)
462                    } else {
463                        tracing::error!("No URL set");
464                        return Err(FeedApiError::Login);
465                    }
466                }
467            };
468
469            if self.config.get_user_name().is_none() {
470                let user = api.get_current_user(client).await?;
471                self.config.set_user_name(&user.username);
472            }
473
474            self.config.write()?;
475            self.api = Some(api);
476            self.logged_in = true;
477            return Ok(());
478        }
479
480        self.logged_in = false;
481        self.api = None;
482        Err(FeedApiError::Login)
483    }
484
485    async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
486        self.config.delete()?;
487        Ok(())
488    }
489
490    async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
491        if let Some(api) = &self.api {
492            let categories = api.get_categories(client);
493            let feeds = api.get_feeds(client);
494
495            let starred = self.get_articles(STARRED_QUERY, client);
496            let unread = self.get_articles(UNREAD_QUERY, client);
497
498            let (categories, feeds, starred, unread) = futures::join!(categories, feeds, starred, unread);
499
500            let (categories, category_mappings) = Miniflux::convert_category_vec(categories?);
501            let (feeds, feed_mappings) = Miniflux::convert_feed_vec(feeds?);
502
503            let mut starred = starred?;
504            let mut unread = unread?;
505
506            let mut articles: Vec<FatArticle> = Vec::new();
507            articles.append(&mut starred.articles);
508            articles.append(&mut unread.articles);
509
510            let mut enclosures: Vec<Enclosure> = Vec::new();
511            enclosures.append(&mut starred.enclosures);
512            enclosures.append(&mut unread.enclosures);
513
514            // latest read articles
515            let entries = api
516                .get_entries(
517                    Some(EntryStatus::Read),
518                    None,
519                    Some(100),
520                    Some(OrderBy::PublishedAt),
521                    Some(OrderDirection::Desc),
522                    None,
523                    None,
524                    None,
525                    None,
526                    None,
527                    client,
528                )
529                .await?;
530            let mut read = Miniflux::convert_entry_vec(entries, self.portal.clone()).await;
531            articles.append(&mut read.articles);
532            enclosures.append(&mut read.enclosures);
533
534            return Ok(SyncResult {
535                feeds: util::vec_to_option(feeds),
536                categories: util::vec_to_option(categories),
537                feed_mappings: util::vec_to_option(feed_mappings),
538                category_mappings: util::vec_to_option(category_mappings),
539                tags: None,
540                taggings: None,
541                headlines: None,
542                articles: util::vec_to_option(articles),
543                enclosures: util::vec_to_option(enclosures),
544            });
545        }
546        Err(FeedApiError::Login)
547    }
548
549    async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
550        if let Some(api) = &self.api {
551            let max_count = self.portal.get_config().read().await.get_sync_amount();
552
553            let categories = api.get_categories(client);
554            let feeds = api.get_feeds(client);
555
556            let unread = self.get_articles(UNREAD_QUERY, client);
557            let starred = self.get_articles(STARRED_QUERY, client);
558
559            // get some recent read articles
560            let recent = api.get_entries(
561                Some(EntryStatus::Read),
562                None,
563                Some(i64::from(max_count)),
564                Some(OrderBy::PublishedAt),
565                Some(OrderDirection::Desc),
566                None,
567                None,
568                None,
569                None,
570                None,
571                client,
572            );
573
574            let (categories, feeds, starred, unread, recent) = futures::join!(categories, feeds, starred, unread, recent);
575
576            let (categories, category_mappings) = Miniflux::convert_category_vec(categories?);
577            let (feeds, feed_mappings) = Miniflux::convert_feed_vec(feeds?);
578
579            let mut recent = Miniflux::convert_entry_vec(recent?, self.portal.clone()).await;
580
581            let mut starred = starred?;
582            let mut unread = unread?;
583
584            let remote_unread_ids: HashSet<ArticleID> = unread.articles.iter().map(|a| &a.article_id).cloned().collect();
585            let remote_marked_ids: HashSet<ArticleID> = starred.articles.iter().map(|a| &a.article_id).cloned().collect();
586
587            let mut articles: Vec<FatArticle> = Vec::new();
588            articles.append(&mut unread.articles);
589            articles.append(&mut starred.articles);
590            articles.append(&mut recent.articles);
591
592            let mut enclosures: Vec<Enclosure> = Vec::new();
593            enclosures.append(&mut unread.enclosures);
594            enclosures.append(&mut starred.enclosures);
595            enclosures.append(&mut recent.enclosures);
596
597            let mut headlines: Vec<Headline> = Vec::new();
598
599            // get local IDs
600            let local_unread_ids = self.portal.get_article_ids_unread_all()?;
601            let local_marked_ids = self.portal.get_article_ids_marked_all()?;
602
603            let local_unread_ids: HashSet<ArticleID> = local_unread_ids.into_iter().collect();
604            let local_marked_ids: HashSet<ArticleID> = local_marked_ids.into_iter().collect();
605
606            // mark remotely read article as read
607            let mut should_mark_read_headlines = local_unread_ids
608                .difference(&remote_unread_ids)
609                .map(|id| Headline {
610                    article_id: ArticleID::new(&id.to_string()),
611                    unread: Read::Read,
612                    marked: if remote_marked_ids.contains(id) {
613                        Marked::Marked
614                    } else {
615                        Marked::Unmarked
616                    },
617                })
618                .collect();
619            headlines.append(&mut should_mark_read_headlines);
620
621            // unmark remotly unstarred articles locally
622            let mut missing_unmarked_headlines = local_marked_ids
623                .difference(&remote_marked_ids)
624                .map(|id| Headline {
625                    article_id: ArticleID::new(&id.to_string()),
626                    marked: Marked::Unmarked,
627                    unread: if remote_unread_ids.contains(id) { Read::Unread } else { Read::Read },
628                })
629                .collect();
630            headlines.append(&mut missing_unmarked_headlines);
631
632            Ok(SyncResult {
633                feeds: util::vec_to_option(feeds),
634                categories: util::vec_to_option(categories),
635                feed_mappings: util::vec_to_option(feed_mappings),
636                category_mappings: util::vec_to_option(category_mappings),
637                tags: None,
638                taggings: None,
639                headlines: Some(headlines),
640                articles: util::vec_to_option(articles),
641                enclosures: util::vec_to_option(enclosures),
642            })
643        } else {
644            Err(FeedApiError::Login)
645        }
646    }
647
648    async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
649        if let Some(api) = &self.api {
650            let miniflux_feed_id = Self::feed_id_to_i64(feed_id)?;
651            let miniflux_feed = api.get_feed(miniflux_feed_id, client).await?;
652            let feed = Miniflux::convert_feed(miniflux_feed);
653
654            let entries = api
655                .get_feed_entries(miniflux_feed_id, None, None, None, None, None, None, None, None, None, None, client)
656                .await?;
657            let result = Miniflux::convert_entry_vec(entries, self.portal.clone()).await;
658
659            Ok(FeedUpdateResult {
660                feed: Some(feed),
661                taggings: None,
662                articles: util::vec_to_option(result.articles),
663                enclosures: util::vec_to_option(result.enclosures),
664            })
665        } else {
666            Err(FeedApiError::Login)
667        }
668    }
669
670    async fn set_article_read(&self, articles: &[ArticleID], read: models::Read, client: &Client) -> FeedApiResult<()> {
671        if articles.is_empty() {
672            Ok(())
673        } else if let Some(api) = &self.api {
674            let entries = Miniflux::article_ids_to_i64(articles);
675            let status = match read {
676                models::Read::Read => EntryStatus::Read,
677                models::Read::Unread => EntryStatus::Unread,
678            };
679            api.update_entries_status(entries, status, client).await?;
680
681            return Ok(());
682        } else {
683            Err(FeedApiError::Login)
684        }
685    }
686
687    async fn set_article_marked(&self, articles: &[ArticleID], _marked: models::Marked, client: &Client) -> FeedApiResult<()> {
688        if let Some(api) = &self.api {
689            // since only the articles that need to be updated are passed to this method
690            // we can ignore the "marked" parameter and simply toggle the bookmark state of all articles
691
692            for article in articles {
693                if let Ok(entry_id) = article.as_str().parse::<i64>() {
694                    api.toggle_bookmark(entry_id, client).await?;
695                }
696            }
697
698            return Ok(());
699        }
700        Err(FeedApiError::Login)
701    }
702
703    async fn set_feed_read(&self, _feeds: &[FeedID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
704        self.set_article_read(articles, Read::Read, client).await
705    }
706
707    async fn set_category_read(&self, _categories: &[CategoryID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
708        self.set_article_read(articles, Read::Read, client).await
709    }
710
711    async fn set_tag_read(&self, _tags: &[TagID], _articles: &[ArticleID], _client: &Client) -> FeedApiResult<()> {
712        Err(FeedApiError::Unsupported)
713    }
714
715    async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
716        self.set_article_read(articles, Read::Read, client).await
717    }
718
719    async fn add_feed(
720        &self,
721        url: &Url,
722        title: Option<String>,
723        category_id: Option<CategoryID>,
724        client: &Client,
725    ) -> FeedApiResult<(Feed, Option<Category>)> {
726        if let Some(api) = &self.api {
727            let category_id = match category_id {
728                Some(category_id) => Self::category_id_to_i64(&category_id)?,
729                None => {
730                    tracing::info!("Creating empty category for feed");
731                    match api.create_category(DEFAULT_CATEGORY, client).await {
732                        Ok(category) => category.id,
733                        Err(_) => {
734                            tracing::warn!("Creating empty category failed");
735                            tracing::info!("Checking if 'New Category' already exists");
736
737                            let categories = api.get_categories(client).await?;
738
739                            match categories.iter().find(|c| c.title == DEFAULT_CATEGORY) {
740                                Some(new_category) => new_category.id,
741                                None => match categories.first() {
742                                    Some(first_category) => first_category.id,
743                                    None => {
744                                        let msg = "Was not able to create or find cateogry to add feed into";
745                                        tracing::error!("{msg}");
746                                        return Err(FeedApiError::Api { message: msg.into() });
747                                    }
748                                },
749                            }
750                        }
751                    }
752                }
753            };
754
755            let feed_id = api.create_feed(url, category_id, client).await?;
756
757            if let Some(title) = title {
758                api.update_feed(feed_id, Some(&title), None, None, None, None, None, None, client).await?;
759            }
760
761            let feed = api.get_feed(feed_id, client).await?;
762            let category = api
763                .get_categories(client)
764                .await?
765                .iter()
766                .find(|c| c.id == category_id)
767                .map(|c| Miniflux::convert_category(c.clone(), None))
768                .map(|(c, _m)| c);
769
770            return Ok((Miniflux::convert_feed(feed), category));
771        }
772        Err(FeedApiError::Login)
773    }
774
775    async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
776        if let Some(api) = &self.api {
777            let feed_id = Self::feed_id_to_i64(id)?;
778            api.delete_feed(feed_id, client).await?;
779            return Ok(());
780        }
781        Err(FeedApiError::Login)
782    }
783
784    async fn move_feed(&self, feed_id: &FeedID, _from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
785        if let Some(api) = &self.api {
786            let category_id = Self::category_id_to_i64(to)?;
787
788            let miniflux_feed_id = Self::feed_id_to_i64(feed_id)?;
789
790            api.update_feed(miniflux_feed_id, None, Some(category_id), None, None, None, None, None, client)
791                .await?;
792            return Ok(());
793        }
794        Err(FeedApiError::Login)
795    }
796
797    async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
798        if let Some(api) = &self.api {
799            let miniflux_feed_id = Self::feed_id_to_i64(feed_id)?;
800
801            api.update_feed(miniflux_feed_id, Some(new_title), None, None, None, None, None, None, client)
802                .await?;
803
804            return Ok(feed_id.clone());
805        }
806        Err(FeedApiError::Login)
807    }
808
809    async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
810        Err(FeedApiError::Unsupported)
811    }
812
813    async fn add_category<'a>(&self, title: &str, _parent: Option<&'a CategoryID>, client: &Client) -> FeedApiResult<CategoryID> {
814        if let Some(api) = &self.api {
815            let category = api.create_category(title, client).await?;
816            return Ok(CategoryID::new(&category.id.to_string()));
817        }
818        Err(FeedApiError::Login)
819    }
820
821    async fn remove_category(&self, id: &CategoryID, _remove_children: bool, client: &Client) -> FeedApiResult<()> {
822        if let Some(api) = &self.api {
823            // FIXME: figure out how api behaves regarding deleting child feeds
824            let miniflux_id = Self::category_id_to_i64(id)?;
825            api.delete_category(miniflux_id, client).await?;
826            return Ok(());
827        }
828        Err(FeedApiError::Login)
829    }
830
831    async fn rename_category(&self, id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
832        if let Some(api) = &self.api {
833            let miniflux_id = Self::category_id_to_i64(id)?;
834            api.update_category(miniflux_id, new_title, client).await?;
835            return Ok(id.clone());
836        }
837        Err(FeedApiError::Login)
838    }
839
840    async fn move_category(&self, _id: &CategoryID, _parent: &CategoryID, _client: &Client) -> FeedApiResult<()> {
841        Err(FeedApiError::Unsupported)
842    }
843
844    async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
845        if let Some(api) = &self.api {
846            api.import_opml(opml, client).await?;
847        }
848        Err(FeedApiError::Login)
849    }
850
851    async fn add_tag(&self, _title: &str, _client: &Client) -> FeedApiResult<TagID> {
852        Err(FeedApiError::Unsupported)
853    }
854
855    async fn remove_tag(&self, _id: &TagID, _client: &Client) -> FeedApiResult<()> {
856        Err(FeedApiError::Unsupported)
857    }
858
859    async fn rename_tag(&self, _id: &TagID, _new_title: &str, _client: &Client) -> FeedApiResult<TagID> {
860        Err(FeedApiError::Unsupported)
861    }
862
863    async fn tag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
864        Err(FeedApiError::Unsupported)
865    }
866
867    async fn untag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
868        Err(FeedApiError::Unsupported)
869    }
870
871    async fn get_favicon(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
872        if let Some(api) = &self.api {
873            let miniflux_feed_id = Self::feed_id_to_i64(feed_id)?;
874
875            let favicon = api.get_feed_icon(miniflux_feed_id, client).await?;
876
877            if let Some(start) = favicon.data.find(',') {
878                let data = base64_std.decode(&favicon.data[start + 1..]).map_err(|_| FeedApiError::Encryption)?;
879
880                let favicon = FavIcon {
881                    feed_id: feed_id.clone(),
882                    expires: Utc::now() + Duration::try_days(EXPIRES_AFTER_DAYS).unwrap(),
883                    format: Some(favicon.mime_type),
884                    etag: None,
885                    source_url: None,
886                    data: Some(data),
887                };
888
889                return Ok(favicon);
890            }
891        }
892        Err(FeedApiError::Login)
893    }
894}