1pub mod config;
2pub mod metadata;
3
4use self::config::AccountConfig;
5use self::metadata::MinifluxMetadata;
6use crate::feed_api::FeedHeaderMap;
7use crate::models::{
8 self, ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, Enclosure, FatArticle, FavIcon, Feed, FeedID, FeedMapping, FeedUpdateResult,
9 Headline, LoginData, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, StreamConversionResult, SyncResult, TagID, TokenLogin, Url,
10};
11use crate::util::favicons::EXPIRES_AFTER_DAYS;
12use crate::util::{self, html2text};
13use crate::{
14 feed_api::{FeedApi, FeedApiError, FeedApiResult, Portal},
15 models::{Marked, Read},
16};
17use async_trait::async_trait;
18use base64::Engine;
19use base64::engine::general_purpose::STANDARD as base64_std;
20use chrono::{DateTime, Duration, Utc};
21use futures::future;
22use miniflux_api::models::{Category as MinifluxCategory, Entry as MinifluxArticle, EntryStatus, Feed as MinifluxFeed, OrderBy, OrderDirection};
23use miniflux_api::{ApiError as MinifluxApiError, MinifluxApi};
24use reqwest::Client;
25use reqwest::header::{HeaderMap, HeaderValue};
26use std::collections::HashSet;
27use std::convert::{From, TryInto};
28use std::sync::Arc;
29use tokio::sync::RwLock;
30
31const DEFAULT_CATEGORY: &str = "New Category";
32
33impl From<MinifluxApiError> for FeedApiError {
34 fn from(error: MinifluxApiError) -> FeedApiError {
35 match error {
36 MinifluxApiError::Url(e) => FeedApiError::Url(e),
37 MinifluxApiError::Json { source, json } => FeedApiError::Json { source, json },
38 MinifluxApiError::Http(e) => FeedApiError::Network(e),
39 MinifluxApiError::Miniflux(e) => FeedApiError::Api {
40 message: format!("Miniflux Error: {}", e.error_message),
41 },
42 MinifluxApiError::Parse => FeedApiError::Api {
43 message: MinifluxApiError::Parse.to_string(),
44 },
45 }
46 }
47}
48
49pub struct ArticleQuery {
50 pub status: Option<EntryStatus>,
51 pub before: Option<i64>,
52 pub after: Option<i64>,
53 pub before_entry_id: Option<i64>,
54 pub after_entry_id: Option<i64>,
55 pub starred: Option<bool>,
56}
57
58const UNREAD_QUERY: ArticleQuery = ArticleQuery {
59 status: Some(EntryStatus::Unread),
60 before: None,
61 after: None,
62 before_entry_id: None,
63 after_entry_id: None,
64 starred: None,
65};
66
67const STARRED_QUERY: ArticleQuery = ArticleQuery {
68 status: None,
69 before: None,
70 after: None,
71 before_entry_id: None,
72 after_entry_id: None,
73 starred: Some(true),
74};
75
76pub struct Miniflux {
77 api: Option<MinifluxApi>,
78 portal: Arc<Box<dyn Portal>>,
79 logged_in: bool,
80 config: AccountConfig,
81}
82
83impl Miniflux {
84 fn convert_category_vec(mut categories: Vec<MinifluxCategory>) -> (Vec<Category>, Vec<CategoryMapping>) {
85 categories
86 .drain(..)
87 .enumerate()
88 .map(|(i, c)| Miniflux::convert_category(c, Some(i as i32)))
89 .unzip()
90 }
91
92 fn convert_category(category: MinifluxCategory, sort_index: Option<i32>) -> (Category, CategoryMapping) {
93 let MinifluxCategory { id, user_id: _, title } = category;
94 let category_id = CategoryID::new(&id.to_string());
95
96 let category = Category {
97 category_id: category_id.clone(),
98 label: title,
99 };
100 let category_mapping = CategoryMapping {
101 parent_id: NEWSFLASH_TOPLEVEL.clone(),
102 category_id,
103 sort_index,
104 };
105
106 (category, category_mapping)
107 }
108
109 fn convert_feed(feed: MinifluxFeed) -> Feed {
110 let MinifluxFeed {
111 id,
112 user_id: _,
113 title,
114 site_url,
115 feed_url,
116 rewrite_rules: _,
117 scraper_rules: _,
118 crawler: _,
119 checked_at: _,
120 etag_header: _,
121 last_modified_header: _,
122 parsing_error_count,
123 parsing_error_message,
124 category: _,
125 icon: _,
126 } = feed;
127
128 Feed {
129 feed_id: FeedID::new(&id.to_string()),
130 label: title,
131 website: Url::parse(&site_url).ok(),
132 feed_url: Url::parse(&feed_url).ok(),
133 icon_url: None,
134 error_count: parsing_error_count as i32,
135 error_message: if !parsing_error_message.is_empty() {
136 Some(parsing_error_message)
137 } else {
138 None
139 },
140 }
141 }
142
143 fn convert_feed_vec(mut feeds: Vec<MinifluxFeed>) -> (Vec<Feed>, Vec<FeedMapping>) {
144 let mut mappings: Vec<FeedMapping> = Vec::new();
145 let feeds = feeds
146 .drain(..)
147 .enumerate()
148 .map(|(i, f)| {
149 mappings.push(FeedMapping {
150 feed_id: FeedID::new(&f.id.to_string()),
151 category_id: CategoryID::new(&f.category.id.to_string()),
152 sort_index: Some(i as i32),
153 });
154
155 Miniflux::convert_feed(f)
156 })
157 .collect();
158
159 (feeds, mappings)
160 }
161
162 fn convert_entry(entry: MinifluxArticle, portal: Arc<Box<dyn Portal>>) -> (FatArticle, Vec<Enclosure>) {
163 let MinifluxArticle {
164 id,
165 user_id: _,
166 feed_id,
167 title,
168 url,
169 comments_url: _,
170 author,
171 content,
172 hash: _,
173 published_at,
174 created_at: _,
175 changed_at: _,
176 status,
177 starred,
178 feed: _,
179 reading_time: _,
180 enclosures,
181 } = entry;
182
183 let article_id = ArticleID::new(&id.to_string());
184
185 let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
186
187 let plain_text = if article_exists_locally {
188 None
189 } else {
190 Some(html2text::html2text(&content))
191 };
192
193 let summary = plain_text.as_deref().map(util::html2text::text2summary);
194
195 let mut thumbnail_url = enclosures.iter().find_map(|e| {
196 let is_image_type = e.mime_type.starts_with("image/");
197 let is_image_href = e.url.ends_with(".jpeg") || e.url.ends_with(".jpg") || e.url.ends_with(".png");
198
199 if is_image_type || is_image_href { Some(e.url.clone()) } else { None }
200 });
201
202 if thumbnail_url.is_none() {
203 thumbnail_url = crate::util::thumbnail::extract_thumbnail(&content);
204 }
205
206 let mut enclosures = enclosures
207 .into_iter()
208 .filter_map(|miniflux_enclosure| {
209 Url::parse(&miniflux_enclosure.url).ok().map(|url| Enclosure {
210 article_id: article_id.clone(),
211 url,
212 mime_type: Some(miniflux_enclosure.mime_type),
213 title: None,
214 position: None,
215 summary: None,
216 thumbnail_url: None,
217 filesize: if miniflux_enclosure.size > 0 {
218 Some(miniflux_enclosure.size as i32)
219 } else {
220 None
221 },
222 width: None,
223 height: None,
224 duration: None,
225 framerate: None,
226 alternative: None,
227 is_default: false,
228 })
229 })
230 .collect::<Vec<_>>();
231
232 let has_video = enclosures.iter().any(Enclosure::is_video);
233 let first_image_url = enclosures
234 .iter()
235 .find(|enclosure| enclosure.is_image())
236 .map(|enclosure| enclosure.url.to_string());
237
238 if let (true, Some(first_image_url)) = (has_video, first_image_url) {
239 tracing::debug!(?first_image_url, "has video + first image url");
240 enclosures = enclosures
241 .into_iter()
242 .filter_map(|mut enclosure| {
243 if enclosure.is_video() {
244 enclosure.thumbnail_url = Some(first_image_url.clone());
245 Some(enclosure)
246 } else if enclosure.is_image() {
247 None
248 } else {
249 Some(enclosure)
250 }
251 })
252 .collect();
253 }
254
255 let article = FatArticle {
256 article_id,
257 title: Some(title),
258 author: if author.is_empty() { None } else { Some(author) },
259 feed_id: FeedID::new(&feed_id.to_string()),
260 url: Url::parse(&url).ok(),
261 date: match DateTime::parse_from_rfc3339(&published_at) {
262 Ok(date) => date.with_timezone(&Utc),
263 Err(_) => Utc::now(),
264 },
265 synced: Utc::now(),
266 updated: None,
267 summary,
268 html: Some(content),
269 direction: None,
270 unread: match status.as_str().try_into() {
271 Ok(status) => match status {
272 EntryStatus::Read => models::Read::Read,
273 _ => models::Read::Unread,
274 },
275 Err(_) => models::Read::Unread,
276 },
277 marked: if starred { models::Marked::Marked } else { models::Marked::Unmarked },
278 scraped_content: None,
279 plain_text,
280 thumbnail_url,
281 };
282
283 (article, enclosures)
284 }
285
286 async fn convert_entry_vec(entries: Vec<MinifluxArticle>, portal: Arc<Box<dyn Portal>>) -> StreamConversionResult {
287 let enclosures: Arc<RwLock<Vec<Enclosure>>> = Arc::new(RwLock::new(Vec::new()));
288 let tasks = entries
289 .into_iter()
290 .map(|e| {
291 let portal = portal.clone();
292 let enclosures = enclosures.clone();
293
294 tokio::spawn(async move {
295 let (article, mut converted_enclousres) = Self::convert_entry(e, portal);
296 enclosures.write().await.append(&mut converted_enclousres);
297 article
298 })
299 })
300 .collect::<Vec<_>>();
301
302 let articles = future::join_all(tasks).await.into_iter().filter_map(|res| res.ok()).collect();
303
304 StreamConversionResult {
305 articles,
306 headlines: Vec::new(),
307 taggings: Vec::new(),
308 enclosures: Arc::into_inner(enclosures).map(|e| e.into_inner()).unwrap_or_default(),
309 }
310 }
311
312 pub async fn get_articles(&self, query: ArticleQuery, client: &Client) -> FeedApiResult<StreamConversionResult> {
313 if let Some(api) = &self.api {
314 let batch_size: i64 = 100;
315 let mut offset: Option<i64> = None;
316 let mut articles: Vec<FatArticle> = Vec::new();
317 let mut enclosures: Vec<Enclosure> = Vec::new();
318
319 loop {
320 let entries = api
321 .get_entries(
322 query.status,
323 offset,
324 Some(batch_size),
325 Some(OrderBy::PublishedAt),
326 Some(OrderDirection::Desc),
327 query.before,
328 query.after,
329 query.before_entry_id,
330 query.after_entry_id,
331 query.starred,
332 client,
333 )
334 .await?;
335
336 let entry_count = entries.len();
337 let mut converted = Miniflux::convert_entry_vec(entries, self.portal.clone()).await;
338 articles.append(&mut converted.articles);
339 enclosures.append(&mut converted.enclosures);
340
341 if entry_count < batch_size as usize {
342 break;
343 }
344
345 offset = match offset {
346 Some(offset) => Some(offset + batch_size),
347 None => Some(batch_size),
348 };
349 }
350 return Ok(StreamConversionResult {
351 articles,
352 headlines: Vec::new(),
353 taggings: Vec::new(),
354 enclosures,
355 });
356 }
357 Err(FeedApiError::Login)
358 }
359
360 fn article_ids_to_i64(ids: &[ArticleID]) -> Vec<i64> {
361 ids.iter().filter_map(|id| Self::article_id_to_i64(id).ok()).collect()
362 }
363
364 fn article_id_to_i64(id: &ArticleID) -> Result<i64, FeedApiError> {
365 id.as_str().parse::<i64>().map_err(|_| {
366 tracing::error!(%id, "Failed to parse ID");
367 FeedApiError::Unknown
368 })
369 }
370
371 fn feed_id_to_i64(id: &FeedID) -> Result<i64, FeedApiError> {
372 id.as_str().parse::<i64>().map_err(|_| {
373 tracing::error!(%id, "Failed to parse ID");
374 FeedApiError::Unknown
375 })
376 }
377
378 fn category_id_to_i64(id: &CategoryID) -> Result<i64, FeedApiError> {
379 id.as_str().parse::<i64>().map_err(|_| {
380 tracing::error!(%id, "Failed to parse ID");
381 FeedApiError::Unknown
382 })
383 }
384}
385
386#[async_trait]
387impl FeedApi for Miniflux {
388 fn features(&self) -> FeedApiResult<PluginCapabilities> {
389 Ok(PluginCapabilities::ADD_REMOVE_FEEDS | PluginCapabilities::SUPPORT_CATEGORIES | PluginCapabilities::MODIFY_CATEGORIES)
390 }
391
392 fn has_user_configured(&self) -> FeedApiResult<bool> {
393 Ok(self.api.is_some())
394 }
395
396 async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
397 if let Some(api) = &self.api {
398 api.healthcheck(client).await?;
399 Ok(true)
400 } else {
401 Err(FeedApiError::Login)
402 }
403 }
404
405 async fn is_logged_in(&self, _client: &Client) -> FeedApiResult<bool> {
406 Ok(self.logged_in)
407 }
408
409 async fn user_name(&self) -> Option<String> {
410 self.config.get_user_name()
411 }
412
413 async fn get_login_data(&self) -> Option<LoginData> {
414 if let Ok(true) = self.has_user_configured() {
415 if let (Some(username), Some(password)) = (self.config.get_user_name(), self.config.get_password()) {
416 return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
417 id: MinifluxMetadata::get_id(),
418 url: self.config.get_url(),
419 user: username,
420 password,
421 basic_auth: None, })));
423 } else if let Some(token) = self.config.get_token() {
424 return Some(LoginData::Direct(DirectLogin::Token(TokenLogin {
425 id: MinifluxMetadata::get_id(),
426 url: self.config.get_url(),
427 token,
428 basic_auth: None,
429 })));
430 }
431 }
432
433 None
434 }
435
436 async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
437 if let LoginData::Direct(simple_login_data) = data {
438 let api = match simple_login_data {
439 DirectLogin::Password(password_data) => {
440 if let Some(url_string) = password_data.url.clone() {
441 self.config.set_url(&url_string);
442 self.config.set_password(&password_data.password);
443 self.config.set_user_name(&password_data.user);
444 self.config.clear_token();
445
446 let url = Url::parse(&url_string)?;
447 MinifluxApi::new(&url, password_data.user.clone(), password_data.password)
448 } else {
449 tracing::error!("No URL set");
450 return Err(FeedApiError::Login);
451 }
452 }
453 DirectLogin::Token(token_data) => {
454 if let Some(url_string) = token_data.url.clone() {
455 self.config.set_url(&url_string);
456 self.config.set_token(&token_data.token);
457 self.config.clear_user_name();
458 self.config.clear_password();
459
460 let url = Url::parse(&url_string)?;
461 MinifluxApi::new_from_token(&url, token_data.token)
462 } else {
463 tracing::error!("No URL set");
464 return Err(FeedApiError::Login);
465 }
466 }
467 };
468
469 if self.config.get_user_name().is_none() {
470 let user = api.get_current_user(client).await?;
471 self.config.set_user_name(&user.username);
472 }
473
474 self.config.write()?;
475 self.api = Some(api);
476 self.logged_in = true;
477 return Ok(());
478 }
479
480 self.logged_in = false;
481 self.api = None;
482 Err(FeedApiError::Login)
483 }
484
485 async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
486 self.config.delete()?;
487 Ok(())
488 }
489
490 async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
491 if let Some(api) = &self.api {
492 let categories = api.get_categories(client);
493 let feeds = api.get_feeds(client);
494
495 let starred = self.get_articles(STARRED_QUERY, client);
496 let unread = self.get_articles(UNREAD_QUERY, client);
497
498 let (categories, feeds, starred, unread) = futures::join!(categories, feeds, starred, unread);
499
500 let (categories, category_mappings) = Miniflux::convert_category_vec(categories?);
501 let (feeds, feed_mappings) = Miniflux::convert_feed_vec(feeds?);
502
503 let mut starred = starred?;
504 let mut unread = unread?;
505
506 let mut articles: Vec<FatArticle> = Vec::new();
507 articles.append(&mut starred.articles);
508 articles.append(&mut unread.articles);
509
510 let mut enclosures: Vec<Enclosure> = Vec::new();
511 enclosures.append(&mut starred.enclosures);
512 enclosures.append(&mut unread.enclosures);
513
514 let entries = api
516 .get_entries(
517 Some(EntryStatus::Read),
518 None,
519 Some(100),
520 Some(OrderBy::PublishedAt),
521 Some(OrderDirection::Desc),
522 None,
523 None,
524 None,
525 None,
526 None,
527 client,
528 )
529 .await?;
530 let mut read = Miniflux::convert_entry_vec(entries, self.portal.clone()).await;
531 articles.append(&mut read.articles);
532 enclosures.append(&mut read.enclosures);
533
534 return Ok(SyncResult {
535 feeds: util::vec_to_option(feeds),
536 categories: util::vec_to_option(categories),
537 feed_mappings: util::vec_to_option(feed_mappings),
538 category_mappings: util::vec_to_option(category_mappings),
539 tags: None,
540 taggings: None,
541 headlines: None,
542 articles: util::vec_to_option(articles),
543 enclosures: util::vec_to_option(enclosures),
544 });
545 }
546 Err(FeedApiError::Login)
547 }
548
549 async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
550 if let Some(api) = &self.api {
551 let max_count = self.portal.get_config().read().await.get_sync_amount();
552
553 let categories = api.get_categories(client);
554 let feeds = api.get_feeds(client);
555
556 let unread = self.get_articles(UNREAD_QUERY, client);
557 let starred = self.get_articles(STARRED_QUERY, client);
558
559 let recent = api.get_entries(
561 Some(EntryStatus::Read),
562 None,
563 Some(i64::from(max_count)),
564 Some(OrderBy::PublishedAt),
565 Some(OrderDirection::Desc),
566 None,
567 None,
568 None,
569 None,
570 None,
571 client,
572 );
573
574 let (categories, feeds, starred, unread, recent) = futures::join!(categories, feeds, starred, unread, recent);
575
576 let (categories, category_mappings) = Miniflux::convert_category_vec(categories?);
577 let (feeds, feed_mappings) = Miniflux::convert_feed_vec(feeds?);
578
579 let mut recent = Miniflux::convert_entry_vec(recent?, self.portal.clone()).await;
580
581 let mut starred = starred?;
582 let mut unread = unread?;
583
584 let remote_unread_ids: HashSet<ArticleID> = unread.articles.iter().map(|a| &a.article_id).cloned().collect();
585 let remote_marked_ids: HashSet<ArticleID> = starred.articles.iter().map(|a| &a.article_id).cloned().collect();
586
587 let mut articles: Vec<FatArticle> = Vec::new();
588 articles.append(&mut unread.articles);
589 articles.append(&mut starred.articles);
590 articles.append(&mut recent.articles);
591
592 let mut enclosures: Vec<Enclosure> = Vec::new();
593 enclosures.append(&mut unread.enclosures);
594 enclosures.append(&mut starred.enclosures);
595 enclosures.append(&mut recent.enclosures);
596
597 let mut headlines: Vec<Headline> = Vec::new();
598
599 let local_unread_ids = self.portal.get_article_ids_unread_all()?;
601 let local_marked_ids = self.portal.get_article_ids_marked_all()?;
602
603 let local_unread_ids: HashSet<ArticleID> = local_unread_ids.into_iter().collect();
604 let local_marked_ids: HashSet<ArticleID> = local_marked_ids.into_iter().collect();
605
606 let mut should_mark_read_headlines = local_unread_ids
608 .difference(&remote_unread_ids)
609 .map(|id| Headline {
610 article_id: ArticleID::new(&id.to_string()),
611 unread: Read::Read,
612 marked: if remote_marked_ids.contains(id) {
613 Marked::Marked
614 } else {
615 Marked::Unmarked
616 },
617 })
618 .collect();
619 headlines.append(&mut should_mark_read_headlines);
620
621 let mut missing_unmarked_headlines = local_marked_ids
623 .difference(&remote_marked_ids)
624 .map(|id| Headline {
625 article_id: ArticleID::new(&id.to_string()),
626 marked: Marked::Unmarked,
627 unread: if remote_unread_ids.contains(id) { Read::Unread } else { Read::Read },
628 })
629 .collect();
630 headlines.append(&mut missing_unmarked_headlines);
631
632 Ok(SyncResult {
633 feeds: util::vec_to_option(feeds),
634 categories: util::vec_to_option(categories),
635 feed_mappings: util::vec_to_option(feed_mappings),
636 category_mappings: util::vec_to_option(category_mappings),
637 tags: None,
638 taggings: None,
639 headlines: Some(headlines),
640 articles: util::vec_to_option(articles),
641 enclosures: util::vec_to_option(enclosures),
642 })
643 } else {
644 Err(FeedApiError::Login)
645 }
646 }
647
648 async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
649 if let Some(api) = &self.api {
650 let miniflux_feed_id = Self::feed_id_to_i64(feed_id)?;
651 let miniflux_feed = api.get_feed(miniflux_feed_id, client).await?;
652 let feed = Miniflux::convert_feed(miniflux_feed);
653
654 let entries = api
655 .get_feed_entries(miniflux_feed_id, None, None, None, None, None, None, None, None, None, None, client)
656 .await?;
657 let result = Miniflux::convert_entry_vec(entries, self.portal.clone()).await;
658
659 Ok(FeedUpdateResult {
660 feed: Some(feed),
661 taggings: None,
662 articles: util::vec_to_option(result.articles),
663 enclosures: util::vec_to_option(result.enclosures),
664 })
665 } else {
666 Err(FeedApiError::Login)
667 }
668 }
669
670 async fn set_article_read(&self, articles: &[ArticleID], read: models::Read, client: &Client) -> FeedApiResult<()> {
671 if articles.is_empty() {
672 Ok(())
673 } else if let Some(api) = &self.api {
674 let entries = Miniflux::article_ids_to_i64(articles);
675 let status = match read {
676 models::Read::Read => EntryStatus::Read,
677 models::Read::Unread => EntryStatus::Unread,
678 };
679 api.update_entries_status(entries, status, client).await?;
680
681 return Ok(());
682 } else {
683 Err(FeedApiError::Login)
684 }
685 }
686
687 async fn set_article_marked(&self, articles: &[ArticleID], _marked: models::Marked, client: &Client) -> FeedApiResult<()> {
688 if let Some(api) = &self.api {
689 for article in articles {
693 if let Ok(entry_id) = article.as_str().parse::<i64>() {
694 api.toggle_bookmark(entry_id, client).await?;
695 }
696 }
697
698 return Ok(());
699 }
700 Err(FeedApiError::Login)
701 }
702
703 async fn set_feed_read(&self, _feeds: &[FeedID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
704 self.set_article_read(articles, Read::Read, client).await
705 }
706
707 async fn set_category_read(&self, _categories: &[CategoryID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
708 self.set_article_read(articles, Read::Read, client).await
709 }
710
711 async fn set_tag_read(&self, _tags: &[TagID], _articles: &[ArticleID], _client: &Client) -> FeedApiResult<()> {
712 Err(FeedApiError::Unsupported)
713 }
714
715 async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
716 self.set_article_read(articles, Read::Read, client).await
717 }
718
719 async fn add_feed(
720 &self,
721 url: &Url,
722 title: Option<String>,
723 category_id: Option<CategoryID>,
724 client: &Client,
725 ) -> FeedApiResult<(Feed, Option<Category>)> {
726 if let Some(api) = &self.api {
727 let category_id = match category_id {
728 Some(category_id) => Self::category_id_to_i64(&category_id)?,
729 None => {
730 tracing::info!("Creating empty category for feed");
731 match api.create_category(DEFAULT_CATEGORY, client).await {
732 Ok(category) => category.id,
733 Err(_) => {
734 tracing::warn!("Creating empty category failed");
735 tracing::info!("Checking if 'New Category' already exists");
736
737 let categories = api.get_categories(client).await?;
738
739 match categories.iter().find(|c| c.title == DEFAULT_CATEGORY) {
740 Some(new_category) => new_category.id,
741 None => match categories.first() {
742 Some(first_category) => first_category.id,
743 None => {
744 let msg = "Was not able to create or find cateogry to add feed into";
745 tracing::error!("{msg}");
746 return Err(FeedApiError::Api { message: msg.into() });
747 }
748 },
749 }
750 }
751 }
752 }
753 };
754
755 let feed_id = api.create_feed(url, category_id, client).await?;
756
757 if let Some(title) = title {
758 api.update_feed(feed_id, Some(&title), None, None, None, None, None, None, client).await?;
759 }
760
761 let feed = api.get_feed(feed_id, client).await?;
762 let category = api
763 .get_categories(client)
764 .await?
765 .iter()
766 .find(|c| c.id == category_id)
767 .map(|c| Miniflux::convert_category(c.clone(), None))
768 .map(|(c, _m)| c);
769
770 return Ok((Miniflux::convert_feed(feed), category));
771 }
772 Err(FeedApiError::Login)
773 }
774
775 async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
776 if let Some(api) = &self.api {
777 let feed_id = Self::feed_id_to_i64(id)?;
778 api.delete_feed(feed_id, client).await?;
779 return Ok(());
780 }
781 Err(FeedApiError::Login)
782 }
783
784 async fn move_feed(&self, feed_id: &FeedID, _from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
785 if let Some(api) = &self.api {
786 let category_id = Self::category_id_to_i64(to)?;
787
788 let miniflux_feed_id = Self::feed_id_to_i64(feed_id)?;
789
790 api.update_feed(miniflux_feed_id, None, Some(category_id), None, None, None, None, None, client)
791 .await?;
792 return Ok(());
793 }
794 Err(FeedApiError::Login)
795 }
796
797 async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
798 if let Some(api) = &self.api {
799 let miniflux_feed_id = Self::feed_id_to_i64(feed_id)?;
800
801 api.update_feed(miniflux_feed_id, Some(new_title), None, None, None, None, None, None, client)
802 .await?;
803
804 return Ok(feed_id.clone());
805 }
806 Err(FeedApiError::Login)
807 }
808
809 async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
810 Err(FeedApiError::Unsupported)
811 }
812
813 async fn add_category<'a>(&self, title: &str, _parent: Option<&'a CategoryID>, client: &Client) -> FeedApiResult<CategoryID> {
814 if let Some(api) = &self.api {
815 let category = api.create_category(title, client).await?;
816 return Ok(CategoryID::new(&category.id.to_string()));
817 }
818 Err(FeedApiError::Login)
819 }
820
821 async fn remove_category(&self, id: &CategoryID, _remove_children: bool, client: &Client) -> FeedApiResult<()> {
822 if let Some(api) = &self.api {
823 let miniflux_id = Self::category_id_to_i64(id)?;
825 api.delete_category(miniflux_id, client).await?;
826 return Ok(());
827 }
828 Err(FeedApiError::Login)
829 }
830
831 async fn rename_category(&self, id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
832 if let Some(api) = &self.api {
833 let miniflux_id = Self::category_id_to_i64(id)?;
834 api.update_category(miniflux_id, new_title, client).await?;
835 return Ok(id.clone());
836 }
837 Err(FeedApiError::Login)
838 }
839
840 async fn move_category(&self, _id: &CategoryID, _parent: &CategoryID, _client: &Client) -> FeedApiResult<()> {
841 Err(FeedApiError::Unsupported)
842 }
843
844 async fn import_opml(&self, opml: &str, client: &Client) -> FeedApiResult<()> {
845 if let Some(api) = &self.api {
846 api.import_opml(opml, client).await?;
847 }
848 Err(FeedApiError::Login)
849 }
850
851 async fn add_tag(&self, _title: &str, _client: &Client) -> FeedApiResult<TagID> {
852 Err(FeedApiError::Unsupported)
853 }
854
855 async fn remove_tag(&self, _id: &TagID, _client: &Client) -> FeedApiResult<()> {
856 Err(FeedApiError::Unsupported)
857 }
858
859 async fn rename_tag(&self, _id: &TagID, _new_title: &str, _client: &Client) -> FeedApiResult<TagID> {
860 Err(FeedApiError::Unsupported)
861 }
862
863 async fn tag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
864 Err(FeedApiError::Unsupported)
865 }
866
867 async fn untag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
868 Err(FeedApiError::Unsupported)
869 }
870
871 async fn get_favicon(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
872 if let Some(api) = &self.api {
873 let miniflux_feed_id = Self::feed_id_to_i64(feed_id)?;
874
875 let favicon = api.get_feed_icon(miniflux_feed_id, client).await?;
876
877 if let Some(start) = favicon.data.find(',') {
878 let data = base64_std.decode(&favicon.data[start + 1..]).map_err(|_| FeedApiError::Encryption)?;
879
880 let favicon = FavIcon {
881 feed_id: feed_id.clone(),
882 expires: Utc::now() + Duration::try_days(EXPIRES_AFTER_DAYS).unwrap(),
883 format: Some(favicon.mime_type),
884 etag: None,
885 source_url: None,
886 data: Some(data),
887 };
888
889 return Ok(favicon);
890 }
891 }
892 Err(FeedApiError::Login)
893 }
894}