Skip to main content

news_flash/feed_api_implementations/feedbin/
mod.rs

1pub mod config;
2pub mod metadata;
3
4use self::config::AccountConfig;
5use self::metadata::FeedbinMetadata;
6use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
7use crate::models::{self, CategoryMapping, DirectLogin, FeedUpdateResult, StreamConversionResult};
8use crate::models::{
9    ArticleID, Category, CategoryID, Enclosure, FatArticle, FavIcon, Feed, FeedID, FeedMapping, Headline, LoginData, Marked, NEWSFLASH_TOPLEVEL,
10    PasswordLogin, PluginCapabilities, Read, SyncResult, TagID, Url,
11};
12use crate::util;
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use feedbin_api::ApiError as FeedbinError;
16use feedbin_api::models::{
17    CacheRequestResponse, CacheResult, CreateSubscriptionResult, Entry, Icon as FeedbinIcon, Subscription, Tagging as FeedbinTagging,
18};
19use feedbin_api::{EntryID, FeedbinApi};
20use reqwest::Client;
21use reqwest::header::{HeaderMap, HeaderValue};
22use std::collections::{HashMap, HashSet};
23use std::sync::Arc;
24use tokio::sync::RwLock;
25use url::Host;
26
27impl From<FeedbinError> for FeedApiError {
28    fn from(error: FeedbinError) -> FeedApiError {
29        match error {
30            FeedbinError::Url(e) => FeedApiError::Url(e),
31            FeedbinError::ServerIsBroken => FeedApiError::Api {
32                message: FeedbinError::ServerIsBroken.to_string(),
33            },
34            FeedbinError::Json { source, json } => FeedApiError::Json { source, json },
35            FeedbinError::Network(e) => FeedApiError::Network(e),
36            FeedbinError::InvalidLogin => FeedApiError::Auth,
37            FeedbinError::AccessDenied => FeedApiError::Auth,
38            FeedbinError::InputSize => FeedApiError::Api {
39                message: FeedbinError::InputSize.to_string(),
40            },
41            FeedbinError::InvalidCaching => FeedApiError::Api {
42                message: FeedbinError::InvalidCaching.to_string(),
43            },
44        }
45    }
46}
47
48pub struct Feedbin {
49    api: Option<FeedbinApi>,
50    portal: Box<dyn Portal>,
51    config: Arc<RwLock<AccountConfig>>,
52}
53
54impl Feedbin {
55    fn api_subdomain_url(url: &Url) -> Option<Url> {
56        if let Some(Host::Domain(host_string)) = url.host().to_owned()
57            && !host_string.starts_with("api.")
58        {
59            let mut api_url = url.clone();
60            api_url.set_host(Some(&format!("api.{host_string}"))).ok();
61            return Some(api_url);
62        }
63
64        None
65    }
66
67    fn parse_itunes_duration(itunes_duration: Option<String>) -> Option<i32> {
68        let duration_str = itunes_duration?;
69        let mut components = duration_str.split(':');
70
71        let hours = components.next()?;
72        let minutes = components.next()?;
73        let seconds = components.next()?;
74
75        if components.next().is_some() {
76            return None;
77        }
78
79        let hours = hours.parse::<i32>().ok()?;
80        let minutes = minutes.parse::<i32>().ok()?;
81        let seconds = seconds.parse::<i32>().ok()?;
82
83        Some(hours * 360 + minutes * 60 + seconds)
84    }
85
86    fn taggings_to_categories(&self, taggings: &CacheRequestResponse<Vec<FeedbinTagging>>) -> FeedApiResult<(Vec<Category>, Vec<CategoryMapping>)> {
87        let taggings = match taggings {
88            CacheRequestResponse::NotModified => {
89                let categories = self.portal.get_categories()?;
90                let category_mappings = self.portal.get_category_mappings()?;
91                return Ok((categories, category_mappings));
92            }
93            CacheRequestResponse::Modified(CacheResult {
94                value: taggings,
95                cache: _cache,
96            }) => taggings,
97        };
98        let category_names: HashSet<String> = taggings.iter().map(|t| t.name.clone()).collect();
99        Ok(category_names
100            .into_iter()
101            .enumerate()
102            .map(|(i, n)| {
103                let category_id = CategoryID::new(&n);
104                let category = Category {
105                    category_id: category_id.clone(),
106                    label: n,
107                };
108                let category_mapping = CategoryMapping {
109                    parent_id: NEWSFLASH_TOPLEVEL.clone(),
110                    category_id,
111                    sort_index: Some(i as i32),
112                };
113
114                (category, category_mapping)
115            })
116            .unzip())
117    }
118
119    fn subscriptions_to_feeds(
120        &self,
121        subscriptions: Vec<Subscription>,
122        icons: Vec<FeedbinIcon>,
123        taggings: &CacheRequestResponse<Vec<FeedbinTagging>>,
124    ) -> FeedApiResult<(Vec<Feed>, Vec<FeedMapping>)> {
125        let icon_map: HashMap<String, String> = icons.into_iter().map(|i| (i.host, i.url)).collect();
126        let taggings = match taggings {
127            CacheRequestResponse::NotModified => {
128                let feeds = self.portal.get_feeds()?;
129                let feed_mappings = self.portal.get_feed_mappings()?;
130                return Ok((feeds, feed_mappings));
131            }
132            CacheRequestResponse::Modified(CacheResult {
133                value: taggings,
134                cache: _cache,
135            }) => taggings,
136        };
137
138        let taggings: HashMap<u64, String> = taggings.iter().map(|t| (t.feed_id, t.name.clone())).collect();
139
140        Ok(subscriptions
141            .into_iter()
142            .enumerate()
143            .filter_map(move |(i, s)| {
144                let title = s.title?;
145
146                let feed_id_u64 = s.feed_id;
147                let feed_id = FeedID::new(&feed_id_u64.to_string());
148                let website = Url::parse(&s.site_url).ok();
149                let feed_url = Url::parse(&s.feed_url).ok();
150                let icon_url = website
151                    .clone()
152                    .and_then(|url| url.host_str().map(|s| s.to_owned()))
153                    .and_then(|host| icon_map.get(&host))
154                    .and_then(|icon_url| Url::parse(icon_url).ok());
155                let feed = Feed {
156                    feed_id: feed_id.clone(),
157                    label: title,
158                    website,
159                    feed_url,
160                    icon_url,
161                    error_count: 0,
162                    error_message: None,
163                };
164                let feed_mapping = FeedMapping {
165                    feed_id,
166                    category_id: taggings
167                        .get(&feed_id_u64)
168                        .map(|name| CategoryID::new(name))
169                        .unwrap_or_else(|| NEWSFLASH_TOPLEVEL.clone()),
170                    sort_index: Some(i as i32),
171                };
172
173                Some((feed, feed_mapping))
174            })
175            .unzip())
176    }
177
178    fn subscription_to_feed(&self, subscription: Subscription, icons: Vec<FeedbinIcon>) -> Option<Feed> {
179        let title = subscription.title?;
180
181        let icon_map: HashMap<String, String> = icons.into_iter().map(|i| (i.host, i.url)).collect();
182        let website = Url::parse(&subscription.site_url).ok();
183        let feed_url = Url::parse(&subscription.feed_url).ok();
184        let icon_url = website
185            .clone()
186            .and_then(|url| url.host_str().map(|s| s.to_owned()))
187            .and_then(|host| icon_map.get(&host))
188            .and_then(|icon_url| Url::parse(icon_url).ok());
189        Some(Feed {
190            feed_id: FeedID::new(&subscription.feed_id.to_string()),
191            label: title,
192            website,
193            feed_url,
194            icon_url,
195            error_count: 0,
196            error_message: None,
197        })
198    }
199
200    fn entries_to_articles(
201        entries: Vec<Entry>,
202        unread_entry_ids: &HashSet<EntryID>,
203        starred_entry_ids: &HashSet<EntryID>,
204        feed_ids: &HashSet<FeedID>,
205        portal: &dyn Portal,
206    ) -> StreamConversionResult {
207        let mut enclosures: Vec<Enclosure> = Vec::new();
208        let articles = entries
209            .into_iter()
210            .filter_map(|e| {
211                let Entry {
212                    id,
213                    feed_id,
214                    title,
215                    url,
216                    extracted_content_url: _,
217                    author,
218                    content,
219                    summary,
220                    published,
221                    created_at: _,
222                    original,
223                    images,
224                    enclosure,
225                    extracted_articles: _,
226                } = e;
227
228                let feed_id = FeedID::new(&feed_id.to_string());
229
230                if !feed_ids.contains(&feed_id) && !starred_entry_ids.contains(&id) {
231                    return None;
232                }
233
234                if let Some(enclosure) = enclosure
235                    && let Ok(url) = Url::parse(&enclosure.enclosure_url)
236                {
237                    enclosures.push(Enclosure {
238                        article_id: ArticleID::new(&id.to_string()),
239                        url,
240                        mime_type: Some(enclosure.enclosure_type),
241                        title: None,
242                        position: None,
243                        summary: None,
244                        thumbnail_url: enclosure.itunes_image,
245                        filesize: enclosure.enclosure_length.and_then(|length| length.parse::<i32>().ok()),
246                        width: None,
247                        height: None,
248                        duration: Self::parse_itunes_duration(enclosure.itunes_duration),
249                        framerate: None,
250                        alternative: None,
251                        is_default: false,
252                    });
253                }
254                let article_id = ArticleID::new(&id.to_string());
255
256                let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
257
258                let plain_text = match &content {
259                    Some(content) => Some(util::html2text::html2text(content)),
260                    None => summary.as_ref().cloned(),
261                };
262
263                let summary = if article_exists_locally { None } else { summary.as_ref().cloned() };
264
265                let thumbnail_url = images.map(|img| img.original_url);
266
267                Some(FatArticle {
268                    article_id,
269                    title: title.map(|t| match escaper::decode_html(&t) {
270                        Ok(title) => title,
271                        Err(_error) => {
272                            // This warning freaks users out for some reason
273                            // warn!("Error {:?} at character {}", error.kind, error.position);
274                            t
275                        }
276                    }),
277                    author,
278                    feed_id,
279                    url: url.and_then(|url| Url::parse(&url).ok()),
280                    date: match DateTime::parse_from_str(&published, "%+") {
281                        Ok(date) => date.with_timezone(&Utc),
282                        Err(_) => Utc::now(),
283                    },
284                    synced: Utc::now(),
285                    updated: None,
286                    html: match original.and_then(|original| original.content) {
287                        Some(original_content) => Some(original_content),
288                        None => match content {
289                            Some(content) => Some(content),
290                            None => summary.as_ref().cloned(),
291                        },
292                    },
293                    summary: summary.as_deref().map(util::html2text::text2summary),
294                    direction: None,
295                    unread: if unread_entry_ids.contains(&id) { Read::Unread } else { Read::Read },
296                    marked: if starred_entry_ids.contains(&id) {
297                        Marked::Marked
298                    } else {
299                        Marked::Unmarked
300                    },
301                    scraped_content: None,
302                    plain_text,
303                    thumbnail_url,
304                })
305            })
306            .collect();
307
308        StreamConversionResult {
309            articles,
310            headlines: Vec::new(),
311            taggings: Vec::new(),
312            enclosures,
313        }
314    }
315
316    fn article_ids_to_entry_ids(article_ids: &[ArticleID]) -> Vec<EntryID> {
317        article_ids.iter().filter_map(|id| Self::article_id_to_entry_id(id).ok()).collect()
318    }
319
320    fn article_id_to_entry_id(id: &ArticleID) -> Result<EntryID, FeedApiError> {
321        let parsed_id = id.as_str().parse::<u64>().map_err(|_| FeedApiError::Api {
322            message: format!("Failed to parse id {id}"),
323        })?;
324        Ok(parsed_id)
325    }
326
327    fn feed_id_to_u64(id: &FeedID) -> Result<EntryID, FeedApiError> {
328        let parsed_id = id.as_str().parse::<u64>().map_err(|_| FeedApiError::Api {
329            message: format!("Failed to parse id {id}"),
330        })?;
331        Ok(parsed_id)
332    }
333
334    async fn initial_sync_impl(&self, client: &Client) -> FeedApiResult<SyncResult> {
335        if let Some(api) = &self.api {
336            let subscription_cache = self.config.read().await.get_subscription_cache();
337            let taggings_cache = self.config.read().await.get_taggins_cache();
338
339            let subscriptions = api.get_subscriptions(client, None, None, subscription_cache).await?;
340            let taggings = api.get_taggings(client, taggings_cache).await?;
341
342            self.config.write().await.set_subscription_cache(&subscriptions);
343            self.config.write().await.set_taggins_cache(&taggings);
344            self.config.read().await.save()?;
345
346            let (feeds, feed_mappings) = match subscriptions {
347                CacheRequestResponse::NotModified => (self.portal.get_feeds()?, self.portal.get_feed_mappings()?),
348                CacheRequestResponse::Modified(CacheResult {
349                    value: subscriptions,
350                    cache: _cache,
351                }) => self.subscriptions_to_feeds(subscriptions, api.get_icons(client).await?, &taggings)?,
352            };
353
354            let mut articles: Vec<FatArticle> = Vec::new();
355            let mut enclosures: Vec<Enclosure> = Vec::new();
356
357            let unread_entry_ids = api.get_unread_entry_ids(client).await?;
358            let starred_entry_ids = api.get_starred_entry_ids(client).await?;
359
360            let unread_entry_id_set: HashSet<EntryID> = unread_entry_ids.iter().copied().collect();
361            let starred_entry_id_set: HashSet<EntryID> = starred_entry_ids.iter().copied().collect();
362            let feed_id_set: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
363
364            let entry_ids_total: Vec<EntryID> = unread_entry_id_set.union(&starred_entry_id_set).copied().collect();
365
366            for entry_ids_total_chunk in entry_ids_total.chunks(100) {
367                let entries_total_chunk = api
368                    .get_entries(client, None, None, Some(entry_ids_total_chunk), None, Some(true), true)
369                    .await?;
370                let mut total = Self::entries_to_articles(
371                    entries_total_chunk,
372                    &unread_entry_id_set,
373                    &starred_entry_id_set,
374                    &feed_id_set,
375                    self.portal.as_ref(),
376                );
377                articles.append(&mut total.articles);
378                enclosures.append(&mut total.enclosures);
379            }
380
381            let (categories, category_mappings) = self.taggings_to_categories(&taggings)?;
382
383            return Ok(SyncResult {
384                feeds: util::vec_to_option(feeds),
385                categories: util::vec_to_option(categories),
386                feed_mappings: util::vec_to_option(feed_mappings),
387                category_mappings: util::vec_to_option(category_mappings),
388                tags: None,
389                taggings: None,
390                headlines: None,
391                articles: util::vec_to_option(articles),
392                enclosures: util::vec_to_option(enclosures),
393            });
394        }
395        Err(FeedApiError::Login)
396    }
397
398    async fn sync_impl(&self, last_sync: DateTime<Utc>, client: &Client) -> FeedApiResult<SyncResult> {
399        if let Some(api) = &self.api {
400            let subscription_cache = self.config.read().await.get_subscription_cache();
401            let taggings_cache = self.config.read().await.get_taggins_cache();
402
403            let subscriptions = api.get_subscriptions(client, None, None, subscription_cache);
404            let taggings = api.get_taggings(client, taggings_cache);
405
406            let unread_entry_ids = api.get_unread_entry_ids(client);
407            let starred_entry_ids = api.get_starred_entry_ids(client);
408
409            let (subscriptions, taggings, unread_entry_ids, starred_entry_ids) =
410                futures::try_join!(subscriptions, taggings, unread_entry_ids, starred_entry_ids)?;
411
412            self.config.write().await.set_subscription_cache(&subscriptions);
413            self.config.write().await.set_taggins_cache(&taggings);
414            self.config.read().await.save()?;
415
416            let (feeds, feed_mappings) = match subscriptions {
417                CacheRequestResponse::NotModified => (self.portal.get_feeds()?, self.portal.get_feed_mappings()?),
418                CacheRequestResponse::Modified(CacheResult {
419                    value: subscriptions,
420                    cache: _cache,
421                }) => self.subscriptions_to_feeds(subscriptions, api.get_icons(client).await?, &taggings)?,
422            };
423
424            let unread_entry_id_set: HashSet<EntryID> = unread_entry_ids.iter().copied().collect();
425            let starred_entry_id_set: HashSet<EntryID> = starred_entry_ids.iter().copied().collect();
426
427            let local_unread_ids = self.portal.get_article_ids_unread_all()?;
428            let local_unread_ids = Self::article_ids_to_entry_ids(&local_unread_ids);
429            let local_unread_ids = local_unread_ids.into_iter().collect();
430
431            let local_marked_ids = self.portal.get_article_ids_marked_all()?;
432            let local_marked_ids = Self::article_ids_to_entry_ids(&local_marked_ids);
433            let local_marked_ids = local_marked_ids.into_iter().collect();
434
435            let missing_unread_ids: HashSet<EntryID> = unread_entry_id_set.difference(&local_unread_ids).cloned().collect();
436            let missing_marked_ids: HashSet<EntryID> = starred_entry_id_set.difference(&local_marked_ids).cloned().collect();
437            let feed_id_set: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
438
439            // sync new unread/marked articles
440            let missing_ids: Vec<EntryID> = missing_marked_ids.union(&missing_unread_ids).copied().collect();
441            let mut result = StreamConversionResult::new();
442            let mut futures = Vec::new();
443
444            for missing_ids_chunk in missing_ids.chunks(100) {
445                futures.push(api.get_entries(client, None, None, Some(missing_ids_chunk), None, Some(true), true));
446            }
447
448            // latest articles
449            futures.push(api.get_entries(client, None, Some(last_sync), None, None, Some(true), true));
450
451            let futures_results = futures::future::try_join_all(futures).await?;
452
453            for missing_entry_chunk in futures_results {
454                let converted_missing_chunk = Self::entries_to_articles(
455                    missing_entry_chunk,
456                    &unread_entry_id_set,
457                    &starred_entry_id_set,
458                    &feed_id_set,
459                    self.portal.as_ref(),
460                );
461                result.add(converted_missing_chunk);
462            }
463
464            // mark remotely read article as read
465            let mut should_mark_read_headlines = local_unread_ids
466                .difference(&unread_entry_id_set)
467                .copied()
468                .map(|id| Headline {
469                    article_id: ArticleID::new(&id.to_string()),
470                    unread: Read::Read,
471                    marked: if starred_entry_id_set.contains(&id) {
472                        Marked::Marked
473                    } else {
474                        Marked::Unmarked
475                    },
476                })
477                .collect();
478            result.headlines.append(&mut should_mark_read_headlines);
479
480            // unmark remotly unstarred articles locally
481            let mut missing_unmarked_headlines = local_marked_ids
482                .difference(&starred_entry_id_set)
483                .copied()
484                .map(|id| Headline {
485                    article_id: ArticleID::new(&id.to_string()),
486                    marked: Marked::Unmarked,
487                    unread: if unread_entry_id_set.contains(&id) { Read::Unread } else { Read::Read },
488                })
489                .collect();
490            result.headlines.append(&mut missing_unmarked_headlines);
491
492            let (categories, category_mappings) = self.taggings_to_categories(&taggings)?;
493
494            Ok(SyncResult {
495                feeds: util::vec_to_option(feeds),
496                categories: util::vec_to_option(categories),
497                feed_mappings: util::vec_to_option(feed_mappings),
498                category_mappings: util::vec_to_option(category_mappings),
499                tags: None,
500                taggings: None,
501                headlines: util::vec_to_option(result.headlines),
502                articles: util::vec_to_option(result.articles),
503                enclosures: util::vec_to_option(result.enclosures),
504            })
505        } else {
506            Err(FeedApiError::Login)
507        }
508    }
509}
510
511#[async_trait]
512impl FeedApi for Feedbin {
513    fn features(&self) -> FeedApiResult<PluginCapabilities> {
514        Ok(PluginCapabilities::ADD_REMOVE_FEEDS | PluginCapabilities::SUPPORT_CATEGORIES | PluginCapabilities::MODIFY_CATEGORIES)
515    }
516
517    fn has_user_configured(&self) -> FeedApiResult<bool> {
518        Ok(self.api.is_some())
519    }
520
521    async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
522        if let Some(url) = self.config.read().await.get_url() {
523            let res = client.head(&url).send().await?;
524            Ok(res.status().is_success())
525        } else {
526            Err(FeedApiError::Login)
527        }
528    }
529
530    async fn is_logged_in(&self, client: &Client) -> FeedApiResult<bool> {
531        match &self.api {
532            None => Ok(false),
533            Some(api) => {
534                let authenticated = api.is_authenticated(client).await?;
535                Ok(authenticated)
536            }
537        }
538    }
539
540    async fn user_name(&self) -> Option<String> {
541        self.config.read().await.get_user_name()
542    }
543
544    async fn get_login_data(&self) -> Option<LoginData> {
545        if self.has_user_configured().unwrap_or(false) {
546            let username = self.config.read().await.get_user_name();
547            let password = self.config.read().await.get_password();
548
549            if let (Some(username), Some(password)) = (username, password) {
550                return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
551                    id: FeedbinMetadata::get_id(),
552                    url: self.config.read().await.get_url(),
553                    user: username,
554                    password,
555                    basic_auth: None, // feedbin authentication already uses basic auth
556                })));
557            }
558        }
559
560        None
561    }
562
563    async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
564        self.api = None;
565
566        if let LoginData::Direct(DirectLogin::Password(data)) = data
567            && let Some(mut url_string) = data.url.clone()
568        {
569            let url = Url::parse(&url_string)?;
570            let mut api = FeedbinApi::new(&url, data.user.clone(), data.password.clone());
571
572            let mut auth_req = api.is_authenticated(client).await;
573            if auth_req.is_err() {
574                if let Some(api_url) = Self::api_subdomain_url(&url) {
575                    tracing::info!(%api_url, "Trying to authenticate with base url");
576                    api = FeedbinApi::new(&api_url, data.user.clone(), data.password.clone());
577                    auth_req = api.is_authenticated(client).await;
578                    if auth_req.is_err() {
579                        return Err(FeedApiError::Auth);
580                    } else {
581                        url_string = api_url.to_string();
582                    }
583                } else {
584                    return Err(FeedApiError::Auth);
585                }
586            }
587
588            if let Ok(true) = auth_req {
589                let mut config_guard = self.config.write().await;
590                config_guard.set_url(&url_string);
591                config_guard.set_password(&data.password);
592                config_guard.set_user_name(&data.user);
593                config_guard.save()?;
594                self.api = Some(api);
595                return Ok(());
596            }
597        }
598
599        Err(FeedApiError::Login)
600    }
601
602    async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
603        self.config.read().await.delete()?;
604        Ok(())
605    }
606
607    async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
608        let result = self.initial_sync_impl(client).await;
609        if result.is_err() {
610            self.config.write().await.reset_subscription_cache();
611            self.config.write().await.reset_taggings_cache();
612        }
613        result
614    }
615
616    async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
617        let last_sync = self.portal.get_config().read().await.get_last_sync();
618        let result = self.sync_impl(last_sync, client).await;
619        if result.is_err() {
620            self.config.write().await.reset_subscription_cache();
621            self.config.write().await.reset_taggings_cache();
622        }
623        result
624    }
625
626    async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
627        if let Some(api) = &self.api {
628            let subscription_id = Self::feed_id_to_u64(feed_id)?;
629
630            // always getting 403 for unknown reason
631            //let subscription = api.get_subscription(client, subscription_id).await?;
632
633            let subscription_cache = self.config.read().await.get_subscription_cache();
634            let subscriptions = api.get_subscriptions(client, None, None, subscription_cache).await?;
635
636            let subscription = match subscriptions {
637                CacheRequestResponse::NotModified => None,
638                CacheRequestResponse::Modified(result) => result.value.into_iter().find(|s| s.feed_id == subscription_id),
639            }
640            .ok_or(FeedApiError::Unknown)?;
641
642            let icons = api.get_icons(client).await?;
643            let feed = self.subscription_to_feed(subscription, icons);
644
645            let unread_entry_ids = api.get_unread_entry_ids(client).await?;
646            let starred_entry_ids = api.get_starred_entry_ids(client).await?;
647
648            let unread_entry_id_set: HashSet<EntryID> = unread_entry_ids.iter().copied().collect();
649            let starred_entry_id_set: HashSet<EntryID> = starred_entry_ids.iter().copied().collect();
650
651            let entries = api.get_entries_for_feed(client, subscription_id, None).await?;
652            let entries = match entries {
653                CacheRequestResponse::Modified(result) => result.value,
654                CacheRequestResponse::NotModified => Vec::new(),
655            };
656
657            let mut feed_id_set = HashSet::new();
658            feed_id_set.insert(feed_id.clone());
659
660            let result = Self::entries_to_articles(entries, &unread_entry_id_set, &starred_entry_id_set, &feed_id_set, self.portal.as_ref());
661
662            Ok(FeedUpdateResult {
663                feed,
664                taggings: None,
665                articles: util::vec_to_option(result.articles),
666                enclosures: util::vec_to_option(result.enclosures),
667            })
668        } else {
669            Err(FeedApiError::Login)
670        }
671    }
672
673    async fn set_article_read(&self, articles: &[ArticleID], read: models::Read, client: &Client) -> FeedApiResult<()> {
674        if let Some(api) = &self.api {
675            match read {
676                Read::Unread => api.set_entries_unread(client, &Self::article_ids_to_entry_ids(articles)).await?,
677                Read::Read => api.set_entries_read(client, &Self::article_ids_to_entry_ids(articles)).await?,
678            }
679
680            return Ok(());
681        }
682        Err(FeedApiError::Login)
683    }
684
685    async fn set_article_marked(&self, articles: &[ArticleID], marked: models::Marked, client: &Client) -> FeedApiResult<()> {
686        if let Some(api) = &self.api {
687            match marked {
688                Marked::Unmarked => api.set_entries_unstarred(client, &Self::article_ids_to_entry_ids(articles)).await?,
689                Marked::Marked => api.set_entries_starred(client, &Self::article_ids_to_entry_ids(articles)).await?,
690            }
691
692            return Ok(());
693        }
694        Err(FeedApiError::Login)
695    }
696
697    async fn set_feed_read(&self, _feeds: &[FeedID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
698        self.set_article_read(articles, Read::Read, client).await
699    }
700
701    async fn set_category_read(&self, _categories: &[CategoryID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
702        self.set_article_read(articles, Read::Read, client).await
703    }
704
705    async fn set_tag_read(&self, _tags: &[TagID], _articles: &[ArticleID], _client: &Client) -> FeedApiResult<()> {
706        Err(FeedApiError::Unsupported)
707    }
708
709    async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
710        self.set_article_read(articles, Read::Read, client).await
711    }
712
713    async fn add_feed(
714        &self,
715        url: &Url,
716        title: Option<String>,
717        category: Option<CategoryID>,
718        client: &Client,
719    ) -> FeedApiResult<(Feed, Option<Category>)> {
720        if let Some(api) = &self.api {
721            let res = api.create_subscription(client, url.to_string()).await?;
722            match res {
723                CreateSubscriptionResult::NotFound | CreateSubscriptionResult::MultipleOptions(_) => return Err(FeedApiError::Unsupported),
724                CreateSubscriptionResult::Found(url) => {
725                    return Err(FeedApiError::Api {
726                        message: format!("Feed already present: {url}"),
727                    });
728                }
729                CreateSubscriptionResult::Created(mut subscription) => {
730                    let icons = api.get_icons(client).await?;
731                    if let Some(title) = title
732                        && subscription.title.as_ref() != Some(&title)
733                    {
734                        api.update_subscription(client, subscription.id, &title).await?;
735                        subscription.title = Some(title);
736                    }
737                    let mut res_category: Option<Category> = None;
738                    if let Some(category_id) = category {
739                        let title = category_id.to_string();
740                        api.create_tagging(client, subscription.feed_id, &title).await?;
741                        res_category = Some(Category { category_id, label: title });
742                    }
743                    if let Some(feed) = self.subscription_to_feed(subscription, icons) {
744                        return Ok((feed, res_category));
745                    } else {
746                        let message = "Subscription is missing a title";
747                        tracing::error!(%message);
748                        return Err(FeedApiError::Api {
749                            message: message.to_string(),
750                        });
751                    }
752                }
753            }
754        }
755        Err(FeedApiError::Login)
756    }
757
758    async fn remove_feed(&self, feed_id: &FeedID, client: &Client) -> FeedApiResult<()> {
759        if let Some(api) = &self.api {
760            let feed_id = Self::feed_id_to_u64(feed_id)?;
761            let subscriptions = match api.get_subscriptions(client, None, None, None).await? {
762                CacheRequestResponse::Modified(CacheResult {
763                    value: subscriptions,
764                    cache: _cache,
765                }) => subscriptions,
766                CacheRequestResponse::NotModified => return Err(FeedApiError::Unknown),
767            };
768            let subscription_id = subscriptions.iter().find(|s| s.feed_id == feed_id).map(|s| s.id);
769            if let Some(subscription_id) = subscription_id {
770                api.delete_subscription(client, subscription_id).await?;
771            }
772            return Ok(());
773        }
774        Err(FeedApiError::Login)
775    }
776
777    async fn move_feed(&self, feed_id: &FeedID, from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
778        if let Some(api) = &self.api {
779            let feed_id = Self::feed_id_to_u64(feed_id)?;
780            if from != &*NEWSFLASH_TOPLEVEL {
781                let taggings = match api.get_taggings(client, None).await? {
782                    CacheRequestResponse::Modified(CacheResult {
783                        value: taggings,
784                        cache: _cache,
785                    }) => taggings,
786                    CacheRequestResponse::NotModified => return Err(FeedApiError::Unknown),
787                };
788                let tagging_id = taggings.iter().find(|t| t.name == from.as_str() && t.feed_id == feed_id).map(|t| t.id);
789                if let Some(tagging_id) = tagging_id {
790                    api.delete_tagging(client, tagging_id).await?;
791                }
792            }
793
794            api.create_tagging(client, feed_id, to.as_str()).await?;
795
796            return Ok(());
797        }
798        Err(FeedApiError::Login)
799    }
800
801    async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
802        if let Some(api) = &self.api {
803            let subscriptions = match api.get_subscriptions(client, None, None, None).await? {
804                CacheRequestResponse::Modified(CacheResult {
805                    value: subscriptions,
806                    cache: _cache,
807                }) => subscriptions,
808                CacheRequestResponse::NotModified => return Err(FeedApiError::Unknown),
809            };
810            let subscription_id = subscriptions
811                .iter()
812                .find(|s| s.feed_id.to_string() == feed_id.as_str())
813                .map(|s| s.id)
814                .expect("Failed to get subscription ID");
815
816            api.update_subscription(client, subscription_id, new_title).await?;
817            return Ok(feed_id.clone());
818        }
819        Err(FeedApiError::Login)
820    }
821
822    async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
823        Err(FeedApiError::Unsupported)
824    }
825
826    async fn add_category<'a>(&self, title: &str, _parent: Option<&'a CategoryID>, _client: &Client) -> FeedApiResult<CategoryID> {
827        Ok(CategoryID::new(title))
828    }
829
830    async fn remove_category(&self, id: &CategoryID, remove_children: bool, client: &Client) -> FeedApiResult<()> {
831        if let Some(api) = &self.api {
832            api.delete_tag(client, id.as_str()).await?;
833            if remove_children {
834                let mappings = self.portal.get_feed_mappings()?;
835                for mapping in mappings {
836                    if &mapping.category_id == id {
837                        self.remove_feed(&mapping.feed_id, client).await?;
838                    }
839                }
840            }
841            return Ok(());
842        }
843        Err(FeedApiError::Login)
844    }
845
846    async fn move_category(&self, _id: &CategoryID, _parent: &CategoryID, _client: &Client) -> FeedApiResult<()> {
847        Err(FeedApiError::Unsupported)
848    }
849
850    async fn rename_category(&self, id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
851        if let Some(api) = &self.api {
852            api.rename_tag(client, id.as_str(), new_title).await?;
853            return Ok(id.clone());
854        }
855        Err(FeedApiError::Login)
856    }
857
858    async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
859        if let Some(api) = &self.api {
860            api.import_opml(client, opml).await?;
861            return Ok(());
862        }
863        Err(FeedApiError::Login)
864    }
865
866    async fn add_tag(&self, _title: &str, _client: &Client) -> FeedApiResult<TagID> {
867        Err(FeedApiError::Unsupported)
868    }
869
870    async fn remove_tag(&self, _id: &TagID, _client: &Client) -> FeedApiResult<()> {
871        Err(FeedApiError::Unsupported)
872    }
873
874    async fn rename_tag(&self, _id: &TagID, _new_title: &str, _client: &Client) -> FeedApiResult<TagID> {
875        Err(FeedApiError::Unsupported)
876    }
877
878    async fn tag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
879        Err(FeedApiError::Unsupported)
880    }
881
882    async fn untag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
883        Err(FeedApiError::Unsupported)
884    }
885
886    async fn get_favicon(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
887        Err(FeedApiError::Unsupported)
888    }
889}