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 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 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 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 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 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, })));
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 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}