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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}