1pub mod config;
2pub mod metadata;
3
4use std::collections::HashSet;
5
6use self::config::AccountConfig;
7use self::metadata::CommafeedMetadata;
8use crate::error::FeedApiError;
9use crate::feed_api::{FeedApi, FeedApiResult, FeedHeaderMap, Portal};
10use crate::models::{
11 ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, Direction, Enclosure, FatArticle, FavIcon, Feed, FeedConversionResult, FeedID,
12 FeedMapping, FeedUpdateResult, Headline, LoginData, Marked, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, Read, StreamConversionResult,
13 SyncResult, Tag, TagID, Tagging, Url,
14};
15use crate::util;
16use async_trait::async_trait;
17use chrono::{DateTime, Utc};
18use commafeed_api::{ApiError as CommafeedError, Category as CommafeedCategory, CommafeedApi, Entries, MarkRequest, StarRequest, TagRequest};
19use feed_rs::parser;
20use reqwest::Client;
21use reqwest::header::{HeaderMap, HeaderValue};
22
23impl From<CommafeedError> for FeedApiError {
24 fn from(error: CommafeedError) -> FeedApiError {
25 match error {
26 CommafeedError::Url(e) => FeedApiError::Url(e),
27 CommafeedError::Json { source, json } => FeedApiError::Json { source, json },
28 CommafeedError::Http(e) => FeedApiError::Network(e),
29 CommafeedError::Parse => FeedApiError::Api {
30 message: CommafeedError::Parse.to_string(),
31 },
32 }
33 }
34}
35
36pub struct Commafeed {
37 api: Option<CommafeedApi>,
38 portal: Box<dyn Portal>,
39 config: AccountConfig,
40}
41
42impl Commafeed {
43 fn parse_tree(toplevel: CommafeedCategory, base_url: &Url) -> FeedConversionResult {
44 let mut feeds = Vec::<Feed>::new();
45 let mut feed_mappings = Vec::<FeedMapping>::new();
46 let mut categories = Vec::<Category>::new();
47 let mut category_mappings = Vec::<CategoryMapping>::new();
48
49 Self::parse_category(
50 toplevel,
51 base_url,
52 &mut feeds,
53 &mut feed_mappings,
54 &mut categories,
55 &mut category_mappings,
56 );
57
58 FeedConversionResult {
59 feeds,
60 categories,
61 feed_mappings,
62 category_mappings,
63 }
64 }
65
66 fn parse_category(
67 comma_category: CommafeedCategory,
68 base_url: &Url,
69 feeds: &mut Vec<Feed>,
70 feed_mappings: &mut Vec<FeedMapping>,
71 categories: &mut Vec<Category>,
72 category_mappings: &mut Vec<CategoryMapping>,
73 ) {
74 let category_id = if comma_category.id.as_str() == "all" {
75 NEWSFLASH_TOPLEVEL.clone()
76 } else {
77 CategoryID::new(comma_category.id.as_str())
78 };
79
80 for (index, feed) in comma_category.feeds.into_iter().enumerate() {
81 let feed_id = FeedID::new(&feed.subscription_id.to_string());
82
83 feeds.push(Feed {
84 feed_id: feed_id.clone(),
85 label: feed.name,
86 website: Url::parse(&feed.feed_link).ok(),
87 feed_url: Url::parse(&feed.feed_url).ok(),
88 icon_url: base_url.join(&feed.icon_url).map(Url::new).ok(),
89 error_count: feed.error_count,
90 error_message: feed.message,
91 });
92
93 feed_mappings.push(FeedMapping {
94 feed_id,
95 category_id: category_id.clone(),
96 sort_index: Some(index as i32),
97 });
98 }
99
100 for (index, category) in comma_category.children.into_iter().enumerate() {
101 let iter_category_id = CategoryID::new(&category.id);
102
103 categories.push(Category {
104 category_id: iter_category_id.clone(),
105 label: category.name.clone(),
106 });
107
108 category_mappings.push(CategoryMapping {
109 parent_id: category_id.clone(),
110 category_id: iter_category_id,
111 sort_index: Some(index as i32),
112 });
113
114 Self::parse_category(category, base_url, feeds, feed_mappings, categories, category_mappings);
115 }
116 }
117
118 fn convert_entries(entries: Entries, portal: &dyn Portal) -> StreamConversionResult {
119 let mut articles = Vec::new();
120 let mut taggings = Vec::new();
121 let mut enclosures = Vec::new();
122
123 for entry in entries.entries {
124 let article_id = ArticleID::new(&entry.id);
125 let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
126
127 let plain_text = if article_exists_locally {
128 None
129 } else {
130 entry.content.as_deref().map(util::html2text::html2text)
131 };
132 let summary = plain_text.as_deref().map(util::html2text::text2summary);
133 let thumbnail_url = if entry.media_thumbnail_url.is_some() {
134 entry.media_thumbnail_url.clone()
135 } else {
136 entry.content.as_deref().and_then(crate::util::thumbnail::extract_thumbnail)
137 };
138
139 if let Some(url) = entry.enclosure_url.and_then(|url| Url::parse(&url).ok()) {
140 enclosures.push(Enclosure {
141 article_id: article_id.clone(),
142 url,
143 mime_type: entry.enclosure_type,
144 title: entry.media_description,
145 position: None,
146 summary: None,
147 thumbnail_url: entry.media_thumbnail_url,
148 filesize: None,
149 width: None,
150 height: None,
151 duration: None,
152 framerate: None,
153 alternative: None,
154 is_default: false,
155 });
156 }
157
158 for tag in entry.tags {
159 taggings.push(Tagging {
160 article_id: article_id.clone(),
161 tag_id: TagID::new(&tag),
162 });
163 }
164
165 articles.push(FatArticle {
166 article_id,
167 title: Some(entry.title),
168 author: entry.author,
169 feed_id: FeedID::new(&entry.feed_id),
170 url: Url::parse(&entry.url).ok(),
171 date: DateTime::from_timestamp_millis(entry.date).unwrap_or(Utc::now()),
172 synced: Utc::now(),
173 updated: None,
174 html: entry.content,
175 summary,
176 direction: Some(if entry.rtl { Direction::RightToLeft } else { Direction::LeftToRight }),
177 unread: if entry.read { Read::Read } else { Read::Unread },
178 marked: if entry.starred { Marked::Marked } else { Marked::Unmarked },
179 scraped_content: None,
180 plain_text,
181 thumbnail_url,
182 });
183 }
184
185 StreamConversionResult {
186 articles,
187 headlines: Vec::new(),
188 taggings,
189 enclosures,
190 }
191 }
192
193 fn convert_tags(tags: Vec<String>) -> Vec<Tag> {
194 tags.into_iter()
195 .enumerate()
196 .map(|(i, t)| Tag {
197 tag_id: TagID::new(&t),
198 label: t,
199 color: None,
200 sort_index: Some(i as i32),
201 })
202 .collect::<Vec<_>>()
203 }
204
205 async fn fetch_articles(&self, category: &str, limit: u32, read: bool, client: &Client) -> FeedApiResult<StreamConversionResult> {
206 if let Some(api) = self.api.as_ref() {
207 let mut offset = 0;
208 let mut result = StreamConversionResult::new();
209
210 loop {
211 let entries = api
212 .get_category_entries(
213 category,
214 read,
215 None,
216 Some(offset),
217 Some(limit as i32),
218 None,
219 None,
220 None,
221 None,
222 None,
223 client,
224 )
225 .await?;
226 let done = !entries.has_more;
227
228 let converted_entries = Self::convert_entries(entries, self.portal.as_ref());
229 result.add(converted_entries);
230
231 if done {
232 break;
233 } else {
234 offset += limit as i32;
235 }
236 }
237
238 Ok(result)
239 } else {
240 Err(FeedApiError::Login)
241 }
242 }
243}
244
245#[async_trait]
246impl FeedApi for Commafeed {
247 fn features(&self) -> FeedApiResult<PluginCapabilities> {
248 Ok(PluginCapabilities::ADD_REMOVE_FEEDS
249 | PluginCapabilities::SUPPORT_CATEGORIES
250 | PluginCapabilities::MODIFY_CATEGORIES
251 | PluginCapabilities::SUPPORT_SUBCATEGORIES
252 | PluginCapabilities::SUPPORT_TAGS)
253 }
254
255 fn has_user_configured(&self) -> FeedApiResult<bool> {
256 Ok(self.api.is_some())
257 }
258
259 async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
260 if let Some(url) = self.config.get_url() {
261 let url = url.trim_end_matches("rest/");
262 let res = client.head(url).send().await?;
263 Ok(res.status().is_success())
264 } else {
265 Err(FeedApiError::Login)
266 }
267 }
268
269 async fn is_logged_in(&self, client: &Client) -> FeedApiResult<bool> {
270 match &self.api {
271 None => Ok(false),
272 Some(api) => {
273 _ = api.get_profile(client).await?;
274 Ok(true)
275 }
276 }
277 }
278
279 async fn user_name(&self) -> Option<String> {
280 self.config.get_user_name()
281 }
282
283 async fn get_login_data(&self) -> Option<LoginData> {
284 if self.has_user_configured().unwrap_or(false) {
285 let username = self.config.get_user_name();
286 let password = self.config.get_password();
287
288 if let (Some(username), Some(password)) = (username, password) {
289 return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
290 id: CommafeedMetadata::get_id(),
291 url: self.config.get_url(),
292 user: username,
293 password,
294 basic_auth: None,
295 })));
296 }
297 }
298
299 None
300 }
301
302 async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
303 if let LoginData::Direct(DirectLogin::Password(password_data)) = data {
304 let api = if let Some(mut url_string) = password_data.url.clone() {
305 if !url_string.ends_with('/') {
306 url_string.push('/');
307 }
308 if !url_string.ends_with("rest/") {
309 url_string.push_str("rest/");
310 }
311
312 self.config.set_url(&url_string);
313 self.config.set_password(&password_data.password);
314 self.config.set_user_name(&password_data.user);
315
316 let url = Url::parse(&url_string)?;
317 let api = CommafeedApi::new(&url, &password_data.user, &password_data.password);
318 let profile = api.get_profile(client).await?;
319
320 tracing::debug!(%profile.name, "logged in");
321
322 api
323 } else {
324 tracing::error!("No URL set");
325 return Err(FeedApiError::Login);
326 };
327
328 if self.config.get_user_name().is_none() {
329 let profile = api.get_profile(client).await?;
330 self.config.set_user_name(&profile.name);
331 }
332
333 self.config.save()?;
334 self.api = Some(api);
335 return Ok(());
336 }
337
338 self.api = None;
339 Err(FeedApiError::Login)
340 }
341
342 async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
343 self.config.delete()?;
344 Ok(())
345 }
346
347 async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
348 if let Some(api) = self.api.as_ref() {
349 let base_url = self.config.get_icon_base_url()?;
350
351 let tree = api.get_category_tree(client);
352 let tags = api.get_tags(client);
353
354 let unread_result = self.fetch_articles("all", 999, false, client);
355 let starred_result = self.fetch_articles("starred", 999, true, client);
356
357 let (tree, tags, unread_result, starred_result) = futures::join!(tree, tags, unread_result, starred_result);
358
359 let mut unread_result = unread_result?;
360 let starred_result = starred_result?;
361
362 let converted_tree = Self::parse_tree(tree?, &base_url);
363 let tags = Self::convert_tags(tags?);
364
365 unread_result.add(starred_result);
366
367 Ok(SyncResult {
368 feeds: util::vec_to_option(converted_tree.feeds),
369 categories: util::vec_to_option(converted_tree.categories),
370 feed_mappings: util::vec_to_option(converted_tree.feed_mappings),
371 category_mappings: util::vec_to_option(converted_tree.category_mappings),
372 tags: util::vec_to_option(tags),
373 taggings: util::vec_to_option(unread_result.taggings),
374 headlines: None,
375 articles: util::vec_to_option(unread_result.articles),
376 enclosures: util::vec_to_option(unread_result.enclosures),
377 })
378 } else {
379 Err(FeedApiError::Login)
380 }
381 }
382
383 async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
384 if let Some(api) = self.api.as_ref() {
385 let base_url = self.config.get_icon_base_url()?;
386 let max_count = self.portal.get_config().read().await.get_sync_amount();
387
388 let mut result = StreamConversionResult::new();
389
390 let tree = api.get_category_tree(client);
391 let tags = api.get_tags(client);
392
393 let unread_result = self.fetch_articles("all", max_count, false, client);
394 let starred_result = self.fetch_articles("starred", max_count, true, client);
395 let recent_entries = api.get_category_entries("all", true, None, None, Some(max_count as i32), None, None, None, None, None, client);
396
397 let (tree, tags, unread_result, starred_result, recent_entries) =
398 futures::join!(tree, tags, unread_result, starred_result, recent_entries);
399
400 let unread_result = unread_result?;
401 let starred_result = starred_result?;
402 let recent_entries = recent_entries?;
403
404 let converted_tree = Self::parse_tree(tree?, &base_url);
405 let tags = Self::convert_tags(tags?);
406
407 let converted_recent_entries = Self::convert_entries(recent_entries, self.portal.as_ref());
408 result.add(converted_recent_entries);
409
410 let local_unread_ids = self.portal.get_article_ids_unread_all()?;
412 let local_marked_ids = self.portal.get_article_ids_marked_all()?;
413
414 let local_unread_ids: HashSet<ArticleID> = local_unread_ids.into_iter().collect();
415 let local_marked_ids: HashSet<ArticleID> = local_marked_ids.into_iter().collect();
416
417 let remote_unread_ids: HashSet<ArticleID> = unread_result.articles.iter().map(|a| &a.article_id).cloned().collect();
418 let remote_starred_ids: HashSet<ArticleID> = starred_result.articles.iter().map(|a| &a.article_id).cloned().collect();
419
420 let mut should_mark_read_headlines = local_unread_ids
422 .difference(&remote_unread_ids)
423 .map(|id| Headline {
424 article_id: ArticleID::new(&id.to_string()),
425 unread: Read::Read,
426 marked: if remote_starred_ids.contains(id) {
427 Marked::Marked
428 } else {
429 Marked::Unmarked
430 },
431 })
432 .collect();
433 result.headlines.append(&mut should_mark_read_headlines);
434
435 let mut missing_unmarked_headlines = local_marked_ids
437 .difference(&remote_starred_ids)
438 .map(|id| Headline {
439 article_id: ArticleID::new(&id.to_string()),
440 marked: Marked::Unmarked,
441 unread: if remote_unread_ids.contains(id) { Read::Unread } else { Read::Read },
442 })
443 .collect();
444 result.headlines.append(&mut missing_unmarked_headlines);
445
446 Ok(SyncResult {
447 feeds: util::vec_to_option(converted_tree.feeds),
448 categories: util::vec_to_option(converted_tree.categories),
449 feed_mappings: util::vec_to_option(converted_tree.feed_mappings),
450 category_mappings: util::vec_to_option(converted_tree.category_mappings),
451 tags: util::vec_to_option(tags),
452 taggings: util::vec_to_option(result.taggings),
453 headlines: util::vec_to_option(result.headlines),
454 articles: util::vec_to_option(result.articles),
455 enclosures: util::vec_to_option(result.enclosures),
456 })
457 } else {
458 Err(FeedApiError::Login)
459 }
460 }
461
462 async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
463 if let Some(api) = self.api.as_ref() {
464 let comma_feed_id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
465 message: format!("Failed to parse id {feed_id}"),
466 })?;
467
468 let base_url = self.config.get_icon_base_url()?;
469 let feed = api.fetch_feed(comma_feed_id, client).await?;
470 let feed = Feed {
471 feed_id: feed_id.clone(),
472 label: feed.name,
473 website: Url::parse(&feed.feed_link).ok(),
474 feed_url: Url::parse(&feed.feed_url).ok(),
475 icon_url: base_url.join(&feed.icon_url).map(Url::new).ok(),
476 error_count: feed.error_count,
477 error_message: feed.message,
478 };
479
480 let entries = api
481 .get_feed_entries(feed_id.as_str(), true, None, None, None, None, None, None, client)
482 .await?;
483 let converted_entries = Self::convert_entries(entries, self.portal.as_ref());
484
485 Ok(FeedUpdateResult {
486 feed: Some(feed),
487 taggings: util::vec_to_option(converted_entries.taggings),
488 articles: util::vec_to_option(converted_entries.articles),
489 enclosures: util::vec_to_option(converted_entries.enclosures),
490 })
491 } else {
492 Err(FeedApiError::Login)
493 }
494 }
495
496 async fn set_article_read(&self, articles: &[ArticleID], read: Read, client: &Client) -> FeedApiResult<()> {
497 if let Some(api) = self.api.as_ref() {
498 let requests = articles
499 .iter()
500 .map(|id| MarkRequest {
501 id: id.as_str().into(),
502 read: read == Read::Read,
503 older_than: None,
504 keywords: None,
505 excluded_subscriptions: None,
506 })
507 .collect();
508 api.mark_multiple_entries_read(requests, client).await?;
509 Ok(())
510 } else {
511 Err(FeedApiError::Login)
512 }
513 }
514
515 async fn set_article_marked(&self, articles: &[ArticleID], marked: Marked, client: &Client) -> FeedApiResult<()> {
516 if let Some(api) = self.api.as_ref() {
517 let requests = articles
518 .iter()
519 .map(|id| StarRequest {
520 id: id.as_str().into(),
521 feed_id: 0,
522 starred: marked == Marked::Marked,
523 })
524 .collect::<Vec<_>>();
525
526 for request in requests {
527 api.mark_entry_starred(request, client).await?;
528 }
529 Ok(())
530 } else {
531 Err(FeedApiError::Login)
532 }
533 }
534
535 async fn set_feed_read(&self, feeds: &[FeedID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
536 if let Some(api) = self.api.as_ref() {
537 for feed in feeds {
538 api.mark_feed_read(feed.as_str(), true, None, None, None, client).await?;
539 }
540 Ok(())
541 } else {
542 Err(FeedApiError::Login)
543 }
544 }
545
546 async fn set_category_read(&self, categories: &[CategoryID], _articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
547 if let Some(api) = self.api.as_ref() {
548 for category in categories {
549 api.mark_category_read(category.as_str(), true, None, None, None, client).await?;
550 }
551 Ok(())
552 } else {
553 Err(FeedApiError::Login)
554 }
555 }
556
557 async fn set_tag_read(&self, _tags: &[TagID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
558 if let Some(api) = self.api.as_ref() {
559 let requests = articles
560 .iter()
561 .map(|id| MarkRequest {
562 id: id.as_str().into(),
563 read: true,
564 older_than: None,
565 keywords: None,
566 excluded_subscriptions: None,
567 })
568 .collect();
569 api.mark_multiple_entries_read(requests, client).await?;
570 Ok(())
571 } else {
572 Err(FeedApiError::Login)
573 }
574 }
575
576 async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
577 if let Some(api) = self.api.as_ref() {
578 let requests = articles
579 .iter()
580 .map(|id| MarkRequest {
581 id: id.as_str().into(),
582 read: true,
583 older_than: None,
584 keywords: None,
585 excluded_subscriptions: None,
586 })
587 .collect();
588 api.mark_multiple_entries_read(requests, client).await?;
589 Ok(())
590 } else {
591 Err(FeedApiError::Login)
592 }
593 }
594
595 async fn add_feed(
596 &self,
597 url: &Url,
598 title: Option<String>,
599 category_id: Option<CategoryID>,
600 client: &Client,
601 ) -> FeedApiResult<(Feed, Option<Category>)> {
602 if let Some(api) = self.api.as_ref() {
603 let feed = if let Some(title) = title.as_deref() {
604 let feed_id = api
605 .subscribe_to_feed(url.as_str(), title, category_id.as_ref().map(|id| id.to_string()).as_deref(), client)
606 .await?;
607 Feed {
608 feed_id: FeedID::new(&feed_id.to_string()),
609 label: title.to_owned(),
610 website: None,
611 feed_url: Some(url.clone()),
612 icon_url: None,
613 error_count: 0,
614 error_message: None,
615 }
616 } else {
617 let feed_id = api.subscribe_to_feed_simple(url.as_str(), client).await?;
618 let feed_response = client.get(url.as_str()).send().await?.error_for_status()?;
619 let result_bytes = feed_response
620 .bytes()
621 .await
622 .inspect_err(|error| tracing::error!(%url, %error, "Reading response as bytes failed"))?;
623
624 let parser = parser::Builder::new().base_uri(Some(url)).build();
625 let feed = parser.parse(result_bytes.as_ref())?;
626 let mut feed = Feed::from_feed_rs(&feed, title, url);
627 feed.feed_id = FeedID::new(&feed_id.to_string());
628 feed
629 };
630
631 let categories = self.portal.get_categories()?;
632 let category = categories.iter().find(|c| Some(&c.category_id) == category_id.as_ref()).cloned();
633
634 Ok((feed, category))
635 } else {
636 Err(FeedApiError::Login)
637 }
638 }
639
640 async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
641 if let Some(api) = self.api.as_ref() {
642 let id = id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
643 message: format!("Failed to parse id {id}"),
644 })?;
645 api.unsubscribe_from_feed(id, client).await?;
646 Ok(())
647 } else {
648 Err(FeedApiError::Login)
649 }
650 }
651
652 async fn move_feed(&self, feed_id: &FeedID, _from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
653 if let Some(api) = self.api.as_ref() {
654 let id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
655 message: format!("Failed to parse id {feed_id}"),
656 })?;
657 api.modify_feed(id, None, Some(to.as_str()), None, client).await?;
658 Ok(())
659 } else {
660 Err(FeedApiError::Login)
661 }
662 }
663
664 async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
665 if let Some(api) = self.api.as_ref() {
666 let id = feed_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
667 message: format!("Failed to parse id {feed_id}"),
668 })?;
669 api.modify_feed(id, Some(new_title), None, None, client).await?;
670 Ok(feed_id.clone())
671 } else {
672 Err(FeedApiError::Login)
673 }
674 }
675
676 async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
677 Err(FeedApiError::Unsupported)
678 }
679
680 async fn add_category<'a>(&self, title: &str, parent: Option<&'a CategoryID>, client: &Client) -> FeedApiResult<CategoryID> {
681 if let Some(api) = self.api.as_ref() {
682 let id = api.create_category(title, parent.map(|id| id.to_string()).as_deref(), client).await?;
683 Ok(CategoryID::new(&id.to_string()))
684 } else {
685 Err(FeedApiError::Login)
686 }
687 }
688
689 async fn remove_category(&self, id: &CategoryID, _remove_children: bool, client: &Client) -> FeedApiResult<()> {
690 if let Some(api) = self.api.as_ref() {
691 let id = id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
692 message: format!("Failed to parse id {id}"),
693 })?;
694 api.delete_category(id, client).await?;
695 Ok(())
696 } else {
697 Err(FeedApiError::Login)
698 }
699 }
700
701 async fn rename_category(&self, category_id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
702 if let Some(api) = self.api.as_ref() {
703 let id = category_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
704 message: format!("Failed to parse id {category_id}"),
705 })?;
706 api.modify_category(id, Some(new_title), None, None, client).await?;
707 Ok(category_id.clone())
708 } else {
709 Err(FeedApiError::Login)
710 }
711 }
712
713 async fn move_category(&self, category_id: &CategoryID, parent: &CategoryID, client: &Client) -> FeedApiResult<()> {
714 if let Some(api) = self.api.as_ref() {
715 let id = category_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
716 message: format!("Failed to parse id {category_id}"),
717 })?;
718 api.modify_category(id, None, Some(parent.as_str()), None, client).await?;
719 Ok(())
720 } else {
721 Err(FeedApiError::Login)
722 }
723 }
724
725 async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
726 if let Some(api) = self.api.as_ref() {
727 api.import_opml(opml, client).await?;
728 Ok(())
729 } else {
730 Err(FeedApiError::Login)
731 }
732 }
733
734 async fn add_tag(&self, title: &str, _client: &Client) -> FeedApiResult<TagID> {
735 Ok(TagID::new(title))
736 }
737
738 async fn remove_tag(&self, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
739 let taggings = self.portal.get_taggings(None, Some(tag_id))?;
740 for tagging in taggings {
741 self.untag_article(&tagging.article_id, tag_id, client).await?;
742 }
743
744 Ok(())
745 }
746
747 async fn rename_tag(&self, tag_id: &TagID, new_title: &str, client: &Client) -> FeedApiResult<TagID> {
748 if let Some(api) = self.api.as_ref() {
749 let taggings = self.portal.get_taggings(None, Some(tag_id))?;
750
751 for tagging in taggings {
752 let entry_id = tagging.article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
753 message: format!("Failed to parse id {}", tagging.article_id),
754 })?;
755
756 let article_taggings = self.portal.get_taggings(Some(&tagging.article_id), None)?;
757 let mut tags = article_taggings
758 .into_iter()
759 .filter_map(|tagging| {
760 if &tagging.tag_id != tag_id {
761 Some(tagging.tag_id.to_string())
762 } else {
763 None
764 }
765 })
766 .collect::<Vec<_>>();
767 tags.push(new_title.into());
768
769 let request = TagRequest { entry_id, tags };
770 api.set_tags(request, client).await?;
771 }
772
773 Ok(TagID::new(new_title))
774 } else {
775 Err(FeedApiError::Login)
776 }
777 }
778
779 async fn tag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
780 if let Some(api) = self.api.as_ref() {
781 let entry_id = article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
782 message: format!("Failed to parse id {article_id}"),
783 })?;
784
785 let taggings = self.portal.get_taggings(Some(article_id), None)?;
786 let mut tags = taggings.into_iter().map(|tagging| tagging.tag_id.to_string()).collect::<Vec<_>>();
787 tags.push(tag_id.to_string());
788
789 let request = TagRequest { entry_id, tags };
790 api.set_tags(request, client).await?;
791 Ok(())
792 } else {
793 Err(FeedApiError::Login)
794 }
795 }
796
797 async fn untag_article(&self, article_id: &ArticleID, tag_id: &TagID, client: &Client) -> FeedApiResult<()> {
798 if let Some(api) = self.api.as_ref() {
799 let entry_id = article_id.as_str().parse::<i64>().map_err(|_| FeedApiError::Api {
800 message: format!("Failed to parse id {article_id}"),
801 })?;
802
803 let taggings = self.portal.get_taggings(Some(article_id), None)?;
804 let tags = taggings
805 .into_iter()
806 .filter_map(|tagging| {
807 if &tagging.tag_id != tag_id {
808 Some(tagging.tag_id.to_string())
809 } else {
810 None
811 }
812 })
813 .collect::<Vec<_>>();
814
815 let request = TagRequest { entry_id, tags };
816 api.set_tags(request, client).await?;
817 Ok(())
818 } else {
819 Err(FeedApiError::Login)
820 }
821 }
822
823 async fn get_favicon(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
824 Err(FeedApiError::Unsupported)
825 }
826}