Skip to main content

news_flash/feed_api_implementations/feedly/
mod.rs

1pub mod config;
2pub mod feedly_secrets;
3pub mod metadata;
4
5use self::config::AccountConfig;
6use self::feedly_secrets::FeedlySecrets;
7use self::metadata::FeedlyMetadata;
8use crate::ParsedUrl;
9use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
10use crate::models::{self, CategoryMapping, FeedConversionResult, FeedUpdateResult, StreamConversionResult};
11use crate::models::{
12    ArticleID, Category, CategoryID, Direction, Enclosure, FatArticle, FavIcon, Feed, FeedID, FeedMapping, Headline, LoginData, Marked,
13    NEWSFLASH_TOPLEVEL, OAuthData, PluginCapabilities, Read, SyncResult, Tag, TagID, Tagging, Url,
14};
15use crate::util::{self, feed_parser};
16use async_trait::async_trait;
17use chrono::{Duration, Utc};
18use feedly_api::models::{
19    Category as FeedlyCategory, Collection as FeedlyCollection, Content as FeedlyContent, Entry, Link, Subscription, SubscriptionInput,
20    Tag as FeedlyTag,
21};
22use feedly_api::{ApiError as FeedlyApiError, FeedlyApi};
23use futures::future;
24use regex::Regex;
25use reqwest::Client;
26use reqwest::header::{HeaderMap, HeaderValue};
27use std::collections::HashSet;
28use std::sync::Arc;
29use tokio::sync::RwLock;
30
31impl From<FeedlyApiError> for FeedApiError {
32    fn from(error: FeedlyApiError) -> FeedApiError {
33        match error {
34            FeedlyApiError::Url(e) => FeedApiError::Url(e),
35            FeedlyApiError::Json { source, json } => FeedApiError::Json { source, json },
36            FeedlyApiError::ManualJson => FeedApiError::Api {
37                message: FeedlyApiError::ManualJson.to_string(),
38            },
39            FeedlyApiError::Http(e) => FeedApiError::Network(e),
40            FeedlyApiError::Feedly(feedly_error) => FeedApiError::Api {
41                message: format!("Feedly Error (code {})\nMessage: {}", feedly_error.error_code, feedly_error.error_message),
42            },
43            FeedlyApiError::Input => FeedApiError::Api {
44                message: FeedlyApiError::Input.to_string(),
45            },
46            FeedlyApiError::Token => FeedApiError::Api {
47                message: FeedlyApiError::Token.to_string(),
48            },
49            FeedlyApiError::AccessDenied => FeedApiError::Auth,
50            FeedlyApiError::InternalMutabilty => FeedApiError::Api {
51                message: FeedlyApiError::InternalMutabilty.to_string(),
52            },
53            FeedlyApiError::TokenExpired => FeedApiError::Api {
54                message: FeedlyApiError::TokenExpired.to_string(),
55            },
56            FeedlyApiError::Unknown => FeedApiError::Unknown,
57        }
58    }
59}
60
61pub struct ArticleQuery<'a> {
62    pub stream_id: &'a str,
63    pub count: Option<u32>,
64    pub ranked: Option<&'a str>,
65    pub unread_only: Option<bool>,
66    pub newer_than: Option<u64>,
67    pub feed_ids: &'a HashSet<FeedID>,
68}
69
70pub struct Feedly {
71    api: Option<FeedlyApi>,
72    portal: Arc<Box<dyn Portal>>,
73    logged_in: bool,
74    config: Arc<RwLock<AccountConfig>>,
75}
76
77impl Feedly {
78    fn convert_tag_vec(mut tags: Vec<FeedlyTag>) -> Vec<Tag> {
79        tags.drain(..)
80            .enumerate()
81            .filter_map(|(i, t)| {
82                let FeedlyTag { id, label, description: _ } = t;
83                if id.contains("global") {
84                    return None;
85                }
86                Some(Tag {
87                    tag_id: TagID::new(&id),
88                    color: None,
89                    label: match label {
90                        Some(label) => label,
91                        None => {
92                            let mut tag_label = "Unknown".to_string();
93                            if let Some(l) = label {
94                                tag_label = l;
95                            } else if let Ok(regex) = Regex::new(r"user/\S*tag/(.*)")
96                                && let Some(captures) = regex.captures(&id)
97                                && let Some(regex_match) = captures.get(1)
98                            {
99                                regex_match.as_str().clone_into(&mut tag_label);
100                            }
101                            tag_label
102                        }
103                    },
104                    sort_index: Some(i as i32),
105                })
106            })
107            .collect()
108    }
109
110    fn convert_collection_vec(collections: Vec<FeedlyCollection>) -> FeedConversionResult {
111        let mut feed_mappings = Vec::new();
112        let mut categories = Vec::new();
113        let mut category_mappings = Vec::new();
114
115        let feeds = collections
116            .into_iter()
117            .enumerate()
118            .flat_map(|(index, collection)| {
119                let FeedlyCollection {
120                    id,
121                    label,
122                    description: _,
123                    feeds,
124                } = collection;
125
126                let category_id = CategoryID::new(&id);
127
128                let collection_category = Category {
129                    category_id: category_id.clone(),
130                    label: {
131                        let mut category_label = "Unknown".to_string();
132                        if let Some(l) = label {
133                            category_label = l;
134                        } else if let Ok(regex) = Regex::new(r"user/\S*category/(.*)")
135                            && let Some(captures) = regex.captures(&id)
136                            && let Some(regex_match) = captures.get(1)
137                        {
138                            regex_match.as_str().clone_into(&mut category_label);
139                        }
140                        category_label
141                    },
142                };
143                categories.push(collection_category);
144
145                let category_mapping = CategoryMapping {
146                    parent_id: NEWSFLASH_TOPLEVEL.clone(),
147                    category_id: category_id.clone(),
148                    sort_index: Some(index as i32),
149                };
150                category_mappings.push(category_mapping);
151
152                match feeds {
153                    Some(subscriptions) => subscriptions
154                        .into_iter()
155                        .filter_map(|feed| {
156                            let Subscription {
157                                id,
158                                title,
159                                categories: _,
160                                website,
161                                updated: _,
162                                subscribers: _,
163                                velocity: _,
164                                topics: _,
165                                content_type: _,
166                                icon_url,
167                                partial: _,
168                                sort_id: _,
169                                added: _,
170                                visual_url,
171                            } = feed;
172
173                            let title = match title {
174                                Some(title) => title,
175                                None => return None,
176                            };
177
178                            let feed_id = FeedID::new(&id);
179
180                            feed_mappings.push(FeedMapping {
181                                feed_id: feed_id.clone(),
182                                category_id: category_id.clone(),
183                                sort_index: Some(index as i32),
184                            });
185
186                            Some(Feed {
187                                feed_id,
188                                label: title,
189                                website: match website {
190                                    Some(url) => Url::parse(&url).ok(),
191                                    None => None,
192                                },
193                                feed_url: None,
194                                icon_url: match icon_url {
195                                    Some(url) => Url::parse(&url).ok(),
196                                    None => match visual_url {
197                                        Some(url) => Url::parse(&url).ok(),
198                                        None => None,
199                                    },
200                                },
201                                error_count: 0,
202                                error_message: None,
203                            })
204                        })
205                        .collect(),
206                    None => Vec::new(),
207                }
208            })
209            .collect();
210
211        FeedConversionResult {
212            feeds,
213            feed_mappings,
214            categories,
215            category_mappings,
216        }
217    }
218
219    async fn convert_entry_vec(
220        entries: Vec<Entry>,
221        marked_tag: &str,
222        feed_ids: &HashSet<FeedID>,
223        portal: Arc<Box<dyn Portal>>,
224    ) -> StreamConversionResult {
225        let enclosures: Arc<RwLock<Vec<Enclosure>>> = Arc::new(RwLock::new(Vec::new()));
226        let taggings: Arc<RwLock<Vec<Tagging>>> = Arc::new(RwLock::new(Vec::new()));
227        let headlines: Arc<RwLock<Vec<Headline>>> = Arc::new(RwLock::new(Vec::new()));
228
229        let tasks = entries
230            .into_iter()
231            .map(|e| {
232                let enclosures = enclosures.clone();
233                let taggings = taggings.clone();
234                let headlines = headlines.clone();
235                let marked_tag = marked_tag.to_owned();
236                let feed_ids = feed_ids.clone();
237                let portal = portal.clone();
238
239                tokio::spawn(async move {
240                    let Entry {
241                        id,
242                        title,
243                        content,
244                        summary,
245                        author,
246                        crawled: _,
247                        recrawled: _,
248                        published,
249                        updated,
250                        alternate,
251                        origin,
252                        keywords: _,
253                        visual,
254                        unread,
255                        tags,
256                        categories: _,
257                        engagement: _,
258                        action_timestamp: _,
259                        enclosure,
260                        fingerprint: _,
261                        origin_id: _,
262                        sid: _,
263                    } = e;
264
265                    let article_id = ArticleID::new(&id);
266                    let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
267
268                    let feed_id = match origin {
269                        Some(origin) => match origin.stream_id {
270                            Some(stream_id) => FeedID::new(&stream_id),
271                            None => FeedID::new("None"),
272                        },
273                        None => FeedID::new("None"),
274                    };
275
276                    let unread = if unread { models::Read::Unread } else { models::Read::Read };
277                    let marked = match tags {
278                        Some(ref tags) => match tags.iter().find(|t| t.id.contains(&marked_tag)) {
279                            Some(_) => models::Marked::Marked,
280                            None => models::Marked::Unmarked,
281                        },
282                        None => models::Marked::Unmarked,
283                    };
284
285                    if !feed_ids.contains(&feed_id) && marked == models::Marked::Unmarked {
286                        return None;
287                    }
288
289                    // already in db and wasn't updated by feedly
290                    // -> only need to update read/marked status
291                    if article_exists_locally && updated.is_none() {
292                        headlines.write().await.push(Headline { article_id, unread, marked });
293                        return None;
294                    }
295
296                    if let Some(ref mut article_enclosures) = Feedly::convert_enclosures(&enclosure, ArticleID::new(&id)) {
297                        enclosures.write().await.append(article_enclosures);
298                    }
299
300                    if let Some(tag_vec) = &tags {
301                        let mut article_taggings: Vec<Tagging> = tag_vec
302                            .iter()
303                            .filter(|t| !t.id.contains("global."))
304                            .map(|t| Tagging {
305                                article_id: ArticleID::new(&id),
306                                tag_id: TagID::new(&t.id),
307                            })
308                            .collect();
309                        taggings.write().await.append(&mut article_taggings);
310                    }
311
312                    let (html, direction) = match Feedly::convert_content(&content) {
313                        Some((html, direction)) => (Some(html), Some(direction)),
314                        None => match Feedly::convert_content(&summary) {
315                            Some((html, direction)) => (Some(html), Some(direction)),
316                            None => (None, None),
317                        },
318                    };
319
320                    let plain_text = if article_exists_locally {
321                        None
322                    } else {
323                        html.as_deref().map(util::html2text::html2text)
324                    };
325                    let summary = plain_text.as_deref().map(util::html2text::html2text);
326
327                    let thumbnail_url = visual.and_then(|vis| if vis.url == "none" { None } else { Some(vis.url) });
328
329                    Some(FatArticle {
330                        article_id,
331                        title,
332                        author,
333                        feed_id,
334                        url: match alternate {
335                            Some(alternates) => match alternates.first() {
336                                Some(link_obj) => Url::parse(&link_obj.href).ok(),
337                                None => None,
338                            },
339                            None => None,
340                        },
341                        date: util::timestamp_to_datetime(published / 1000),
342                        synced: Utc::now(),
343                        updated: updated.map(|timestamp| util::timestamp_to_datetime(timestamp / 1000)),
344                        html,
345                        summary,
346                        direction,
347                        unread,
348                        marked,
349                        scraped_content: None,
350                        plain_text,
351                        thumbnail_url,
352                    })
353                })
354            })
355            .collect::<Vec<_>>();
356
357        let articles = future::join_all(tasks).await.into_iter().filter_map(|res| res.ok().flatten()).collect();
358
359        StreamConversionResult {
360            articles,
361            headlines: Arc::into_inner(headlines).map(|e| e.into_inner()).unwrap_or_default(),
362            taggings: Arc::into_inner(taggings).map(|e| e.into_inner()).unwrap_or_default(),
363            enclosures: Arc::into_inner(enclosures).map(|e| e.into_inner()).unwrap_or_default(),
364        }
365    }
366
367    fn convert_content(content: &Option<FeedlyContent>) -> Option<(String, Direction)> {
368        match content {
369            Some(c) => {
370                let direction = match c.direction {
371                    Some(ref direction) => {
372                        if direction == "rtl" {
373                            Direction::RightToLeft
374                        } else {
375                            Direction::LeftToRight
376                        }
377                    }
378                    None => Direction::LeftToRight,
379                };
380
381                Some((c.content.clone(), direction))
382            }
383            None => None,
384        }
385    }
386
387    fn convert_enclosures(enclosures: &Option<Vec<Link>>, article_id: ArticleID) -> Option<Vec<Enclosure>> {
388        match enclosures {
389            Some(enclosure_vec) => {
390                let res = enclosure_vec
391                    .iter()
392                    .map(|enc| Feedly::convert_enclosure(enc, &article_id))
393                    .collect::<Result<Vec<Enclosure>, _>>();
394                res.ok()
395            }
396            None => None,
397        }
398    }
399
400    fn convert_enclosure(enc: &Link, article_id: &ArticleID) -> FeedApiResult<Enclosure> {
401        let url = Url::parse(&enc.href)?;
402        Ok(Enclosure {
403            article_id: article_id.clone(),
404            url,
405            mime_type: enc._type.clone(),
406            title: None,
407            position: None,
408            summary: None,
409            thumbnail_url: None,
410            filesize: None,
411            width: None,
412            height: None,
413            duration: None,
414            framerate: None,
415            alternative: None,
416            is_default: false,
417        })
418    }
419
420    async fn get_articles(&self, api: &FeedlyApi, query: ArticleQuery<'_>, client: &Client) -> FeedApiResult<StreamConversionResult> {
421        let mut continuation: Option<String> = None;
422        let mut articles: Vec<FatArticle> = Vec::new();
423        let mut enclosures: Vec<Enclosure> = Vec::new();
424        let mut taggings: Vec<Tagging> = Vec::new();
425        let mut headlines: Vec<Headline> = Vec::new();
426        let tag_marked = api.tag_marked(client).await?;
427
428        loop {
429            let stream = api
430                .get_stream(
431                    query.stream_id,
432                    continuation,
433                    query.count,
434                    query.ranked,
435                    query.unread_only,
436                    query.newer_than,
437                    client,
438                )
439                .await?;
440            let mut converted = Feedly::convert_entry_vec(stream.items, &tag_marked, query.feed_ids, self.portal.clone()).await;
441            articles.append(&mut converted.articles);
442            enclosures.append(&mut converted.enclosures);
443            taggings.append(&mut converted.taggings);
444            headlines.append(&mut converted.headlines);
445            continuation = stream.continuation;
446
447            if continuation.is_none() {
448                break;
449            }
450        }
451
452        Ok(StreamConversionResult {
453            articles,
454            enclosures,
455            taggings,
456            headlines,
457        })
458    }
459
460    async fn is_token_expired(&self) -> Result<bool, FeedlyApiError> {
461        let timestamp = self.config.write().await.get_token_expires().ok_or(FeedlyApiError::TokenExpired)?;
462        let timestamp = timestamp.parse::<i64>().map_err(|_| FeedlyApiError::TokenExpired)?;
463
464        let expires_at = util::timestamp_to_datetime(timestamp);
465        let expires_in = expires_at.signed_duration_since(Utc::now());
466        Ok(expires_in.num_seconds() <= 60)
467    }
468
469    async fn refresh_token(&self, api: &FeedlyApi, client: &Client) -> FeedApiResult<()> {
470        let response = api.refresh_auth_token(client).await?;
471        let token_expires = Utc::now() + Duration::try_seconds(i64::from(response.expires_in)).unwrap();
472        self.config.write().await.set_access_token(&response.access_token);
473        self.config.write().await.set_token_expires(&token_expires.timestamp().to_string());
474        self.config.write().await.write()?;
475        Ok(())
476    }
477
478    async fn check_and_update_token(&self, api: &FeedlyApi, client: &Client) -> FeedApiResult<()> {
479        if self.is_token_expired().await? {
480            self.refresh_token(api, client).await?;
481        }
482
483        Ok(())
484    }
485}
486
487#[async_trait]
488impl FeedApi for Feedly {
489    fn features(&self) -> FeedApiResult<PluginCapabilities> {
490        Ok(PluginCapabilities::ADD_REMOVE_FEEDS
491            | PluginCapabilities::SUPPORT_CATEGORIES
492            | PluginCapabilities::MODIFY_CATEGORIES
493            | PluginCapabilities::SUPPORT_TAGS)
494    }
495
496    fn has_user_configured(&self) -> FeedApiResult<bool> {
497        Ok(self.api.is_some())
498    }
499
500    async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
501        let res = client.head("https://cloud.feedly.com").send().await?;
502        Ok(res.status().is_success())
503    }
504
505    async fn is_logged_in(&self, _client: &Client) -> FeedApiResult<bool> {
506        Ok(self.logged_in)
507    }
508
509    async fn user_name(&self) -> Option<String> {
510        self.config.read().await.get_user_name()
511    }
512
513    async fn get_login_data(&self) -> Option<LoginData> {
514        if let Ok(true) = self.has_user_configured() {
515            return Some(LoginData::OAuth(OAuthData {
516                id: FeedlyMetadata::get_id(),
517                url: String::new(),
518                custom_api_secret: None,
519            }));
520        }
521
522        None
523    }
524
525    async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
526        if let LoginData::OAuth(data) = data {
527            let url = Url::parse(&data.url)?;
528            let secret_struct = FeedlySecrets::new();
529            match FeedlyApi::parse_redirected_url(&url) {
530                Ok(auth_code) => match FeedlyApi::request_auth_token(&secret_struct.id(), &secret_struct.secret(), auth_code, client).await {
531                    Ok(response) => {
532                        let now = Utc::now();
533                        let token_expires = now + Duration::try_seconds(i64::from(response.expires_in)).unwrap();
534                        let api = FeedlyApi::new(
535                            secret_struct.id(),
536                            secret_struct.secret(),
537                            response.access_token.clone(),
538                            response.refresh_token.clone(),
539                            token_expires,
540                        )?;
541                        api.initialize_user_id(client).await?;
542                        let profile = api.get_profile(client).await?;
543                        self.config.write().await.set_access_token(&response.access_token);
544                        self.config.write().await.set_refresh_token(&response.refresh_token);
545                        self.config.write().await.set_token_expires(&token_expires.timestamp().to_string());
546                        if let Some(user_name) = profile.given_name {
547                            self.config.write().await.set_user_name(&user_name);
548                        }
549                        self.config.read().await.write()?;
550
551                        self.api = Some(api);
552                        self.logged_in = true;
553                        return Ok(());
554                    }
555                    Err(_e) => {
556                        self.api = None;
557                        self.logged_in = false;
558                        return Err(FeedApiError::Login);
559                    }
560                },
561                Err(_e) => {
562                    self.api = None;
563                    self.logged_in = false;
564                    return Err(FeedApiError::Login);
565                }
566            }
567        }
568
569        self.api = None;
570        self.logged_in = false;
571        Err(FeedApiError::Login)
572    }
573
574    async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
575        self.config.read().await.delete()?;
576        Ok(())
577    }
578
579    async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
580        if let Some(api) = &self.api {
581            self.check_and_update_token(api, client).await?;
582
583            let tag_marked = api.tag_marked(client).await?;
584            let tag_all = api.category_all(client).await?;
585
586            let collections = api.get_collections(client);
587            let tags = api.get_tags(client);
588
589            let (collections, tags) = futures::try_join!(collections, tags)?;
590
591            let conversion_result = Feedly::convert_collection_vec(collections);
592            let feed_ids: HashSet<FeedID> = conversion_result.feeds.iter().map(|f| f.feed_id.clone()).collect();
593            let tags = Feedly::convert_tag_vec(tags);
594
595            let mut result = StreamConversionResult::new();
596            let mut futures = Vec::new();
597
598            // get marked articles
599            let query = ArticleQuery {
600                stream_id: &tag_marked,
601                count: Some(200),
602                ranked: None,
603                unread_only: None,
604                newer_than: None,
605                feed_ids: &feed_ids,
606            };
607            futures.push(self.get_articles(api, query, client));
608
609            // get tagged articles
610            for tag in &tags {
611                let query = ArticleQuery {
612                    stream_id: tag.tag_id.as_str(),
613                    count: Some(200),
614                    ranked: None,
615                    unread_only: None,
616                    newer_than: None,
617                    feed_ids: &feed_ids,
618                };
619
620                futures.push(self.get_articles(api, query, client));
621            }
622
623            // get unread articles
624            let query = ArticleQuery {
625                stream_id: &tag_all,
626                count: Some(200),
627                ranked: None,
628                unread_only: Some(true),
629                newer_than: None,
630                feed_ids: &feed_ids,
631            };
632
633            futures.push(self.get_articles(api, query, client));
634
635            let article_results = futures::future::try_join_all(futures).await?;
636
637            for articles in article_results {
638                result.add(articles);
639            }
640
641            Ok(SyncResult {
642                feeds: util::vec_to_option(conversion_result.feeds),
643                categories: util::vec_to_option(conversion_result.categories),
644                feed_mappings: util::vec_to_option(conversion_result.feed_mappings),
645                category_mappings: util::vec_to_option(conversion_result.category_mappings),
646                tags: util::vec_to_option(tags),
647                taggings: util::vec_to_option(result.taggings),
648                headlines: util::vec_to_option(result.headlines),
649                articles: util::vec_to_option(result.articles),
650                enclosures: util::vec_to_option(result.enclosures),
651            })
652        } else {
653            Err(FeedApiError::Login)
654        }
655    }
656
657    async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
658        if let Some(api) = &self.api {
659            self.check_and_update_token(api, client).await?;
660            let last_sync = self.portal.get_config().read().await.get_last_sync();
661
662            let tag_all = api.category_all(client).await?;
663            let tag_marked = api.tag_marked(client).await?;
664
665            let collections = api.get_collections(client);
666            let tags = api.get_tags(client);
667
668            let (collections, tags) = futures::try_join!(collections, tags)?;
669
670            let conversion_result = Feedly::convert_collection_vec(collections);
671            let feed_ids: HashSet<FeedID> = conversion_result.feeds.iter().map(|f| f.feed_id.clone()).collect();
672            let tags = Feedly::convert_tag_vec(tags);
673
674            let mut result = StreamConversionResult::new();
675
676            // get recent articles
677            let recent = self.get_articles(
678                api,
679                ArticleQuery {
680                    stream_id: &tag_all,
681                    count: Some(200),
682                    ranked: None,
683                    unread_only: None,
684                    newer_than: Some(last_sync.timestamp() as u64),
685                    feed_ids: &feed_ids,
686                },
687                client,
688            );
689
690            // get marked articles
691            let marked = self.get_articles(
692                api,
693                ArticleQuery {
694                    stream_id: &tag_marked,
695                    count: Some(50),
696                    ranked: None,
697                    unread_only: None,
698                    newer_than: None,
699                    feed_ids: &feed_ids,
700                },
701                client,
702            );
703
704            // get unread articles
705            let unread = self.get_articles(
706                api,
707                ArticleQuery {
708                    stream_id: &tag_all,
709                    count: None,
710                    ranked: None,
711                    unread_only: Some(true),
712                    newer_than: None,
713                    feed_ids: &feed_ids,
714                },
715                client,
716            );
717
718            let (recent, marked, unread) = futures::try_join!(recent, marked, unread)?;
719
720            let remote_marked_ids: HashSet<ArticleID> = marked
721                .articles
722                .iter()
723                .map(|a| &a.article_id)
724                .cloned()
725                .chain(marked.headlines.iter().map(|h| &h.article_id).cloned())
726                .collect();
727
728            let remote_unread_ids: HashSet<ArticleID> = unread
729                .articles
730                .iter()
731                .map(|a| &a.article_id)
732                .cloned()
733                .chain(unread.headlines.iter().map(|h| &h.article_id).cloned())
734                .collect();
735
736            result.add(recent);
737            result.add(marked);
738            result.add(unread);
739
740            // get local IDs
741            let local_unread_ids = self.portal.get_article_ids_unread_all()?;
742            let local_marked_ids = self.portal.get_article_ids_marked_all()?;
743
744            let local_unread_ids: HashSet<ArticleID> = local_unread_ids.into_iter().collect();
745            let local_marked_ids: HashSet<ArticleID> = local_marked_ids.into_iter().collect();
746
747            // mark remotely read article as read
748            let mut should_mark_read_headlines = local_unread_ids
749                .difference(&remote_unread_ids)
750                .map(|id| Headline {
751                    article_id: ArticleID::new(&id.to_string()),
752                    unread: Read::Read,
753                    marked: if remote_marked_ids.contains(id) {
754                        Marked::Marked
755                    } else {
756                        Marked::Unmarked
757                    },
758                })
759                .collect();
760            result.headlines.append(&mut should_mark_read_headlines);
761
762            // unmark remotly unmarked articles locally
763            let mut missing_unmarked_headlines = local_marked_ids
764                .difference(&remote_marked_ids)
765                .map(|id| Headline {
766                    article_id: ArticleID::new(&id.to_string()),
767                    marked: Marked::Unmarked,
768                    unread: if remote_unread_ids.contains(id) { Read::Unread } else { Read::Read },
769                })
770                .collect();
771            result.headlines.append(&mut missing_unmarked_headlines);
772
773            Ok(SyncResult {
774                feeds: util::vec_to_option(conversion_result.feeds),
775                categories: util::vec_to_option(conversion_result.categories),
776                feed_mappings: util::vec_to_option(conversion_result.feed_mappings),
777                category_mappings: util::vec_to_option(conversion_result.category_mappings),
778                tags: util::vec_to_option(tags),
779                taggings: util::vec_to_option(result.taggings),
780                headlines: util::vec_to_option(result.headlines),
781                articles: util::vec_to_option(result.articles),
782                enclosures: util::vec_to_option(result.enclosures),
783            })
784        } else {
785            Err(FeedApiError::Login)
786        }
787    }
788
789    async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
790        if let Some(api) = &self.api {
791            self.check_and_update_token(api, client).await?;
792
793            let collections = api.get_collections(client).await?;
794            let conversion_result = Feedly::convert_collection_vec(collections);
795
796            let feed = conversion_result.feeds.iter().find(|feed| &feed.feed_id == feed_id).cloned();
797
798            let mut feed_ids: HashSet<FeedID> = HashSet::new();
799            feed_ids.insert(feed_id.clone());
800
801            // get marked articles
802            let query = ArticleQuery {
803                stream_id: feed_id.as_str(),
804                count: Some(200),
805                ranked: None,
806                unread_only: None,
807                newer_than: None,
808                feed_ids: &feed_ids,
809            };
810            let result = self.get_articles(api, query, client).await?;
811
812            Ok(FeedUpdateResult {
813                feed,
814                taggings: util::vec_to_option(result.taggings),
815                articles: util::vec_to_option(result.articles),
816                enclosures: util::vec_to_option(result.enclosures),
817            })
818        } else {
819            Err(FeedApiError::Login)
820        }
821    }
822
823    async fn set_article_read(&self, articles: &[ArticleID], read: models::Read, client: &Client) -> FeedApiResult<()> {
824        if let Some(api) = &self.api {
825            self.check_and_update_token(api, client).await?;
826
827            let string_vec: Vec<&str> = articles.iter().map(|x| x.as_str()).collect();
828            match read {
829                models::Read::Read => api.mark_entries_read(string_vec.clone(), client).await?,
830                models::Read::Unread => api.mark_entries_unread(string_vec.clone(), client).await?,
831            };
832
833            Ok(())
834        } else {
835            Err(FeedApiError::Login)
836        }
837    }
838
839    async fn set_article_marked(&self, articles: &[ArticleID], marked: models::Marked, client: &Client) -> FeedApiResult<()> {
840        if let Some(api) = &self.api {
841            self.check_and_update_token(api, client).await?;
842
843            let string_vec: Vec<&str> = articles.iter().map(|x| x.as_str()).collect();
844            match marked {
845                models::Marked::Marked => api.mark_entries_saved(string_vec.clone(), client).await?,
846                models::Marked::Unmarked => api.mark_entries_unsaved(string_vec.clone(), client).await?,
847            };
848
849            Ok(())
850        } else {
851            Err(FeedApiError::Login)
852        }
853    }
854
855    async fn set_feed_read(&self, feeds: &[FeedID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
856        if let Some(api) = &self.api {
857            self.check_and_update_token(api, client).await?;
858
859            let string_vec: Vec<&str> = feeds.iter().map(|x| x.as_str()).collect();
860            api.mark_feeds_read(string_vec.clone(), client).await?;
861            Ok(())
862        } else {
863            Err(FeedApiError::Login)
864        }
865    }
866
867    async fn set_category_read(&self, categories: &[CategoryID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
868        if let Some(api) = &self.api {
869            self.check_and_update_token(api, client).await?;
870
871            let string_vec: Vec<&str> = categories.iter().map(|x| x.as_str()).collect();
872            api.mark_categories_read(string_vec.clone(), client).await?;
873            Ok(())
874        } else {
875            Err(FeedApiError::Login)
876        }
877    }
878
879    async fn set_tag_read(&self, tags: &[TagID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
880        if let Some(api) = &self.api {
881            self.check_and_update_token(api, client).await?;
882
883            let string_vec: Vec<&str> = tags.iter().map(|x| x.as_str()).collect();
884            api.mark_tags_read(string_vec.clone(), client).await?;
885            Ok(())
886        } else {
887            Err(FeedApiError::Login)
888        }
889    }
890
891    async fn set_all_read(&self, _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
892        if let Some(api) = &self.api {
893            self.check_and_update_token(api, client).await?;
894
895            let all = api.category_all(client).await?;
896            let vec: Vec<&str> = vec![&all];
897            api.mark_categories_read(vec.clone(), client).await?;
898            Ok(())
899        } else {
900            Err(FeedApiError::Login)
901        }
902    }
903
904    async fn add_feed(
905        &self,
906        url: &Url,
907        title: Option<String>,
908        category: Option<CategoryID>,
909        client: &Client,
910    ) -> FeedApiResult<(Feed, Option<Category>)> {
911        if let Some(api) = &self.api {
912            self.check_and_update_token(api, client).await?;
913
914            let feed_id = FeedlyApi::gernerate_feed_id(url);
915
916            let feed = SubscriptionInput {
917                id: feed_id.clone(),
918                title: title.as_ref().cloned(),
919                categories: match category {
920                    Some(category_id) => {
921                        let category = FeedlyCategory {
922                            id: category_id.to_string(),
923                            label: None,
924                            description: None,
925                        };
926                        Some(vec![category])
927                    }
928                    None => None,
929                },
930            };
931            api.add_subscription(feed.clone(), client).await?;
932
933            let feed_id = FeedID::new(&feed_id);
934            let semaphore = self.portal.get_download_semaphore();
935            let feed = feed_parser::download_and_parse_feed(url, &feed_id, title, semaphore, client).await;
936            if feed.is_err() {
937                self.remove_feed(&feed_id, client).await?;
938            }
939            if let Ok(ParsedUrl::SingleFeed(feed)) = feed {
940                return Ok((*feed, None));
941            }
942        }
943        Err(FeedApiError::Login)
944    }
945
946    async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
947        if let Some(api) = &self.api {
948            self.check_and_update_token(api, client).await?;
949
950            api.delete_subscription(id.as_str(), client).await?;
951            Ok(())
952        } else {
953            Err(FeedApiError::Login)
954        }
955    }
956
957    async fn move_feed(&self, feed_id: &FeedID, from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
958        if let Some(api) = &self.api {
959            self.check_and_update_token(api, client).await?;
960
961            let mappings = self.portal.get_feed_mappings()?;
962            // all categories for feed_id except category 'from'
963            let mut categories: Vec<FeedlyCategory> = mappings
964                .into_iter()
965                .filter(|mapping| &mapping.feed_id == feed_id && &mapping.category_id != from)
966                .map(|mapping| FeedlyCategory {
967                    id: mapping.category_id.to_string(),
968                    label: None,
969                    description: None,
970                })
971                .collect();
972
973            // add category 'to'
974            categories.push(FeedlyCategory {
975                id: to.to_string(),
976                label: None,
977                description: None,
978            });
979            let feed = SubscriptionInput {
980                id: feed_id.to_string(),
981                title: None,
982                categories: Some(categories),
983            };
984            api.add_subscription(feed.clone(), client).await?;
985            Ok(())
986        } else {
987            Err(FeedApiError::Login)
988        }
989    }
990
991    async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
992        if let Some(api) = &self.api {
993            self.check_and_update_token(api, client).await?;
994
995            let feed = SubscriptionInput {
996                id: feed_id.to_string(),
997                title: Some(new_title.to_owned()),
998                categories: None,
999            };
1000            api.add_subscription(feed.clone(), client).await?;
1001            Ok(feed_id.clone())
1002        } else {
1003            Err(FeedApiError::Login)
1004        }
1005    }
1006
1007    async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
1008        Err(FeedApiError::Unsupported)
1009    }
1010
1011    async fn add_category<'a>(&self, title: &str, parent: Option<&'a CategoryID>, client: &Client) -> FeedApiResult<CategoryID> {
1012        if let Some(api) = &self.api {
1013            self.check_and_update_token(api, client).await?;
1014
1015            if parent.is_some() {
1016                return Err(FeedApiError::Unsupported);
1017            }
1018
1019            // only generate id
1020            // useing id as if it would exist will create category
1021            let category_id = api.generate_category_id(title, client).await?;
1022            Ok(CategoryID::new(&category_id))
1023        } else {
1024            Err(FeedApiError::Login)
1025        }
1026    }
1027
1028    async fn remove_category(&self, id: &CategoryID, remove_children: bool, client: &Client) -> FeedApiResult<()> {
1029        if let Some(api) = &self.api {
1030            self.check_and_update_token(api, client).await?;
1031
1032            if remove_children {
1033                let mappings = self.portal.get_feed_mappings()?;
1034
1035                let updated_subscriptions = mappings
1036                    .iter()
1037                    .filter(|m| &m.category_id == id)
1038                    .map(|m| SubscriptionInput {
1039                        id: m.feed_id.to_string(),
1040                        title: None,
1041                        categories: {
1042                            let categories = mappings
1043                                .iter()
1044                                .filter(|m2| m2.feed_id == m.feed_id && &m2.category_id != id)
1045                                .map(|m3| FeedlyCategory {
1046                                    id: m3.category_id.to_string(),
1047                                    label: None,
1048                                    description: None,
1049                                })
1050                                .collect::<Vec<FeedlyCategory>>();
1051                            Some(categories)
1052                        },
1053                    })
1054                    .collect::<Vec<SubscriptionInput>>();
1055
1056                api.update_subscriptions(updated_subscriptions.clone(), client).await?;
1057            }
1058
1059            api.delete_category(id.as_str(), client).await?;
1060            Ok(())
1061        } else {
1062            Err(FeedApiError::Login)
1063        }
1064    }
1065
1066    async fn rename_category(&self, id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
1067        if let Some(api) = &self.api {
1068            self.check_and_update_token(api, client).await?;
1069
1070            let new_id = api.generate_category_id(new_title, client).await?;
1071            api.update_category(id.as_str(), new_title, client).await?;
1072            Ok(CategoryID::new(&new_id))
1073        } else {
1074            Err(FeedApiError::Login)
1075        }
1076    }
1077
1078    async fn move_category(&self, _id: &CategoryID, _parent: &CategoryID, _client: &Client) -> FeedApiResult<()> {
1079        Err(FeedApiError::Unsupported)
1080    }
1081
1082    async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
1083        if let Some(api) = &self.api {
1084            self.check_and_update_token(api, client).await?;
1085            api.import_opml(opml, client).await?;
1086            Ok(())
1087        } else {
1088            Err(FeedApiError::Login)
1089        }
1090    }
1091
1092    async fn add_tag(&self, title: &str, client: &Client) -> FeedApiResult<TagID> {
1093        if let Some(api) = &self.api {
1094            self.check_and_update_token(api, client).await?;
1095            let id = api.generate_tag_id(title, client).await?;
1096            Ok(TagID::new(&id))
1097        } else {
1098            Err(FeedApiError::Login)
1099        }
1100    }
1101
1102    async fn remove_tag(&self, id: &TagID, client: &Client) -> FeedApiResult<()> {
1103        if let Some(api) = &self.api {
1104            self.check_and_update_token(api, client).await?;
1105            api.delete_tags(vec![id.as_str()], client).await?;
1106            Ok(())
1107        } else {
1108            Err(FeedApiError::Login)
1109        }
1110    }
1111
1112    async fn rename_tag(&self, id: &TagID, new_title: &str, client: &Client) -> FeedApiResult<TagID> {
1113        if let Some(api) = &self.api {
1114            self.check_and_update_token(api, client).await?;
1115
1116            let new_id = api.generate_tag_id(new_title, client).await?;
1117            api.update_tag(id.as_str(), new_title, client).await?;
1118            Ok(TagID::new(&new_id))
1119        } else {
1120            Err(FeedApiError::Login)
1121        }
1122    }
1123
1124    async fn tag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
1125        if let Some(api) = &self.api {
1126            self.check_and_update_token(api, client).await?;
1127            api.tag_entry(article_id.as_str(), vec![tag_id.as_str()], client).await?;
1128            Ok(())
1129        } else {
1130            Err(FeedApiError::Login)
1131        }
1132    }
1133
1134    async fn untag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
1135        if let Some(api) = &self.api {
1136            self.check_and_update_token(api, client).await?;
1137
1138            api.untag_entries(vec![article_id.as_str()], vec![tag_id.as_str()], client).await?;
1139            Ok(())
1140        } else {
1141            Err(FeedApiError::Login)
1142        }
1143    }
1144
1145    async fn get_favicon(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
1146        Err(FeedApiError::Unsupported)
1147    }
1148}