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}