Skip to main content

news_flash/feed_api_implementations/nextcloud/
mod.rs

1mod config;
2pub mod metadata;
3
4use std::collections::HashSet;
5use std::sync::Arc;
6
7use self::config::AccountConfig;
8use self::metadata::NextcloudMetadata;
9use crate::feed_api::{FeedApi, FeedApiError, FeedApiResult, FeedHeaderMap, Portal};
10use crate::models::{
11    self, ArticleID, Category, CategoryID, CategoryMapping, DirectLogin, Direction, Enclosure, FatArticle, FavIcon, Feed, FeedID, FeedMapping,
12    FeedUpdateResult, LoginData, Marked, NEWSFLASH_TOPLEVEL, PasswordLogin, PluginCapabilities, Read, StreamConversionResult, SyncResult, TagID, Url,
13};
14use crate::util;
15use async_trait::async_trait;
16use chrono::Utc;
17use futures::future;
18use nextcloud_news_api::models::{Feed as NcFeed, Folder, Item, ItemType};
19use nextcloud_news_api::{ApiError as NextcloudError, NextcloudNewsApi};
20use reqwest::Client;
21use reqwest::header::{HeaderMap, HeaderValue};
22use semver::Version;
23use tokio::sync::RwLock;
24
25impl From<NextcloudError> for FeedApiError {
26    fn from(error: NextcloudError) -> FeedApiError {
27        match error {
28            NextcloudError::Url(e) => FeedApiError::Url(e),
29            NextcloudError::Json { source, json } => FeedApiError::Json { source, json },
30            NextcloudError::Http(e) => FeedApiError::Network(e),
31            NextcloudError::Parse => FeedApiError::Api {
32                message: NextcloudError::Parse.to_string(),
33            },
34            NextcloudError::Input => FeedApiError::Api {
35                message: NextcloudError::Input.to_string(),
36            },
37            NextcloudError::Unauthorized => FeedApiError::Auth,
38            NextcloudError::Unknown => FeedApiError::Unknown,
39        }
40    }
41}
42
43pub struct Nextcloud {
44    api: Option<NextcloudNewsApi>,
45    portal: Arc<Box<dyn Portal>>,
46    logged_in: bool,
47    config: AccountConfig,
48}
49
50impl Nextcloud {
51    fn ids_to_nc_ids<T: ToString>(ids: &[T]) -> Vec<i64> {
52        ids.iter().filter_map(|id| id.to_string().parse::<i64>().ok()).collect()
53    }
54
55    fn id_to_nc_id<T: ToString>(id: &T) -> Option<i64> {
56        id.to_string().parse::<i64>().ok()
57    }
58
59    fn convert_folder_vec(mut categories: Vec<Folder>) -> (Vec<Category>, Vec<CategoryMapping>) {
60        categories
61            .drain(..)
62            .enumerate()
63            .map(|(i, c)| Self::convert_folder(c, Some(i as i32)))
64            .unzip()
65    }
66
67    fn convert_folder(folder: Folder, sort_index: Option<i32>) -> (Category, CategoryMapping) {
68        let Folder { id, name } = folder;
69        let category_id = CategoryID::new(&id.to_string());
70        let category = Category {
71            category_id: category_id.clone(),
72            label: name,
73        };
74        let category_mapping = CategoryMapping {
75            parent_id: NEWSFLASH_TOPLEVEL.clone(),
76            category_id,
77            sort_index,
78        };
79        (category, category_mapping)
80    }
81
82    fn convert_feed(feed: NcFeed) -> Feed {
83        let NcFeed {
84            id,
85            url,
86            title,
87            favicon_link,
88            added: _,
89            folder_id: _,
90            unread_count: _,
91            ordering: _,
92            link,
93            pinned: _,
94            update_error_count,
95            last_update_error,
96        } = feed;
97
98        Feed {
99            feed_id: FeedID::new(&id.to_string()),
100            label: title,
101            website: link.and_then(|link| Url::parse(&link).ok()),
102            feed_url: Url::parse(&url).ok(),
103            icon_url: favicon_link.and_then(|url| Url::parse(&url).ok()),
104            error_count: update_error_count as i32,
105            error_message: last_update_error,
106        }
107    }
108
109    fn convert_feed_vec(mut feeds: Vec<NcFeed>) -> (Vec<Feed>, Vec<FeedMapping>) {
110        let mut mappings: Vec<FeedMapping> = Vec::new();
111        let feeds = feeds
112            .drain(..)
113            .enumerate()
114            .map(|(i, f)| {
115                mappings.push(FeedMapping {
116                    feed_id: FeedID::new(&f.id.to_string()),
117                    category_id: f
118                        .folder_id
119                        .map(|id| CategoryID::new(&id.to_string()))
120                        .unwrap_or_else(|| NEWSFLASH_TOPLEVEL.clone()),
121                    sort_index: Some(i as i32),
122                });
123
124                Self::convert_feed(f)
125            })
126            .collect();
127
128        (feeds, mappings)
129    }
130
131    async fn convert_item_vec(items: Vec<Item>, feed_ids: &HashSet<FeedID>, portal: Arc<Box<dyn Portal>>) -> StreamConversionResult {
132        let enclosures: Arc<RwLock<Vec<Enclosure>>> = Arc::new(RwLock::new(Vec::new()));
133        let tasks = items
134            .into_iter()
135            .map(|i| {
136                let feed_ids = feed_ids.clone();
137                let portal = portal.clone();
138                let enclosures = enclosures.clone();
139
140                tokio::spawn(async move {
141                    if feed_ids.contains(&FeedID::new(&i.feed_id.to_string())) || i.starred {
142                        let (article, enclousre) = Self::convert_item(i, portal);
143                        if let Some(enclosure) = enclousre {
144                            enclosures.write().await.push(enclosure);
145                        }
146                        Some(article)
147                    } else {
148                        None
149                    }
150                })
151            })
152            .collect::<Vec<_>>();
153
154        let articles = future::join_all(tasks).await.into_iter().filter_map(|res| res.ok().flatten()).collect();
155
156        StreamConversionResult {
157            articles,
158            headlines: Vec::new(),
159            taggings: Vec::new(),
160            enclosures: Arc::into_inner(enclosures).map(|e| e.into_inner()).unwrap_or_default(),
161        }
162    }
163
164    fn convert_item(item: Item, portal: Arc<Box<dyn Portal>>) -> (FatArticle, Option<Enclosure>) {
165        let Item {
166            id,
167            guid: _,
168            guid_hash: _,
169            url,
170            title,
171            author,
172            pub_date,
173            body,
174            enclosure_mime,
175            enclosure_link,
176            media_thumbnail,
177            media_description,
178            feed_id,
179            unread,
180            starred,
181            rtl,
182            last_modified: _,
183            fingerprint: _,
184        } = item;
185
186        let article_id = ArticleID::new(&id.to_string());
187
188        let article_exists_locally = portal.get_article_exists(&article_id).unwrap_or(false);
189
190        let plain_text = if article_exists_locally {
191            None
192        } else {
193            Some(util::html2text::html2text(&body))
194        };
195
196        let summary = plain_text.as_deref().map(util::html2text::text2summary);
197        let thumbnail_url = if let Some(media_thumbnail) = &media_thumbnail {
198            Some(media_thumbnail.clone())
199        } else {
200            crate::util::thumbnail::extract_thumbnail(&body)
201        };
202
203        let article = FatArticle {
204            article_id: article_id.clone(),
205            title,
206            author,
207            feed_id: FeedID::new(&feed_id.to_string()),
208            url: url.and_then(|url| Url::parse(&url).ok()),
209            date: util::timestamp_to_datetime(pub_date),
210            synced: Utc::now(),
211            updated: None,
212            summary,
213            html: Some(body),
214            direction: Some(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }),
215            unread: if unread { Read::Unread } else { Read::Read },
216            marked: if starred { Marked::Marked } else { Marked::Unmarked },
217            scraped_content: None,
218            plain_text,
219            thumbnail_url,
220        };
221        let enclosure = enclosure_link.and_then(|enc_url| {
222            Url::parse(&enc_url).ok().map(|url| Enclosure {
223                article_id,
224                url,
225                mime_type: enclosure_mime,
226                title: None,
227                position: None,
228                summary: media_description,
229                thumbnail_url: media_thumbnail,
230                filesize: None,
231                width: None,
232                height: None,
233                duration: None,
234                framerate: None,
235                alternative: None,
236                is_default: false,
237            })
238        });
239
240        (article, enclosure)
241    }
242}
243
244#[async_trait]
245impl FeedApi for Nextcloud {
246    fn features(&self) -> FeedApiResult<PluginCapabilities> {
247        Ok(PluginCapabilities::ADD_REMOVE_FEEDS | PluginCapabilities::SUPPORT_CATEGORIES | PluginCapabilities::MODIFY_CATEGORIES)
248    }
249
250    fn has_user_configured(&self) -> FeedApiResult<bool> {
251        Ok(self.api.is_some())
252    }
253
254    async fn is_reachable(&self, client: &Client) -> FeedApiResult<bool> {
255        if let Some(api) = &self.api {
256            let _version = api.get_version(client).await?;
257            Ok(true)
258        } else {
259            Err(FeedApiError::Login)
260        }
261    }
262
263    async fn is_logged_in(&self, _client: &Client) -> FeedApiResult<bool> {
264        Ok(self.logged_in)
265    }
266
267    async fn user_name(&self) -> Option<String> {
268        self.config.get_user_name()
269    }
270
271    async fn get_login_data(&self) -> Option<LoginData> {
272        if self.has_user_configured().unwrap_or(false) {
273            let username = self.config.get_user_name();
274            let password = self.config.get_password();
275
276            if let (Some(username), Some(password)) = (username, password) {
277                return Some(LoginData::Direct(DirectLogin::Password(PasswordLogin {
278                    id: NextcloudMetadata::get_id(),
279                    url: self.config.get_url(),
280                    user: username,
281                    password,
282                    basic_auth: None,
283                })));
284            }
285        }
286
287        None
288    }
289
290    async fn login(&mut self, data: LoginData, client: &Client) -> FeedApiResult<()> {
291        if let LoginData::Direct(DirectLogin::Password(data)) = data
292            && let Some(url_string) = data.url.clone()
293        {
294            let url = Url::parse(&url_string)?;
295            let api = NextcloudNewsApi::new(&url, data.user.clone(), data.password.clone())?;
296
297            let nextcloud_news_api::models::Version { version: version_string } = api.get_version(client).await?;
298            let semver = Version::parse(&version_string).map_err(|_| {
299                tracing::error!(%version_string,"Failed to parse version string: {version_string}");
300                FeedApiError::Login
301            })?;
302            let min_version = Version::new(18, 1, 1);
303            if semver < min_version {
304                tracing::error!("Nextcloud News app is version {semver}. Minimal required version is {min_version}.");
305                return Err(FeedApiError::UnsupportedVersion {
306                    min_supported: min_version,
307                    found: Some(semver),
308                });
309            }
310
311            self.config.set_url(&url_string);
312            self.config.set_password(&data.password);
313            self.config.set_user_name(&data.user);
314            self.config.write()?;
315            self.api = Some(api);
316            self.logged_in = true;
317            return Ok(());
318        }
319
320        self.logged_in = false;
321        self.api = None;
322        Err(FeedApiError::Login)
323    }
324
325    async fn logout(&mut self, _client: &Client) -> FeedApiResult<()> {
326        self.config.delete()
327    }
328
329    async fn initial_sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
330        if let Some(api) = &self.api {
331            let folders = api.get_folders(client);
332            let feeds = api.get_feeds(client);
333
334            let unread_items = api.get_items(client, -1, None, None, None, Some(false), None);
335            let starred_items = api.get_items(client, -1, None, Some(ItemType::Starred), None, None, None);
336
337            let (folders, feeds, unread_items, starred_items) = futures::try_join!(folders, feeds, unread_items, starred_items)?;
338
339            let (categories, category_mappings) = Nextcloud::convert_folder_vec(folders);
340            let (feeds, feed_mappings) = Nextcloud::convert_feed_vec(feeds);
341
342            let feed_id_set: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
343
344            let mut articles: Vec<FatArticle> = Vec::new();
345            let mut enclosures: Vec<Enclosure> = Vec::new();
346
347            let mut unread = Self::convert_item_vec(unread_items, &feed_id_set, self.portal.clone()).await;
348            articles.append(&mut unread.articles);
349            enclosures.append(&mut unread.enclosures);
350
351            let mut starred = Self::convert_item_vec(starred_items, &feed_id_set, self.portal.clone()).await;
352            articles.append(&mut starred.articles);
353            enclosures.append(&mut starred.enclosures);
354
355            return Ok(SyncResult {
356                feeds: util::vec_to_option(feeds),
357                categories: util::vec_to_option(categories),
358                feed_mappings: util::vec_to_option(feed_mappings),
359                category_mappings: util::vec_to_option(category_mappings),
360                tags: None,
361                taggings: None,
362                headlines: None,
363                articles: util::vec_to_option(articles),
364                enclosures: util::vec_to_option(enclosures),
365            });
366        }
367
368        Err(FeedApiError::Login)
369    }
370
371    async fn sync(&self, client: &Client, _custom_header: FeedHeaderMap) -> FeedApiResult<SyncResult> {
372        if let Some(api) = &self.api {
373            let last_sync = self.portal.get_config().read().await.get_last_sync().timestamp() as u64;
374
375            let folders = api.get_folders(client);
376            let feeds = api.get_feeds(client);
377            let updated_items = api.get_updated_items(client, last_sync, None, None);
378
379            let (folders, feeds, updated_items) = futures::try_join!(folders, feeds, updated_items)?;
380
381            let (categories, category_mappings) = Nextcloud::convert_folder_vec(folders);
382            let (feeds, mappings) = Nextcloud::convert_feed_vec(feeds);
383            let feed_id_set: HashSet<FeedID> = feeds.iter().map(|f| f.feed_id.clone()).collect();
384
385            let conversion_result = Self::convert_item_vec(updated_items, &feed_id_set, self.portal.clone()).await;
386
387            return Ok(SyncResult {
388                feeds: util::vec_to_option(feeds),
389                categories: util::vec_to_option(categories),
390                feed_mappings: util::vec_to_option(mappings),
391                category_mappings: util::vec_to_option(category_mappings),
392                tags: None,
393                taggings: None,
394                headlines: None,
395                articles: util::vec_to_option(conversion_result.articles),
396                enclosures: util::vec_to_option(conversion_result.enclosures),
397            });
398        }
399
400        Err(FeedApiError::Login)
401    }
402
403    async fn fetch_feed(&self, feed_id: &FeedID, client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FeedUpdateResult> {
404        if let Some(api) = &self.api {
405            let feeds = api.get_feeds(client).await?;
406            let (feeds, _mappings) = Nextcloud::convert_feed_vec(feeds);
407
408            let feed = feeds.iter().find(|feed| &feed.feed_id == feed_id).cloned();
409
410            let mut feed_id_set: HashSet<FeedID> = HashSet::new();
411            feed_id_set.insert(feed_id.clone());
412
413            let nextcloud_feed_id = feed_id.as_str().parse::<u64>().map_err(|_| FeedApiError::Api {
414                message: format!("Failed to parse id {feed_id}"),
415            })?;
416
417            let items = api.get_items(client, -1, None, None, Some(nextcloud_feed_id), None, None).await?;
418            let conversion_result = Self::convert_item_vec(items, &feed_id_set, self.portal.clone()).await;
419
420            Ok(FeedUpdateResult {
421                feed,
422                taggings: None,
423                articles: util::vec_to_option(conversion_result.articles),
424                enclosures: util::vec_to_option(conversion_result.enclosures),
425            })
426        } else {
427            Err(FeedApiError::Login)
428        }
429    }
430
431    async fn set_article_read(&self, articles: &[ArticleID], read: models::Read, client: &Client) -> FeedApiResult<()> {
432        if let Some(api) = &self.api {
433            let nc_ids = Self::ids_to_nc_ids(articles);
434
435            match read {
436                Read::Read => api.mark_items_read(client, nc_ids).await?,
437                Read::Unread => api.mark_items_unread(client, nc_ids).await?,
438            }
439
440            return Ok(());
441        }
442        Err(FeedApiError::Login)
443    }
444
445    async fn set_article_marked(&self, articles: &[ArticleID], marked: models::Marked, client: &Client) -> FeedApiResult<()> {
446        if let Some(api) = &self.api {
447            let nc_ids = Self::ids_to_nc_ids(articles);
448
449            match marked {
450                Marked::Marked => api.mark_items_starred(client, nc_ids).await?,
451                Marked::Unmarked => api.mark_items_unstarred(client, nc_ids).await?,
452            }
453
454            return Ok(());
455        }
456        Err(FeedApiError::Login)
457    }
458
459    async fn set_feed_read(&self, feeds: &[FeedID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
460        if let Some(api) = &self.api {
461            let nc_ids = Self::ids_to_nc_ids(feeds);
462            let newest_unread_article_id = articles.iter().filter_map(Self::id_to_nc_id).max().unwrap_or(i64::MAX);
463
464            let mut futures = Vec::new();
465            for feed_id in nc_ids {
466                futures.push(api.mark_feed(client, feed_id, newest_unread_article_id));
467            }
468            let results = futures::future::join_all(futures).await;
469            let result: Result<Vec<()>, FeedApiError> = results.into_iter().map(|res| res.map_err(FeedApiError::from)).collect();
470            let _ = result?;
471
472            return Ok(());
473        }
474        Err(FeedApiError::Login)
475    }
476
477    async fn set_category_read(&self, categories: &[CategoryID], articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
478        if let Some(api) = &self.api {
479            let nc_ids = Self::ids_to_nc_ids(categories);
480            let newest_unread_article_id = articles.iter().filter_map(Self::id_to_nc_id).max().unwrap_or(i64::MAX);
481
482            let mut futures = Vec::new();
483            for folder_id in nc_ids {
484                futures.push(api.mark_folder(client, folder_id, newest_unread_article_id));
485            }
486            let results = futures::future::join_all(futures).await;
487            let result: Result<Vec<()>, FeedApiError> = results.into_iter().map(|res| res.map_err(FeedApiError::from)).collect();
488            let _ = result?;
489
490            return Ok(());
491        }
492        Err(FeedApiError::Login)
493    }
494
495    async fn set_tag_read(&self, _tags: &[TagID], _articles: &[ArticleID], _client: &Client) -> FeedApiResult<()> {
496        Err(FeedApiError::Unsupported)
497    }
498
499    async fn set_all_read(&self, articles: &[ArticleID], client: &Client) -> FeedApiResult<()> {
500        if let Some(api) = &self.api {
501            let newest_unread_article_id = articles.iter().filter_map(Self::id_to_nc_id).max().unwrap_or(i64::MAX);
502            let _ = api.mark_all_items_read(client, newest_unread_article_id).await?;
503            return Ok(());
504        }
505        Err(FeedApiError::Login)
506    }
507
508    async fn add_feed(
509        &self,
510        url: &Url,
511        title: Option<String>,
512        category_id: Option<CategoryID>,
513        client: &Client,
514    ) -> FeedApiResult<(Feed, Option<Category>)> {
515        if let Some(api) = &self.api {
516            let folder_id = category_id.and_then(|id| Self::id_to_nc_id(&id));
517
518            let feed = api.create_feed(client, url.as_str(), folder_id).await?;
519
520            if let Some(title) = title {
521                api.rename_feed(client, feed.id, &title).await?;
522            }
523
524            let category = api
525                .get_folders(client)
526                .await?
527                .iter()
528                .find(|f| Some(f.id) == folder_id)
529                .map(|f| Self::convert_folder(f.clone(), None))
530                .map(|(c, _m)| c);
531
532            return Ok((Self::convert_feed(feed), category));
533        }
534        Err(FeedApiError::Login)
535    }
536
537    async fn remove_feed(&self, id: &FeedID, client: &Client) -> FeedApiResult<()> {
538        if let Some(api) = &self.api {
539            api.delete_feed(client, Self::id_to_nc_id(id).unwrap()).await?;
540            return Ok(());
541        }
542        Err(FeedApiError::Login)
543    }
544
545    async fn move_feed(&self, feed_id: &FeedID, _from: &CategoryID, to: &CategoryID, client: &Client) -> FeedApiResult<()> {
546        if let Some(api) = &self.api {
547            api.move_feed(client, Self::id_to_nc_id(feed_id).unwrap(), Some(Self::id_to_nc_id(to).unwrap()))
548                .await?;
549            return Ok(());
550        }
551        Err(FeedApiError::Login)
552    }
553
554    async fn rename_feed(&self, feed_id: &FeedID, new_title: &str, client: &Client) -> FeedApiResult<FeedID> {
555        if let Some(api) = &self.api {
556            api.rename_feed(client, Self::id_to_nc_id(feed_id).unwrap(), new_title).await?;
557            return Ok(feed_id.clone());
558        }
559        Err(FeedApiError::Login)
560    }
561
562    async fn edit_feed_url(&self, _feed_id: &FeedID, _new_url: &str, _client: &Client) -> FeedApiResult<()> {
563        Err(FeedApiError::Unsupported)
564    }
565
566    async fn add_category<'a>(&self, title: &str, _parent: Option<&'a CategoryID>, client: &Client) -> FeedApiResult<CategoryID> {
567        if let Some(api) = &self.api {
568            let folder = api.create_folder(client, title).await?;
569            return Ok(CategoryID::new(&folder.id.to_string()));
570        }
571        Err(FeedApiError::Login)
572    }
573
574    async fn remove_category(&self, id: &CategoryID, _remove_children: bool, client: &Client) -> FeedApiResult<()> {
575        if let Some(api) = &self.api {
576            api.delete_folder(client, Self::id_to_nc_id(id).unwrap()).await?;
577            return Ok(());
578        }
579        Err(FeedApiError::Login)
580    }
581
582    async fn rename_category(&self, id: &CategoryID, new_title: &str, client: &Client) -> FeedApiResult<CategoryID> {
583        if let Some(api) = &self.api {
584            api.rename_folder(client, Self::id_to_nc_id(id).unwrap(), new_title).await?;
585            return Ok(id.clone());
586        }
587        Err(FeedApiError::Login)
588    }
589
590    async fn move_category(&self, _id: &CategoryID, _parent: &CategoryID, _client: &Client) -> FeedApiResult<()> {
591        Err(FeedApiError::Unsupported)
592    }
593
594    async fn import_opml(&self, _opml: &str, _client: &Client) -> FeedApiResult<()> {
595        Err(FeedApiError::Unsupported)
596    }
597
598    async fn add_tag(&self, _title: &str, _client: &Client) -> FeedApiResult<TagID> {
599        Err(FeedApiError::Unsupported)
600    }
601
602    async fn remove_tag(&self, _id: &TagID, _client: &Client) -> FeedApiResult<()> {
603        Err(FeedApiError::Unsupported)
604    }
605
606    async fn rename_tag(&self, _id: &TagID, _new_title: &str, _client: &Client) -> FeedApiResult<TagID> {
607        Err(FeedApiError::Unsupported)
608    }
609
610    async fn tag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
611        Err(FeedApiError::Unsupported)
612    }
613
614    async fn untag_article(&self, _article_id: &ArticleID, _tag_id: &TagID, _client: &Client) -> FeedApiResult<()> {
615        Err(FeedApiError::Unsupported)
616    }
617
618    async fn get_favicon(&self, _feed_id: &FeedID, _client: &Client, _custom_header: HeaderMap<HeaderValue>) -> FeedApiResult<FavIcon> {
619        Err(FeedApiError::Unsupported)
620    }
621}