From 9ca7a4e0e56610b7762fa29f0292b485e75cc60e Mon Sep 17 00:00:00 2001 From: josuebarretogit Date: Wed, 6 Nov 2024 23:09:56 -0500 Subject: [PATCH 01/14] test(anilist): add test anilist update manga progress --- src/backend.rs | 1 + src/backend/tracker.rs | 30 +++ src/backend/tracker/anilist.rs | 368 +++++++++++++++++++++++++++++++++ 3 files changed, 399 insertions(+) create mode 100644 src/backend/tracker.rs create mode 100644 src/backend/tracker/anilist.rs diff --git a/src/backend.rs b/src/backend.rs index 6bdc815..d45a885 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -15,6 +15,7 @@ pub mod error_log; pub mod fetch; pub mod filter; pub mod migration; +pub mod tracker; pub mod tui; #[derive(Display, EnumIter)] diff --git a/src/backend/tracker.rs b/src/backend/tracker.rs new file mode 100644 index 0000000..b71c831 --- /dev/null +++ b/src/backend/tracker.rs @@ -0,0 +1,30 @@ +use futures::Future; +use manga_tui::SearchTerm; +use serde::{Deserialize, Serialize}; + +pub mod anilist; + +#[derive(Debug, Deserialize, Serialize, Default, PartialEq, Eq)] +pub struct MangaToTrack { + pub id: String, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct MarkAsRead<'a> { + pub id: &'a str, + pub chapter_number: u32, + pub volume_number: Option, +} + +pub trait MangaTracker { + fn search_manga_by_title( + &self, + title: SearchTerm, + ) -> impl Future, Box>> + Send; + + /// Implementors may require api key / account token in order to perform this operation + fn mark_manga_as_read_with_chapter_count( + &self, + manga: MarkAsRead<'_>, + ) -> impl Future>> + Send; +} diff --git a/src/backend/tracker/anilist.rs b/src/backend/tracker/anilist.rs new file mode 100644 index 0000000..f0f300d --- /dev/null +++ b/src/backend/tracker/anilist.rs @@ -0,0 +1,368 @@ +static BASE_ANILIST_API_URL: &str = "https://graphql.anilist.co"; + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use http::header::{ACCEPT, CONTENT_TYPE}; + use http::{HeaderMap, HeaderValue, StatusCode}; + use httpmock::Method::POST; + use httpmock::MockServer; + use manga_tui::SearchTerm; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::{Client, Url}; + use serde::{Deserialize, Serialize}; + use serde_json::json; + use uuid::Uuid; + + use super::*; + use crate::backend::tracker::{MangaToTrack, MangaTracker, MarkAsRead}; + + trait RemoveWhitespace { + /// Util trait for comparing two string without taking into account whitespaces and tabs (don't know a + /// better, smarter way xd) + fn remove_whitespace(&self) -> String; + } + + impl RemoveWhitespace for serde_json::Value { + fn remove_whitespace(&self) -> String { + self.to_string().split_whitespace().map(|line| line.trim()).collect() + } + } + + #[derive(Debug, Deserialize, Serialize)] + struct Manga { + id: String, + title: String, + } + + #[derive(Debug, Deserialize, Serialize)] + struct GetMangaByTitleQuery<'a> { + title: &'a str, + } + + /// The body that must be sent via POST request to anilist API + /// Composed of the `query` which models what to request + /// and `variables` to indicate the data that must be sent + pub trait GraphqlBody: Sized { + fn query(&self) -> &'static str; + fn variables(&self) -> serde_json::Value; + fn into_json(self) -> serde_json::Value { + json!( + { + "query" : self.query(), + "variables" : self.variables() + } + ) + } + + fn into_body(self) -> String { + self.into_json().to_string() + } + } + + impl<'a> GetMangaByTitleQuery<'a> { + fn new(title: &'a str) -> Self { + Self { title } + } + } + + impl<'a> GraphqlBody for GetMangaByTitleQuery<'a> { + fn query(&self) -> &'static str { + r#" + query ($search: String) { + Media (search: $search, type: MANGA) { + id + } + } + "# + } + + fn variables(&self) -> serde_json::Value { + json!({ + "search" : self.title + }) + } + } + + /// set as reading, + /// mark chapter progress number + /// mark start date + /// mark volume progress as well + #[derive(Debug, Deserialize, Serialize)] + struct MarkMangaAsReadQuery { + id: u32, + chapter_count: u32, + volume_number: u32, + } + + impl MarkMangaAsReadQuery { + fn new(id: u32, chapter_count: u32, volume_number: u32) -> Self { + Self { + id, + chapter_count, + volume_number, + } + } + } + + impl GraphqlBody for MarkMangaAsReadQuery { + fn query(&self) -> &'static str { + r#" + mutation ($id: Int, $progress: Int, $progressVolumes : Int) { + SaveMediaListEntry(mediaId: $id, progress: $progress, progressVolumes : $progressVolumes, status: CURRENT) { + id + } + } + "# + } + + fn variables(&self) -> serde_json::Value { + json!({ + "id" : self.id, + "progress" : self.chapter_count, + "progressVolumes" : self.volume_number + }) + } + } + + #[derive(Debug, Deserialize, Serialize, Default)] + struct GetMangaByTitleResponse { + data: GetMangaByTitleData, + } + + #[derive(Debug, Deserialize, Serialize, Default)] + struct GetMangaByTitleData { + #[serde(rename = "Media")] + media: GetMangaByTitleMedia, + } + + #[derive(Debug, Deserialize, Serialize, Default)] + struct GetMangaByTitleMedia { + id: u32, + } + + #[derive(Debug)] + struct Anilist { + base_url: Url, + account_token: String, + client: Client, + } + + #[derive(Debug)] + struct AnilistToken { + id: String, + secret: String, + jwt: String, + } + + //https://anilist.co/api/v2/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code" + + impl Anilist { + pub fn new(base_url: Url) -> Self { + let mut default_headers = HeaderMap::new(); + + default_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + default_headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + + let client = Client::builder() + .default_headers(default_headers) + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + + Self { + base_url, + client, + account_token: "".to_string(), + } + } + + pub fn with_token(mut self, token: String) -> Self { + self.account_token = token; + self + } + } + + // it should: + // find which manga is reading + // find which chapter is reading + // update which manga is reading + // update the reading progress + impl From for MangaToTrack { + fn from(value: GetMangaByTitleResponse) -> Self { + Self { + id: value.data.media.id.to_string(), + } + } + } + + impl MangaTracker for Anilist { + async fn search_manga_by_title(&self, title: SearchTerm) -> Result, Box> { + let query = GetMangaByTitleQuery::new(title.get()); + + let response = self.client.post(self.base_url.clone()).body(query.into_body()).send().await?; + + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } + + let response: GetMangaByTitleResponse = response.json().await?; + + Ok(Some(MangaToTrack::from(response))) + } + + async fn mark_manga_as_read_with_chapter_count(&self, manga: MarkAsRead<'_>) -> Result<(), Box> { + let query = + MarkMangaAsReadQuery::new(manga.id.parse().unwrap_or(0), manga.chapter_number, manga.volume_number.unwrap_or(0)); + + self.client.post(self.base_url.clone()).body(query.into_body()).send().await?; + + Ok(()) + } + } + + #[test] + fn get_manga_by_title_query_is_built_as_expected() { + let expected = json!({ + "query" : r#" + query ($search: String) { + Media (search: $search, type: MANGA) { + id + } + } + "#, + "variables" : { + "search" : "some_title" + } + }); + + let query = GetMangaByTitleQuery::new("some_title"); + + let as_json = query.into_json(); + + assert_str_eq!(expected.get("query").unwrap().remove_whitespace(), as_json.get("query").unwrap().remove_whitespace()); + assert_eq!(expected.get("variables"), as_json.get("variables")); + } + + #[tokio::test] + async fn anilist_searches_a_manga_by_its_title() { + let server = MockServer::start_async().await; + let anilist = Anilist::new(server.base_url().parse().unwrap()); + + let expected_manga = MangaToTrack { + id: "123123".to_string(), + }; + + let expected_server_response: GetMangaByTitleResponse = GetMangaByTitleResponse { + data: GetMangaByTitleData { + media: GetMangaByTitleMedia { + id: expected_manga.id.parse().unwrap(), + }, + }, + }; + + let expected_body_sent = GetMangaByTitleQuery::new("some_title").into_json(); + + let request = server + .mock_async(|when, then| { + when.method(POST).json_body_obj(&expected_body_sent); + + then.status(200).json_body_obj(&expected_server_response); + }) + .await; + + let response = anilist + .search_manga_by_title(SearchTerm::trimmed_lowercased("some_title").unwrap()) + .await + .expect("should search manga by title"); + + request.assert_async().await; + + assert_eq!(expected_manga, response.expect("should not be none")) + } + + #[tokio::test] + async fn anilist_searches_a_manga_by_its_title_and_returns_none_if_not_found() { + let server = MockServer::start_async().await; + let anilist = Anilist::new(server.base_url().parse().unwrap()); + + let expected_body_sent = GetMangaByTitleQuery::new("some_title").into_json(); + + let request = server + .mock_async(|when, then| { + when.method(POST).json_body_obj(&expected_body_sent); + then.status(404); + }) + .await; + + let response = anilist + .search_manga_by_title(SearchTerm::trimmed_lowercased("some_title").unwrap()) + .await + .expect("should search manga by title"); + + request.assert_async().await; + assert!(response.is_none()) + } + + #[test] + fn mark_as_read_query_is_built_as_expected() { + let expected = json!({ + "query" : r#" + mutation ($id: Int, $progress: Int, $progressVolumes : Int) { + SaveMediaListEntry(mediaId: $id, progress: $progress, progressVolumes : $progressVolumes, status: CURRENT) { + id + } + } + "#, + "variables" : { + "id" : 123, + "progress" : 2, + "progressVolumes" : 1 + + } + }); + + let mark_as_read_query = MarkMangaAsReadQuery::new(123, 2, 1); + + let as_json = mark_as_read_query.into_json(); + + assert_str_eq!(expected.get("query").unwrap().remove_whitespace(), as_json.get("query").unwrap().remove_whitespace()); + + assert_eq!(expected.get("variables"), as_json.get("variables")); + } + + //#[tokio::test] + //async fn anilist_get_authorization_token() { + // let server = MockServer::start_async().await; + // let token = Uuid::new_v4().to_string(); + // let anilist = Anilist::new(server.base_url().parse().unwrap()).with_token(token); + //} + + // Todo! include authorization + #[tokio::test] + async fn anilist_marks_manga_as_reading_with_chapter_and_volume_count() { + let server = MockServer::start_async().await; + let anilist = Anilist::new(server.base_url().parse().unwrap()); + + let expected_body_sent = MarkMangaAsReadQuery::new(100, 2, 1).into_json(); + + let request = server + .mock_async(|when, then| { + when.method(POST).json_body_obj(&expected_body_sent); + then.status(200); + }) + .await; + + anilist + .mark_manga_as_read_with_chapter_count(MarkAsRead { + id: "100", + chapter_number: 2, + volume_number: Some(1), + }) + .await + .expect("should be marked as read"); + + request.assert_async().await; + } +} From fb93248350e77eb68cdcefecb7e74647415082eb Mon Sep 17 00:00:00 2001 From: josuebarretogit Date: Fri, 8 Nov 2024 23:18:41 -0500 Subject: [PATCH 02/14] test(anilist): add test for function to request access_token --- Cargo.toml | 2 +- src/backend/tracker/anilist.rs | 455 ++++++++++++++++++++------------- 2 files changed, 278 insertions(+), 179 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ff45898..fe445f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ clap = { version = "4.5.18", features = ["derive", "cargo"] } zip = "2.1.6" toml = "0.8.19" epub-builder = "0.7.4" +http = "1.0" [dev-dependencies] httpmock = "0.7.0-rc.1" @@ -48,7 +49,6 @@ pretty_assertions = "1.4.0" rusty-hook = "0.11.2" uuid = { version = "1.10.0", features = ["v4", "fast-rng"] } fake = "2.10.0" -http = "1.0" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59.0", features = ["Win32_Foundation", "Win32_System_Console", "Win32_UI_HiDpi"]} diff --git a/src/backend/tracker/anilist.rs b/src/backend/tracker/anilist.rs index f0f300d..475b276 100644 --- a/src/backend/tracker/anilist.rs +++ b/src/backend/tracker/anilist.rs @@ -1,224 +1,280 @@ static BASE_ANILIST_API_URL: &str = "https://graphql.anilist.co"; +static REDIRECT_URI: &str = "https://anilist.co/api/v2/oauth/pin"; +static GET_ACCESS_TOKEN_URL: &str = "https://anilist.co/api/v2/oauth/token"; +//https://anilist.co/api/v2/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code" + +use std::error::Error; +use std::time::Duration; + +use http::{HeaderMap, HeaderValue}; +use manga_tui::SearchTerm; +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::{Body, Client, StatusCode, Url}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::backend::tracker::{MangaToTrack, MangaTracker, MarkAsRead}; + +#[derive(Debug, Deserialize, Serialize)] +struct Manga { + id: String, + title: String, +} -#[cfg(test)] -mod tests { - use std::time::Duration; - - use http::header::{ACCEPT, CONTENT_TYPE}; - use http::{HeaderMap, HeaderValue, StatusCode}; - use httpmock::Method::POST; - use httpmock::MockServer; - use manga_tui::SearchTerm; - use pretty_assertions::{assert_eq, assert_str_eq}; - use reqwest::{Client, Url}; - use serde::{Deserialize, Serialize}; - use serde_json::json; - use uuid::Uuid; - - use super::*; - use crate::backend::tracker::{MangaToTrack, MangaTracker, MarkAsRead}; - - trait RemoveWhitespace { - /// Util trait for comparing two string without taking into account whitespaces and tabs (don't know a - /// better, smarter way xd) - fn remove_whitespace(&self) -> String; - } - - impl RemoveWhitespace for serde_json::Value { - fn remove_whitespace(&self) -> String { - self.to_string().split_whitespace().map(|line| line.trim()).collect() - } - } - - #[derive(Debug, Deserialize, Serialize)] - struct Manga { - id: String, - title: String, - } +#[derive(Debug, Deserialize, Serialize)] +struct GetMangaByTitleQuery<'a> { + title: &'a str, +} - #[derive(Debug, Deserialize, Serialize)] - struct GetMangaByTitleQuery<'a> { - title: &'a str, +/// The body that must be sent via POST request to anilist API +/// Composed of the `query` which models what to request +/// and `variables` to indicate the data that must be sent +pub trait GraphqlBody: Sized { + fn query(&self) -> &'static str; + fn variables(&self) -> serde_json::Value; + fn into_json(self) -> serde_json::Value { + json!( + { + "query" : self.query(), + "variables" : self.variables() + } + ) } - /// The body that must be sent via POST request to anilist API - /// Composed of the `query` which models what to request - /// and `variables` to indicate the data that must be sent - pub trait GraphqlBody: Sized { - fn query(&self) -> &'static str; - fn variables(&self) -> serde_json::Value; - fn into_json(self) -> serde_json::Value { - json!( - { - "query" : self.query(), - "variables" : self.variables() - } - ) - } - - fn into_body(self) -> String { - self.into_json().to_string() - } + fn into_body(self) -> String { + self.into_json().to_string() } +} - impl<'a> GetMangaByTitleQuery<'a> { - fn new(title: &'a str) -> Self { - Self { title } - } +impl<'a> GetMangaByTitleQuery<'a> { + fn new(title: &'a str) -> Self { + Self { title } } +} - impl<'a> GraphqlBody for GetMangaByTitleQuery<'a> { - fn query(&self) -> &'static str { - r#" +impl<'a> GraphqlBody for GetMangaByTitleQuery<'a> { + fn query(&self) -> &'static str { + r#" query ($search: String) { Media (search: $search, type: MANGA) { id } } "# - } - - fn variables(&self) -> serde_json::Value { - json!({ - "search" : self.title - }) - } } - /// set as reading, - /// mark chapter progress number - /// mark start date - /// mark volume progress as well - #[derive(Debug, Deserialize, Serialize)] - struct MarkMangaAsReadQuery { - id: u32, - chapter_count: u32, - volume_number: u32, + fn variables(&self) -> serde_json::Value { + json!({ + "search" : self.title + }) } +} - impl MarkMangaAsReadQuery { - fn new(id: u32, chapter_count: u32, volume_number: u32) -> Self { - Self { - id, - chapter_count, - volume_number, - } +#[derive(Debug, Deserialize, Serialize)] +struct MarkMangaAsReadQuery { + id: u32, + chapter_count: u32, + volume_number: u32, +} + +impl MarkMangaAsReadQuery { + fn new(id: u32, chapter_count: u32, volume_number: u32) -> Self { + Self { + id, + chapter_count, + volume_number, } } +} - impl GraphqlBody for MarkMangaAsReadQuery { - fn query(&self) -> &'static str { - r#" +impl GraphqlBody for MarkMangaAsReadQuery { + fn query(&self) -> &'static str { + r#" mutation ($id: Int, $progress: Int, $progressVolumes : Int) { SaveMediaListEntry(mediaId: $id, progress: $progress, progressVolumes : $progressVolumes, status: CURRENT) { id } } "# - } - - fn variables(&self) -> serde_json::Value { - json!({ - "id" : self.id, - "progress" : self.chapter_count, - "progressVolumes" : self.volume_number - }) - } } - #[derive(Debug, Deserialize, Serialize, Default)] - struct GetMangaByTitleResponse { - data: GetMangaByTitleData, + fn variables(&self) -> serde_json::Value { + json!({ + "id" : self.id, + "progress" : self.chapter_count, + "progressVolumes" : self.volume_number + }) } +} - #[derive(Debug, Deserialize, Serialize, Default)] - struct GetMangaByTitleData { - #[serde(rename = "Media")] - media: GetMangaByTitleMedia, - } +#[derive(Debug, Deserialize, Serialize, Default)] +struct GetMangaByTitleResponse { + data: GetMangaByTitleData, +} - #[derive(Debug, Deserialize, Serialize, Default)] - struct GetMangaByTitleMedia { - id: u32, - } +#[derive(Debug, Deserialize, Serialize, Default)] +struct GetMangaByTitleData { + #[serde(rename = "Media")] + media: GetMangaByTitleMedia, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +struct GetMangaByTitleMedia { + id: u32, +} - #[derive(Debug)] - struct Anilist { - base_url: Url, - account_token: String, - client: Client, +#[derive(Debug)] +struct Anilist { + base_url: Url, + access_token_url: Url, + access_token: String, + client: Client, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GetAnilistAccessTokenBody { + id: String, + secret: String, + code: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AnilistAccessTokenResponse { + access_token: String, +} + +impl GetAnilistAccessTokenBody { + fn new(id: &str, secret: &str, code: &str) -> Self { + Self { + id: id.to_string(), + secret: secret.to_string(), + code: code.to_string(), + } } +} - #[derive(Debug)] - struct AnilistToken { - id: String, - secret: String, - jwt: String, +impl GetAnilistAccessTokenBody { + fn into_json(self) -> serde_json::Value { + json!({ + "grant_type": "authorization_code", + "client_id": self.id, + "client_secret": self.secret, + "redirect_uri": REDIRECT_URI, + "code": self.code, + }) } +} - //https://anilist.co/api/v2/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code" +impl From for Body { + fn from(val: GetAnilistAccessTokenBody) -> Self { + val.into_json().to_string().into() + } +} - impl Anilist { - pub fn new(base_url: Url) -> Self { - let mut default_headers = HeaderMap::new(); +impl Anilist { + pub fn new(base_url: Url, access_token_url: Url) -> Self { + let mut default_headers = HeaderMap::new(); - default_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - default_headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + default_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + default_headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - let client = Client::builder() - .default_headers(default_headers) - .timeout(Duration::from_secs(10)) - .build() - .unwrap(); + let client = Client::builder() + .default_headers(default_headers) + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); - Self { - base_url, - client, - account_token: "".to_string(), - } + Self { + base_url, + client, + access_token_url, + access_token: "".to_string(), } + } + + pub fn with_token(mut self, token: String) -> Self { + self.access_token = token; + self + } - pub fn with_token(mut self, token: String) -> Self { - self.account_token = token; - self + async fn request_access_token(&mut self, body: GetAnilistAccessTokenBody) -> Result<(), Box> { + let response = self.client.post(self.access_token_url.clone()).body(body).send().await?; + + if response.status() == StatusCode::OK { + let response = dbg!(response); + let access_token: AnilistAccessTokenResponse = response.json().await?; + self.access_token = access_token.access_token; + Ok(()) + } else { + let response = dbg!(response); + Err(format!("could not request anilist access_token, more request details \n {:#?} ", response).into()) } } +} - // it should: - // find which manga is reading - // find which chapter is reading - // update which manga is reading - // update the reading progress - impl From for MangaToTrack { - fn from(value: GetMangaByTitleResponse) -> Self { - Self { - id: value.data.media.id.to_string(), - } +impl From for MangaToTrack { + fn from(value: GetMangaByTitleResponse) -> Self { + Self { + id: value.data.media.id.to_string(), } } +} - impl MangaTracker for Anilist { - async fn search_manga_by_title(&self, title: SearchTerm) -> Result, Box> { - let query = GetMangaByTitleQuery::new(title.get()); +impl MangaTracker for Anilist { + async fn search_manga_by_title(&self, title: SearchTerm) -> Result, Box> { + let query = GetMangaByTitleQuery::new(title.get()); - let response = self.client.post(self.base_url.clone()).body(query.into_body()).send().await?; + let response = self.client.post(self.base_url.clone()).body(query.into_body()).send().await?; - if response.status() == StatusCode::NOT_FOUND { - return Ok(None); - } + if response.status() == StatusCode::NOT_FOUND { + return Ok(None); + } - let response: GetMangaByTitleResponse = response.json().await?; + let response: GetMangaByTitleResponse = response.json().await?; - Ok(Some(MangaToTrack::from(response))) + Ok(Some(MangaToTrack::from(response))) + } + + async fn mark_manga_as_read_with_chapter_count(&self, manga: MarkAsRead<'_>) -> Result<(), Box> { + let query = + MarkMangaAsReadQuery::new(manga.id.parse().unwrap_or(0), manga.chapter_number, manga.volume_number.unwrap_or(0)); + + let response = self + .client + .post(self.base_url.clone()) + .body(query.into_body()) + .header(AUTHORIZATION, self.access_token.clone()) + .send() + .await?; + + if response.status() != StatusCode::OK { + return Err( + format!("could not sync reading status with anilist, more details of the response : \n {:#?} ", response).into() + ); } - async fn mark_manga_as_read_with_chapter_count(&self, manga: MarkAsRead<'_>) -> Result<(), Box> { - let query = - MarkMangaAsReadQuery::new(manga.id.parse().unwrap_or(0), manga.chapter_number, manga.volume_number.unwrap_or(0)); + Ok(()) + } +} - self.client.post(self.base_url.clone()).body(query.into_body()).send().await?; +#[cfg(test)] +mod tests { + use httpmock::Method::POST; + use httpmock::MockServer; + use pretty_assertions::{assert_eq, assert_str_eq}; + use uuid::Uuid; - Ok(()) + use super::*; + + trait RemoveWhitespace { + /// Util trait for comparing two string without taking into account whitespaces and tabs (don't know a + /// better, smarter way xd) + fn remove_whitespace(&self) -> String; + } + + impl RemoveWhitespace for serde_json::Value { + fn remove_whitespace(&self) -> String { + self.to_string().split_whitespace().map(|line| line.trim()).collect() } } @@ -248,7 +304,8 @@ mod tests { #[tokio::test] async fn anilist_searches_a_manga_by_its_title() { let server = MockServer::start_async().await; - let anilist = Anilist::new(server.base_url().parse().unwrap()); + let base_url: Url = server.base_url().parse().unwrap(); + let anilist = Anilist::new(base_url.clone(), base_url); let expected_manga = MangaToTrack { id: "123123".to_string(), @@ -285,7 +342,8 @@ mod tests { #[tokio::test] async fn anilist_searches_a_manga_by_its_title_and_returns_none_if_not_found() { let server = MockServer::start_async().await; - let anilist = Anilist::new(server.base_url().parse().unwrap()); + let base_url: Url = server.base_url().parse().unwrap(); + let anilist = Anilist::new(base_url.clone(), base_url); let expected_body_sent = GetMangaByTitleQuery::new("some_title").into_json(); @@ -332,33 +390,74 @@ mod tests { assert_eq!(expected.get("variables"), as_json.get("variables")); } - //#[tokio::test] - //async fn anilist_get_authorization_token() { - // let server = MockServer::start_async().await; - // let token = Uuid::new_v4().to_string(); - // let anilist = Anilist::new(server.base_url().parse().unwrap()).with_token(token); - //} + #[test] + fn get_access_token_query_is_built_correctly() { + let expected = json!({ + "grant_type": "authorization_code", + "client_id": "22248", + "client_secret": "some_secret", + "redirect_uri": "https://anilist.co/api/v2/oauth/pin", + "code": "some_code" + }); + + let query = GetAnilistAccessTokenBody::new("22248", "some_secret", "some_code"); + + assert_eq!(expected, query.into_json()); + } + + #[tokio::test] + async fn anilist_gets_authorization_token() { + let server = MockServer::start_async().await; + let token = Uuid::new_v4().to_string(); + let base_url: Url = server.base_url().parse().unwrap(); + let mut anilist = Anilist::new(base_url.clone(), base_url); + //let mut anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), GET_ACCESS_TOKEN_URL.parse().unwrap()); + + let expected_body_sent = GetAnilistAccessTokenBody::new("22248", "some_secret", "some_code"); + + let request = server + .mock_async(|when, then| { + when.method(POST).json_body_obj(&expected_body_sent.clone().into_json()); + then.status(200).json_body_obj(&AnilistAccessTokenResponse { + access_token: token.clone(), + }); + }) + .await; + + anilist.request_access_token(expected_body_sent).await.expect("should not fail"); + + request.assert_async().await; + + assert_eq!(token, anilist.access_token); + } - // Todo! include authorization #[tokio::test] async fn anilist_marks_manga_as_reading_with_chapter_and_volume_count() { let server = MockServer::start_async().await; - let anilist = Anilist::new(server.base_url().parse().unwrap()); - let expected_body_sent = MarkMangaAsReadQuery::new(100, 2, 1).into_json(); + let access_token = Uuid::new_v4().to_string(); + let base_url: Url = server.base_url().parse().unwrap(); + let anilist = Anilist::new(base_url.clone(), base_url).with_token(access_token.clone()); + //let anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), base_url).with_token(access_token.clone()); + + let manga_id = 86635; + let chapter = 10; + let volume_number = 1; + + let expected_body_sent = MarkMangaAsReadQuery::new(manga_id, chapter, volume_number).into_json(); let request = server .mock_async(|when, then| { - when.method(POST).json_body_obj(&expected_body_sent); + when.method(POST).header("Authorization", access_token).json_body_obj(&expected_body_sent); then.status(200); }) .await; anilist .mark_manga_as_read_with_chapter_count(MarkAsRead { - id: "100", - chapter_number: 2, - volume_number: Some(1), + id: &manga_id.to_string(), + chapter_number: chapter, + volume_number: Some(volume_number), }) .await .expect("should be marked as read"); From c26ab0eb855e5af94bc43fc2327f8bf2fb51e38c Mon Sep 17 00:00:00 2001 From: josuebarretogit Date: Sat, 23 Nov 2024 19:53:39 -0500 Subject: [PATCH 03/14] test(cli): add functions to save anilist credentials --- Cargo.lock | 80 ++++++++++++++ Cargo.toml | 1 + src/backend.rs | 1 + src/backend/secrets.rs | 8 ++ src/backend/secrets/anilist.rs | 25 +++++ src/backend/tracker.rs | 9 +- src/backend/tracker/anilist.rs | 47 +++++--- src/cli.rs | 190 +++++++++++++++++++++++++++++++++ src/main.rs | 39 +------ 9 files changed, 348 insertions(+), 52 deletions(-) create mode 100644 src/backend/secrets.rs create mode 100644 src/backend/secrets/anilist.rs diff --git a/Cargo.lock b/Cargo.lock index 7d877e6..f0cd4b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -893,6 +893,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "libc", + "libdbus-sys", + "winapi", +] + +[[package]] +name = "dbus-secret-service" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" +dependencies = [ + "dbus", + "futures-util", + "num", + "once_cell", + "rand", +] + [[package]] name = "deflate64" version = "0.1.9" @@ -2053,6 +2077,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa83d1ca02db069b5fbe94b23b584d588e989218310c9c15015bb5571ef1a94" +dependencies = [ + "byteorder", + "dbus-secret-service", + "security-framework", + "windows-sys 0.59.0", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -2117,6 +2153,15 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "libdbus-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.7" @@ -2236,6 +2281,7 @@ dependencies = [ "http 1.1.0", "httpmock", "image", + "keyring", "once_cell", "open", "pretty_assertions", @@ -2354,6 +2400,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.5" @@ -2364,6 +2424,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2390,6 +2459,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index fe445f7..2bba4a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ zip = "2.1.6" toml = "0.8.19" epub-builder = "0.7.4" http = "1.0" +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } [dev-dependencies] httpmock = "0.7.0-rc.1" diff --git a/src/backend.rs b/src/backend.rs index d45a885..e9e3912 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -15,6 +15,7 @@ pub mod error_log; pub mod fetch; pub mod filter; pub mod migration; +pub mod secrets; pub mod tracker; pub mod tui; diff --git a/src/backend/secrets.rs b/src/backend/secrets.rs new file mode 100644 index 0000000..ebf4343 --- /dev/null +++ b/src/backend/secrets.rs @@ -0,0 +1,8 @@ +pub mod anilist; + +use std::error::Error; + +//pub trait SecretStorage { +// fn set_secret(&self, secret_in_bytes: &[u8]) -> Result<(), Box>; +// fn get_secret(self) -> Result>; +//} diff --git a/src/backend/secrets/anilist.rs b/src/backend/secrets/anilist.rs new file mode 100644 index 0000000..47e7540 --- /dev/null +++ b/src/backend/secrets/anilist.rs @@ -0,0 +1,25 @@ +#[cfg(test)] +mod tests { + use clap::crate_name; + use uuid::Uuid; + + use super::*; + + #[derive(Debug)] + struct AnilistStorage; + + //#[test] + //fn it_stores_anilist_account_secrets() { + // let id = Uuid::new_v4().to_string(); + // let code = Uuid::new_v4().to_string(); + // let secret = Uuid::new_v4().to_string(); + // + // AnilistStorage::store(id, code, secret); + // + // let (id_stored, code_stored, secret_stored) = AnilistStorage::get_credentials(); + // + // assert_eq!(id, id_stored); + // assert_eq!(code, code_stored); + // assert_eq!(secret, secret_stored); + //} +} diff --git a/src/backend/tracker.rs b/src/backend/tracker.rs index b71c831..8c25d7e 100644 --- a/src/backend/tracker.rs +++ b/src/backend/tracker.rs @@ -1,3 +1,5 @@ +use std::error::Error; + use futures::Future; use manga_tui::SearchTerm; use serde::{Deserialize, Serialize}; @@ -26,5 +28,10 @@ pub trait MangaTracker { fn mark_manga_as_read_with_chapter_count( &self, manga: MarkAsRead<'_>, - ) -> impl Future>> + Send; + ) -> impl Future>> + Send; + + /// Used for the user to check wether or not the api key provided is valid + fn verify_authentication(&self) -> impl Future>> + Send { + async { Ok(false) } + } } diff --git a/src/backend/tracker/anilist.rs b/src/backend/tracker/anilist.rs index 475b276..5811120 100644 --- a/src/backend/tracker/anilist.rs +++ b/src/backend/tracker/anilist.rs @@ -15,12 +15,6 @@ use serde_json::json; use crate::backend::tracker::{MangaToTrack, MangaTracker, MarkAsRead}; -#[derive(Debug, Deserialize, Serialize)] -struct Manga { - id: String, - title: String, -} - #[derive(Debug, Deserialize, Serialize)] struct GetMangaByTitleQuery<'a> { title: &'a str, @@ -197,17 +191,16 @@ impl Anilist { self } - async fn request_access_token(&mut self, body: GetAnilistAccessTokenBody) -> Result<(), Box> { + async fn request_access_token(&self, body: GetAnilistAccessTokenBody) -> Result> { let response = self.client.post(self.access_token_url.clone()).body(body).send().await?; if response.status() == StatusCode::OK { let response = dbg!(response); let access_token: AnilistAccessTokenResponse = response.json().await?; - self.access_token = access_token.access_token; - Ok(()) + Ok(access_token.access_token) } else { let response = dbg!(response); - Err(format!("could not request anilist access_token, more request details \n {:#?} ", response).into()) + Err(format!("could not request anilist access token, more details about the request: \n {:#?} ", response).into()) } } } @@ -410,7 +403,7 @@ mod tests { let server = MockServer::start_async().await; let token = Uuid::new_v4().to_string(); let base_url: Url = server.base_url().parse().unwrap(); - let mut anilist = Anilist::new(base_url.clone(), base_url); + let anilist = Anilist::new(base_url.clone(), base_url); //let mut anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), GET_ACCESS_TOKEN_URL.parse().unwrap()); let expected_body_sent = GetAnilistAccessTokenBody::new("22248", "some_secret", "some_code"); @@ -424,13 +417,41 @@ mod tests { }) .await; - anilist.request_access_token(expected_body_sent).await.expect("should not fail"); + let token_requested = anilist.request_access_token(expected_body_sent).await.expect("should not fail"); request.assert_async().await; - assert_eq!(token, anilist.access_token); + assert_eq!(token_requested, token); } + //#[tokio::test] + //async fn anilist_checks_its_access_token_is_valid() { + // let server = MockServer::start_async().await; + // + // let token = Uuid::new_v4().to_string(); + // + // let base_url: Url = server.base_url().parse().unwrap(); + // let anilist = Anilist::new(base_url.clone(), base_url); + // //let mut anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), GET_ACCESS_TOKEN_URL.parse().unwrap()); + // + // let expected_body_sent = GetAnilistAccessTokenBody::new("22248", "some_secret", "some_code"); + // + // let request = server + // .mock_async(|when, then| { + // when.method(POST).json_body_obj(&expected_body_sent.clone().into_json()); + // then.status(200).json_body_obj(&AnilistAccessTokenResponse { + // access_token: token.clone(), + // }); + // }) + // .await; + // + // anilist.request_access_token(expected_body_sent).await.expect("should not fail"); + // + // request.assert_async().await; + // + // assert_eq!(token, anilist.access_token); + //} + #[tokio::test] async fn anilist_marks_manga_as_reading_with_chapter_and_volume_count() { let server = MockServer::start_async().await; diff --git a/src/cli.rs b/src/cli.rs index 9c2af95..08a2740 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,7 +1,20 @@ +use std::collections::HashMap; +use std::error::Error; +use std::io::{self, BufRead}; + use clap::{crate_version, Parser, Subcommand}; use strum::IntoEnumIterator; use crate::backend::filter::Languages; +use crate::backend::APP_DATA_DIR; +use crate::global::PREFERRED_LANGUAGE; + +fn read_input(mut input_reader: impl BufRead, message: &str) -> Result> { + println!("{message}"); + let mut input_provided = String::new(); + input_reader.read_line(&mut input_provided)?; + Ok(input_provided) +} #[derive(Subcommand)] pub enum Commands { @@ -11,6 +24,11 @@ pub enum Commands { #[arg(short, long)] set: Option, }, + + Anilist { + #[arg(short, long)] + init: bool, + }, } #[derive(Parser)] @@ -22,11 +40,183 @@ pub struct CliArgs { pub data_dir: bool, } +/// Abstraction of the location where secrets will be stored +pub trait SecretStorage { + fn save_secret(&mut self, secret_name: String, value: String) -> Result<(), Box>; + fn save_multiple_secrets(&mut self, values: HashMap) -> Result<(), Box>; + fn get_secret(&self, _secret_name: &str) -> Result> { + Err("not implemented".into()) + } +} + +struct AnilistCredentialsProvided<'a> { + pub code: &'a str, + pub secret: &'a str, + pub client_id: &'a str, +} + impl CliArgs { + pub fn new() -> Self { + Self { + command: None, + data_dir: false, + } + } + + pub fn with_command(mut self, command: Commands) -> Self { + self.command = Some(command); + self + } + pub fn print_available_languages() { println!("The available languages are:"); Languages::iter().filter(|lang| *lang != Languages::Unkown).for_each(|lang| { println!("{} {} | iso code : {}", lang.as_emoji(), lang.as_human_readable().to_lowercase(), lang.as_iso_code()) }); } + + fn save_anilist_credentials( + &self, + credentials: AnilistCredentialsProvided<'_>, + storage: &mut dyn SecretStorage, + ) -> Result<(), Box> { + storage.save_multiple_secrets(HashMap::from([ + ("anilist_client_id".to_string(), credentials.client_id.to_string()), + ("anilist_code".to_string(), credentials.code.to_string()), + ("anilist_secret".to_string(), credentials.secret.to_string()), + ]))?; + + Ok(()) + } + + pub fn init_anilist(self, mut input_reader: impl BufRead, storage: &mut dyn SecretStorage) -> Result<(), Box> { + let client_id = read_input(&mut input_reader, "Provide the client id")?; + let secret = read_input(&mut input_reader, "Provide the secret")?; + let code = read_input(&mut input_reader, "Provide the code")?; + + self.save_anilist_credentials( + AnilistCredentialsProvided { + code: &code, + secret: &secret, + client_id: &client_id, + }, + storage, + )?; + + println!("Anilist was correctly setup :D"); + + Ok(()) + } + + pub fn proccess_args(self) -> Result<(), Box> { + if self.data_dir { + let app_dir = APP_DATA_DIR.as_ref().unwrap(); + println!("{}", app_dir.to_str().unwrap()); + return Ok(()); + } + + match self.command { + Some(command) => match command { + Commands::Lang { print, set } => { + if print { + Self::print_available_languages(); + return Ok(()); + } + + match set { + Some(lang) => { + let try_lang = Languages::try_from_iso_code(lang.as_str()); + + if try_lang.is_none() { + println!( + "`{}` is not a valid ISO language code, run `{} lang --print` to list available languages and their ISO codes", + lang, + env!("CARGO_BIN_NAME") + ); + + return Ok(()); + } + + PREFERRED_LANGUAGE.set(try_lang.unwrap()).unwrap(); + }, + None => { + PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); + }, + } + Ok(()) + }, + + Commands::Anilist { init } => todo!(), + }, + None => { + PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); + Ok(()) + }, + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::error::Error; + use std::io::{BufReader, Cursor}; + + use pretty_assertions::assert_eq; + use uuid::Uuid; + use Commands::*; + + use super::*; + + #[derive(Default, Clone)] + struct MockStorage { + secrets_stored: HashMap, + } + + impl SecretStorage for MockStorage { + fn save_secret(&mut self, name: String, value: String) -> Result<(), Box> { + self.secrets_stored.insert(name, value); + Ok(()) + } + + fn save_multiple_secrets(&mut self, values: HashMap) -> Result<(), Box> { + for (key, name) in values { + self.save_secret(key, name)?; + } + Ok(()) + } + } + + #[test] + fn it_saves_anilist_account_credentials() { + let cli = CliArgs::new(); + let client_id_provided = Uuid::new_v4().to_string(); + let secret_provided = Uuid::new_v4().to_string(); + let code_provided = Uuid::new_v4().to_string(); + + let mut storage = MockStorage::default(); + + cli.save_anilist_credentials( + AnilistCredentialsProvided { + code: &code_provided, + secret: &secret_provided, + client_id: &client_id_provided, + }, + &mut storage, + ) + .expect("should not panic"); + + let (name, id) = storage.secrets_stored.get_key_value("anilist_client_id").unwrap(); + let (key_name2, secret) = storage.secrets_stored.get_key_value("anilist_secret").unwrap(); + let (key_name3, code) = storage.secrets_stored.get_key_value("anilist_code").unwrap(); + + assert_eq!("anilist_client_id", name); + assert_eq!(client_id_provided, *id); + + assert_eq!("anilist_secret", key_name2); + assert_eq!(secret_provided, *secret); + + assert_eq!("anilist_code", key_name3); + assert_eq!(code_provided, *code); + } } diff --git a/src/main.rs b/src/main.rs index 2f42c73..093977b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,13 +8,11 @@ use reqwest::StatusCode; use self::backend::database::Database; use self::backend::error_log::init_error_hooks; use self::backend::fetch::{MangadexClient, API_URL_BASE, COVER_IMG_URL_BASE, MANGADEX_CLIENT_INSTANCE}; -use self::backend::filter::Languages; use self::backend::migration::migrate_version; use self::backend::tui::{init, restore, run_app}; use self::backend::{build_data_dir, APP_DATA_DIR}; use self::cli::CliArgs; use self::config::MangaTuiConfig; -use self::global::PREFERRED_LANGUAGE; mod backend; mod cli; @@ -29,42 +27,7 @@ mod view; async fn main() -> Result<(), Box> { let cli_args = CliArgs::parse(); - if cli_args.data_dir { - let app_dir = APP_DATA_DIR.as_ref().unwrap(); - println!("{}", app_dir.to_str().unwrap()); - return Ok(()); - } - - match cli_args.command { - Some(command) => match command { - cli::Commands::Lang { print, set } => { - if print { - CliArgs::print_available_languages(); - return Ok(()); - } - - match set { - Some(lang) => { - let try_lang = Languages::try_from_iso_code(lang.as_str()); - - if try_lang.is_none() { - println!( - "`{}` is not a valid ISO language code, run `{} lang --print` to list available languages and their ISO codes", - lang, - env!("CARGO_BIN_NAME") - ); - - return Ok(()); - } - - PREFERRED_LANGUAGE.set(try_lang.unwrap()).unwrap() - }, - None => PREFERRED_LANGUAGE.set(Languages::default()).unwrap(), - } - }, - }, - None => PREFERRED_LANGUAGE.set(Languages::default()).unwrap(), - } + cli_args.proccess_args()?; match build_data_dir() { Ok(_) => {}, From d658b617c6566bad3cd95167b31ec754093b545c Mon Sep 17 00:00:00 2001 From: josuebarretogit Date: Fri, 13 Dec 2024 23:11:06 -0500 Subject: [PATCH 04/14] feat(anilist-secret): implement secret storage for aniliststorage --- Cargo.lock | 116 +++++++++++--------------- Cargo.toml | 5 +- src/backend/secrets.rs | 13 ++- src/backend/secrets/anilist.rs | 98 +++++++++++++++++----- src/cli.rs | 143 +++++++++++++++++++++++++-------- src/logger.rs | 37 +++++++++ src/main.rs | 4 +- 7 files changed, 291 insertions(+), 125 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0cd4b7..35cc886 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,6 +748,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +[[package]] +name = "colored" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +dependencies = [ + "lazy_static", + "windows-sys 0.48.0", +] + [[package]] name = "compact_str" version = "0.8.0" @@ -893,30 +903,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "dbus" -version = "0.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" -dependencies = [ - "libc", - "libdbus-sys", - "winapi", -] - -[[package]] -name = "dbus-secret-service" -version = "4.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" -dependencies = [ - "dbus", - "futures-util", - "num", - "once_cell", - "rand", -] - [[package]] name = "deflate64" version = "0.1.9" @@ -2084,7 +2070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa83d1ca02db069b5fbe94b23b584d588e989218310c9c15015bb5571ef1a94" dependencies = [ "byteorder", - "dbus-secret-service", + "linux-keyutils", "security-framework", "windows-sys 0.59.0", ] @@ -2153,15 +2139,6 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" -[[package]] -name = "libdbus-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" -dependencies = [ - "pkg-config", -] - [[package]] name = "libfuzzer-sys" version = "0.4.7" @@ -2194,6 +2171,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" +dependencies = [ + "bitflags 2.5.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -2234,6 +2221,7 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" dependencies = [ + "serde", "value-bag", ] @@ -2282,6 +2270,7 @@ dependencies = [ "httpmock", "image", "keyring", + "log", "once_cell", "open", "pretty_assertions", @@ -2292,6 +2281,7 @@ dependencies = [ "rusty-hook", "serde", "serde_json", + "simple_logger", "strum", "strum_macros", "throbber-widgets-tui", @@ -2400,20 +2390,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - [[package]] name = "num-bigint" version = "0.4.5" @@ -2424,15 +2400,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - [[package]] name = "num-conv" version = "0.1.0" @@ -2459,17 +2426,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-rational" version = "0.4.2" @@ -3407,6 +3363,18 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" +[[package]] +name = "simple_logger" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb" +dependencies = [ + "colored", + "log", + "time", + "windows-sys 0.48.0", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -3676,12 +3644,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde", "time-core", + "time-macros", ] [[package]] @@ -3690,6 +3660,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index 2bba4a2..3f501a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,9 @@ zip = "2.1.6" toml = "0.8.19" epub-builder = "0.7.4" http = "1.0" -keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } +keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } +log = { version = "0.4", features = ["std", "serde"] } +simple_logger = "5.0.0" [dev-dependencies] httpmock = "0.7.0-rc.1" @@ -53,4 +55,3 @@ fake = "2.10.0" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59.0", features = ["Win32_Foundation", "Win32_System_Console", "Win32_UI_HiDpi"]} - diff --git a/src/backend/secrets.rs b/src/backend/secrets.rs index ebf4343..3307e22 100644 --- a/src/backend/secrets.rs +++ b/src/backend/secrets.rs @@ -1,8 +1,13 @@ pub mod anilist; +use std::collections::HashMap; use std::error::Error; -//pub trait SecretStorage { -// fn set_secret(&self, secret_in_bytes: &[u8]) -> Result<(), Box>; -// fn get_secret(self) -> Result>; -//} +/// Abstraction of the location where secrets will be stored +pub trait SecretStorage { + fn save_secret>(&mut self, secret_name: T, value: T) -> Result<(), Box>; + fn save_multiple_secrets>(&mut self, values: HashMap) -> Result<(), Box>; + fn get_secret>(&self, _secret_name: T) -> Result, Box> { + Err("not implemented".into()) + } +} diff --git a/src/backend/secrets/anilist.rs b/src/backend/secrets/anilist.rs index 47e7540..53ac2a3 100644 --- a/src/backend/secrets/anilist.rs +++ b/src/backend/secrets/anilist.rs @@ -1,25 +1,87 @@ +use clap::crate_name; +use keyring::Entry; + +use super::SecretStorage; + +#[derive(Debug)] +struct AnilistStorage { + service_name: &'static str, +} + +impl AnilistStorage { + fn new() -> Self { + Self { + service_name: crate_name!(), + } + } +} + +impl SecretStorage for AnilistStorage { + fn save_secret>(&mut self, secret_name: T, value: T) -> Result<(), Box> { + let secret = Entry::new(self.service_name, &secret_name.into())?; + + let secret_as_string: String = value.into(); + + secret.set_secret(secret_as_string.as_bytes())?; + + Ok(()) + } + + fn get_secret>(&self, secret_name: T) -> Result, Box> { + let secret = Entry::new(self.service_name, &secret_name.into())?; + + match secret.get_secret() { + Ok(secret_as_bytes) => Ok(Some(String::from_utf8(secret_as_bytes)?)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(Box::new(e)), + } + } + + fn save_multiple_secrets>( + &mut self, + values: std::collections::HashMap, + ) -> Result<(), Box> { + for (name, value) in values { + self.save_secret(name, value)? + } + Ok(()) + } +} + #[cfg(test)] mod tests { - use clap::crate_name; + use std::collections::HashMap; + use std::error::Error; + use uuid::Uuid; use super::*; - #[derive(Debug)] - struct AnilistStorage; - - //#[test] - //fn it_stores_anilist_account_secrets() { - // let id = Uuid::new_v4().to_string(); - // let code = Uuid::new_v4().to_string(); - // let secret = Uuid::new_v4().to_string(); - // - // AnilistStorage::store(id, code, secret); - // - // let (id_stored, code_stored, secret_stored) = AnilistStorage::get_credentials(); - // - // assert_eq!(id, id_stored); - // assert_eq!(code, code_stored); - // assert_eq!(secret, secret_stored); - //} + #[test] + fn it_stores_anilist_account_secrets() -> Result<(), Box> { + let id = Uuid::new_v4().to_string(); + let code = Uuid::new_v4().to_string(); + let secret = Uuid::new_v4().to_string(); + + let mut anilist_storage = AnilistStorage::new(); + + anilist_storage + .save_multiple_secrets(HashMap::from([ + ("id".to_string(), id.clone()), + ("code".to_string(), code.clone()), + ("secret".to_string(), secret.clone()), + ])) + .unwrap(); + + let id_stored = anilist_storage.get_secret("id")?.unwrap(); + assert_eq!(id_stored, id); + + let code_stored = anilist_storage.get_secret("code")?.unwrap(); + assert_eq!(code_stored, code); + + let secret_stored = anilist_storage.get_secret("secret")?.unwrap(); + assert_eq!(secret_stored, secret); + + Ok(()) + } } diff --git a/src/cli.rs b/src/cli.rs index 08a2740..02d2bb4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,21 +1,37 @@ use std::collections::HashMap; use std::error::Error; -use std::io::{self, BufRead}; +use std::io::BufRead; use clap::{crate_version, Parser, Subcommand}; -use strum::IntoEnumIterator; +use strum::{Display, IntoEnumIterator}; use crate::backend::filter::Languages; +use crate::backend::secrets::SecretStorage; use crate::backend::APP_DATA_DIR; use crate::global::PREFERRED_LANGUAGE; +use crate::logger::ILogger; -fn read_input(mut input_reader: impl BufRead, message: &str) -> Result> { - println!("{message}"); +fn read_input(mut input_reader: impl BufRead, logger: &impl ILogger, message: &str) -> Result> { + logger.inform(message); let mut input_provided = String::new(); input_reader.read_line(&mut input_provided)?; Ok(input_provided) } +#[derive(Debug)] +pub enum AnilistStatus { + Setup, + MissigCredentials, +} + +#[derive(Subcommand)] +pub enum AnilistCommand { + /// setup anilist client to be able to sync reading progress + Init, + /// check wheter or not anilist is setup correctly + Status, +} + #[derive(Subcommand)] pub enum Commands { Lang { @@ -26,11 +42,29 @@ pub enum Commands { }, Anilist { - #[arg(short, long)] - init: bool, + #[command(subcommand)] + command: AnilistCommand, }, } +#[derive(Debug, Display)] +enum AnilistCredentials { + #[strum(to_string = "anilist_client_id")] + ClientId, + #[strum(to_string = "anilist_secret")] + Secret, + #[strum(to_string = "anilist_code")] + Code, + #[strum(to_string = "anilist_access_token")] + AccessToken, +} + +impl From for String { + fn from(value: AnilistCredentials) -> Self { + value.to_string() + } +} + #[derive(Parser)] #[command(version = crate_version!())] pub struct CliArgs { @@ -40,16 +74,7 @@ pub struct CliArgs { pub data_dir: bool, } -/// Abstraction of the location where secrets will be stored -pub trait SecretStorage { - fn save_secret(&mut self, secret_name: String, value: String) -> Result<(), Box>; - fn save_multiple_secrets(&mut self, values: HashMap) -> Result<(), Box>; - fn get_secret(&self, _secret_name: &str) -> Result> { - Err("not implemented".into()) - } -} - -struct AnilistCredentialsProvided<'a> { +pub struct AnilistCredentialsProvided<'a> { pub code: &'a str, pub secret: &'a str, pub client_id: &'a str, @@ -78,21 +103,26 @@ impl CliArgs { fn save_anilist_credentials( &self, credentials: AnilistCredentialsProvided<'_>, - storage: &mut dyn SecretStorage, + storage: &mut impl SecretStorage, ) -> Result<(), Box> { storage.save_multiple_secrets(HashMap::from([ - ("anilist_client_id".to_string(), credentials.client_id.to_string()), - ("anilist_code".to_string(), credentials.code.to_string()), - ("anilist_secret".to_string(), credentials.secret.to_string()), + (AnilistCredentials::ClientId.to_string(), credentials.client_id.to_string()), + (AnilistCredentials::Code.to_string(), credentials.code.to_string()), + (AnilistCredentials::Secret.to_string(), credentials.secret.to_string()), ]))?; Ok(()) } - pub fn init_anilist(self, mut input_reader: impl BufRead, storage: &mut dyn SecretStorage) -> Result<(), Box> { - let client_id = read_input(&mut input_reader, "Provide the client id")?; - let secret = read_input(&mut input_reader, "Provide the secret")?; - let code = read_input(&mut input_reader, "Provide the code")?; + pub fn init_anilist( + self, + mut input_reader: impl BufRead, + storage: &mut impl SecretStorage, + logger: impl ILogger, + ) -> Result<(), Box> { + let client_id = read_input(&mut input_reader, &logger, "Provide the client id")?; + let secret = read_input(&mut input_reader, &logger, "Provide the secret")?; + let code = read_input(&mut input_reader, &logger, "Provide the code")?; self.save_anilist_credentials( AnilistCredentialsProvided { @@ -103,11 +133,27 @@ impl CliArgs { storage, )?; - println!("Anilist was correctly setup :D"); + logger.inform("Anilist was correctly setup :D"); Ok(()) } + fn anilist_status(&self, storage: &impl SecretStorage) -> Result> { + let credentials = [ + storage.get_secret(AnilistCredentials::Code)?, + storage.get_secret(AnilistCredentials::Secret)?, + storage.get_secret(AnilistCredentials::ClientId)?, + ]; + + for credential in credentials { + if credential.is_none() { + return Ok(AnilistStatus::MissigCredentials); + } + } + + Ok(AnilistStatus::Setup) + } + pub fn proccess_args(self) -> Result<(), Box> { if self.data_dir { let app_dir = APP_DATA_DIR.as_ref().unwrap(); @@ -120,7 +166,7 @@ impl CliArgs { Commands::Lang { print, set } => { if print { Self::print_available_languages(); - return Ok(()); + std::process::exit(1) } match set { @@ -134,7 +180,7 @@ impl CliArgs { env!("CARGO_BIN_NAME") ); - return Ok(()); + std::process::exit(1) } PREFERRED_LANGUAGE.set(try_lang.unwrap()).unwrap(); @@ -146,7 +192,7 @@ impl CliArgs { Ok(()) }, - Commands::Anilist { init } => todo!(), + Commands::Anilist { command } => todo!(), }, None => { PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); @@ -160,7 +206,6 @@ impl CliArgs { mod tests { use std::collections::HashMap; use std::error::Error; - use std::io::{BufReader, Cursor}; use pretty_assertions::assert_eq; use uuid::Uuid; @@ -174,17 +219,21 @@ mod tests { } impl SecretStorage for MockStorage { - fn save_secret(&mut self, name: String, value: String) -> Result<(), Box> { - self.secrets_stored.insert(name, value); + fn save_secret>(&mut self, name: T, value: T) -> Result<(), Box> { + self.secrets_stored.insert(name.into(), value.into()); Ok(()) } - fn save_multiple_secrets(&mut self, values: HashMap) -> Result<(), Box> { + fn save_multiple_secrets>(&mut self, values: HashMap) -> Result<(), Box> { for (key, name) in values { self.save_secret(key, name)?; } Ok(()) } + + fn get_secret>(&self, secret_name: T) -> Result, Box> { + Ok(self.secrets_stored.get(&secret_name.into()).cloned()) + } } #[test] @@ -219,4 +268,34 @@ mod tests { assert_eq!("anilist_code", key_name3); assert_eq!(code_provided, *code); } + + #[test] + fn it_checks_anilist_is_setup() { + let cli = CliArgs::new(); + + let mut storage = MockStorage::default(); + + let not_setup = cli.anilist_status(&storage).unwrap(); + + assert!(!matches!(not_setup, AnilistStatus::Setup)); + + let client_id_provided = Uuid::new_v4().to_string(); + let secret_provided = Uuid::new_v4().to_string(); + let code_provided = Uuid::new_v4().to_string(); + + // after storing the credentials it should have a ok status + cli.save_anilist_credentials( + AnilistCredentialsProvided { + code: &code_provided, + secret: &secret_provided, + client_id: &client_id_provided, + }, + &mut storage, + ) + .expect("should not panic"); + + let is_setup = cli.anilist_status(&storage).unwrap(); + + assert!(matches!(is_setup, AnilistStatus::Setup)); + } } diff --git a/src/logger.rs b/src/logger.rs index 8b13789..cdf2dc9 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -1 +1,38 @@ +use std::error::Error; +use log::{error, info, warn}; + +/// Abstraction for printing to the console messages +pub trait ILogger { + fn inform(&self, message: impl AsRef) { + println!("{}", message.as_ref()); + } + + fn error(&self, error: Box) { + println!("ERROR | {}", error) + } + + fn warn(&self, warning: impl AsRef) { + println!("WARN | {}", warning.as_ref()) + } +} + +pub struct DefaultLogger; + +pub struct Logger; + +impl ILogger for DefaultLogger {} + +impl ILogger for Logger { + fn inform(&self, message: impl AsRef) { + info!("{}", message.as_ref()); + } + + fn warn(&self, warning: impl AsRef) { + warn!("{}", warning.as_ref()); + } + + fn error(&self, error: Box) { + error!("{error}"); + } +} diff --git a/src/main.rs b/src/main.rs index 093977b..58676f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,17 @@ #![allow(dead_code)] #![allow(deprecated)] +use backend::fetch::ApiClient; use clap::Parser; use ratatui::backend::CrosstermBackend; use reqwest::StatusCode; +use self::backend::build_data_dir; use self::backend::database::Database; use self::backend::error_log::init_error_hooks; use self::backend::fetch::{MangadexClient, API_URL_BASE, COVER_IMG_URL_BASE, MANGADEX_CLIENT_INSTANCE}; use self::backend::migration::migrate_version; use self::backend::tui::{init, restore, run_app}; -use self::backend::{build_data_dir, APP_DATA_DIR}; use self::cli::CliArgs; use self::config::MangaTuiConfig; @@ -25,6 +26,7 @@ mod view; #[tokio::main(flavor = "multi_thread", worker_threads = 7)] async fn main() -> Result<(), Box> { + simple_logger::init()?; let cli_args = CliArgs::parse(); cli_args.proccess_args()?; From b17056bfecf689892df6e5938fa7f32fa30a38a0 Mon Sep 17 00:00:00 2001 From: josuebarretogit Date: Thu, 19 Dec 2024 23:15:10 -0500 Subject: [PATCH 05/14] feat(Cli): add function check anilist status --- Cargo.lock | 105 ++++++++---- Cargo.toml | 2 +- src/backend/secrets.rs | 36 +++- src/backend/secrets/anilist.rs | 16 +- src/backend/tracker/anilist.rs | 186 +++++++++++++------- src/cli.rs | 300 +++++++++++++++++++++++---------- src/main.rs | 13 +- 7 files changed, 455 insertions(+), 203 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35cc886..8137cf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,6 +400,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -748,16 +759,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" -[[package]] -name = "colored" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" -dependencies = [ - "lazy_static", - "windows-sys 0.48.0", -] - [[package]] name = "compact_str" version = "0.8.0" @@ -1035,6 +1036,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "envmnt" version = "0.8.4" @@ -1490,6 +1504,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.3.9" @@ -1616,6 +1639,15 @@ dependencies = [ "url", ] +[[package]] +name = "humantime" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" +dependencies = [ + "quick-error 1.2.3", +] + [[package]] name = "hyper" version = "0.14.30" @@ -2274,6 +2306,7 @@ dependencies = [ "once_cell", "open", "pretty_assertions", + "pretty_env_logger", "ratatui", "ratatui-image", "reqwest", @@ -2281,7 +2314,6 @@ dependencies = [ "rusty-hook", "serde", "serde_json", - "simple_logger", "strum", "strum_macros", "throbber-widgets-tui", @@ -2806,6 +2838,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "pretty_env_logger" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "926d36b9553851b8b0005f1275891b392ee4d2d833852c417ed025477350fb9d" +dependencies = [ + "env_logger", + "log", +] + [[package]] name = "proc-macro2" version = "1.0.85" @@ -2843,6 +2885,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -2970,7 +3018,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -3363,18 +3411,6 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" -[[package]] -name = "simple_logger" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb" -dependencies = [ - "colored", - "log", - "time", - "windows-sys 0.48.0", -] - [[package]] name = "siphasher" version = "0.3.11" @@ -3586,6 +3622,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.61" @@ -3644,14 +3689,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde", "time-core", - "time-macros", ] [[package]] @@ -3660,16 +3703,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" -[[package]] -name = "time-macros" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tiny-keccak" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index 3f501a5..059caf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ epub-builder = "0.7.4" http = "1.0" keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } log = { version = "0.4", features = ["std", "serde"] } -simple_logger = "5.0.0" +pretty_env_logger = "0.4" [dev-dependencies] httpmock = "0.7.0-rc.1" diff --git a/src/backend/secrets.rs b/src/backend/secrets.rs index 3307e22..25cb733 100644 --- a/src/backend/secrets.rs +++ b/src/backend/secrets.rs @@ -6,8 +6,38 @@ use std::error::Error; /// Abstraction of the location where secrets will be stored pub trait SecretStorage { fn save_secret>(&mut self, secret_name: T, value: T) -> Result<(), Box>; - fn save_multiple_secrets>(&mut self, values: HashMap) -> Result<(), Box>; - fn get_secret>(&self, _secret_name: T) -> Result, Box> { - Err("not implemented".into()) + + fn save_multiple_secrets>(&mut self, values: HashMap) -> Result<(), Box> { + for (name, value) in values { + self.save_secret(name, value)? + } + Ok(()) + } + + fn remove_multiple_secrets>(&mut self, values: impl Iterator) -> Result<(), Box> { + for name in values { + self.remove_secret(name)? + } + Ok(()) } + + fn get_multiple_secrets>( + &self, + secrets_names: impl Iterator, + ) -> Result, Box> { + let mut secrets_collected: HashMap = HashMap::new(); + + for secrets in secrets_names { + let secret_to_find: String = secrets.into(); + if let Some(secret) = self.get_secret(secret_to_find.clone())? { + secrets_collected.insert(secret_to_find, secret); + } + } + + Ok(secrets_collected) + } + + fn get_secret>(&self, _secret_name: T) -> Result, Box>; + + fn remove_secret>(&mut self, secret_name: T) -> Result<(), Box>; } diff --git a/src/backend/secrets/anilist.rs b/src/backend/secrets/anilist.rs index 53ac2a3..0b89309 100644 --- a/src/backend/secrets/anilist.rs +++ b/src/backend/secrets/anilist.rs @@ -4,12 +4,12 @@ use keyring::Entry; use super::SecretStorage; #[derive(Debug)] -struct AnilistStorage { +pub struct AnilistStorage { service_name: &'static str, } impl AnilistStorage { - fn new() -> Self { + pub fn new() -> Self { Self { service_name: crate_name!(), } @@ -37,13 +37,11 @@ impl SecretStorage for AnilistStorage { } } - fn save_multiple_secrets>( - &mut self, - values: std::collections::HashMap, - ) -> Result<(), Box> { - for (name, value) in values { - self.save_secret(name, value)? - } + fn remove_secret>(&mut self, secret_name: T) -> Result<(), Box> { + let secret = Entry::new(self.service_name, secret_name.as_ref())?; + + secret.delete_credential()?; + Ok(()) } } diff --git a/src/backend/tracker/anilist.rs b/src/backend/tracker/anilist.rs index 5811120..03786dc 100644 --- a/src/backend/tracker/anilist.rs +++ b/src/backend/tracker/anilist.rs @@ -1,4 +1,4 @@ -static BASE_ANILIST_API_URL: &str = "https://graphql.anilist.co"; +pub static BASE_ANILIST_API_URL: &str = "https://graphql.anilist.co"; static REDIRECT_URI: &str = "https://anilist.co/api/v2/oauth/pin"; static GET_ACCESS_TOKEN_URL: &str = "https://anilist.co/api/v2/oauth/token"; //https://anilist.co/api/v2/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code" @@ -14,9 +14,10 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::backend::tracker::{MangaToTrack, MangaTracker, MarkAsRead}; +use crate::cli::AnilistTokenChecker; #[derive(Debug, Deserialize, Serialize)] -struct GetMangaByTitleQuery<'a> { +pub struct GetMangaByTitleQuery<'a> { title: &'a str, } @@ -65,7 +66,7 @@ impl<'a> GraphqlBody for GetMangaByTitleQuery<'a> { } #[derive(Debug, Deserialize, Serialize)] -struct MarkMangaAsReadQuery { +pub struct MarkMangaAsReadQuery { id: u32, chapter_count: u32, volume_number: u32, @@ -102,38 +103,83 @@ impl GraphqlBody for MarkMangaAsReadQuery { } #[derive(Debug, Deserialize, Serialize, Default)] -struct GetMangaByTitleResponse { +pub struct GetMangaByTitleResponse { data: GetMangaByTitleData, } #[derive(Debug, Deserialize, Serialize, Default)] -struct GetMangaByTitleData { +pub struct GetMangaByTitleData { #[serde(rename = "Media")] media: GetMangaByTitleMedia, } #[derive(Debug, Deserialize, Serialize, Default)] -struct GetMangaByTitleMedia { +pub struct GetMangaByTitleMedia { id: u32, } +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct GetUserIdQueryResponse { + data: GetUserIdQueryData, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct GetUserIdQueryData { + #[serde(rename = "User")] + user: UserId, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct UserId { + id: u32, +} + +#[derive(Debug, Deserialize, Serialize, Default, Clone)] +pub struct GetUserIdBody { + id: String, +} + +impl GetUserIdBody { + pub fn new(id: String) -> Self { + Self { id } + } +} + +impl GraphqlBody for GetUserIdBody { + fn query(&self) -> &'static str { + r#" + query User($id: Int) { + User(id: $id) { + id + } + } + "# + } + + fn variables(&self) -> serde_json::Value { + json!({ + "id" : self.id + }) + } +} + #[derive(Debug)] -struct Anilist { +pub struct Anilist { base_url: Url, - access_token_url: Url, access_token: String, + client_id: String, client: Client, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct GetAnilistAccessTokenBody { +pub struct GetAnilistAccessTokenBody { id: String, secret: String, code: String, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct AnilistAccessTokenResponse { +pub struct AnilistAccessTokenResponse { access_token: String, } @@ -166,7 +212,7 @@ impl From for Body { } impl Anilist { - pub fn new(base_url: Url, access_token_url: Url) -> Self { + pub fn new(base_url: Url) -> Self { let mut default_headers = HeaderMap::new(); default_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); @@ -181,7 +227,7 @@ impl Anilist { Self { base_url, client, - access_token_url, + client_id: String::default(), access_token: "".to_string(), } } @@ -191,17 +237,29 @@ impl Anilist { self } - async fn request_access_token(&self, body: GetAnilistAccessTokenBody) -> Result> { - let response = self.client.post(self.access_token_url.clone()).body(body).send().await?; + pub fn with_client_id(mut self, client_id: String) -> Self { + self.client_id = client_id; + self + } + + async fn check_credentials_are_valid(&self) -> Result> { + let body = GetUserIdBody::new(self.client_id.clone()); + + let body = body.into_body(); - if response.status() == StatusCode::OK { - let response = dbg!(response); - let access_token: AnilistAccessTokenResponse = response.json().await?; - Ok(access_token.access_token) - } else { - let response = dbg!(response); - Err(format!("could not request anilist access token, more details about the request: \n {:#?} ", response).into()) + let response = self + .client + .post(self.base_url.clone()) + .body(body) + .header(AUTHORIZATION, self.access_token.clone()) + .send() + .await?; + + if response.status() == StatusCode::UNAUTHORIZED || response.status() == StatusCode::BAD_REQUEST { + return Ok(false); } + + Ok(true) } } @@ -250,6 +308,12 @@ impl MangaTracker for Anilist { } } +impl AnilistTokenChecker for Anilist { + async fn verify_token(&self, token: String) -> Result> { + self.check_credentials_are_valid().await + } +} + #[cfg(test)] mod tests { use httpmock::Method::POST; @@ -294,11 +358,34 @@ mod tests { assert_eq!(expected.get("variables"), as_json.get("variables")); } + #[test] + fn get_user_id_query_is_built_as_expected() { + let expected = json!({ + "query" : r#" + query User($id: Int) { + User(id: $id) { + id + } + } + "#, + "variables" : { + "id" : 123.to_string() + } + }); + + let query = GetUserIdBody::new("123".to_string()); + + let as_json = query.into_json(); + + assert_str_eq!(expected.get("query").unwrap().remove_whitespace(), as_json.get("query").unwrap().remove_whitespace()); + assert_eq!(expected.get("variables"), as_json.get("variables")); + } + #[tokio::test] async fn anilist_searches_a_manga_by_its_title() { let server = MockServer::start_async().await; let base_url: Url = server.base_url().parse().unwrap(); - let anilist = Anilist::new(base_url.clone(), base_url); + let anilist = Anilist::new(base_url.clone()); let expected_manga = MangaToTrack { id: "123123".to_string(), @@ -336,7 +423,7 @@ mod tests { async fn anilist_searches_a_manga_by_its_title_and_returns_none_if_not_found() { let server = MockServer::start_async().await; let base_url: Url = server.base_url().parse().unwrap(); - let anilist = Anilist::new(base_url.clone(), base_url); + let anilist = Anilist::new(base_url.clone()); let expected_body_sent = GetMangaByTitleQuery::new("some_title").into_json(); @@ -399,58 +486,33 @@ mod tests { } #[tokio::test] - async fn anilist_gets_authorization_token() { + async fn anilist_checks_its_access_token_is_valid() { let server = MockServer::start_async().await; + let token = Uuid::new_v4().to_string(); + let user_id = 123.to_string(); + let base_url: Url = server.base_url().parse().unwrap(); - let anilist = Anilist::new(base_url.clone(), base_url); + let anilist = Anilist::new(base_url).with_token(token.clone()).with_client_id(user_id.clone()); //let mut anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), GET_ACCESS_TOKEN_URL.parse().unwrap()); - let expected_body_sent = GetAnilistAccessTokenBody::new("22248", "some_secret", "some_code"); + let expected_body_sent = GetUserIdBody::new(user_id.clone()); let request = server .mock_async(|when, then| { - when.method(POST).json_body_obj(&expected_body_sent.clone().into_json()); - then.status(200).json_body_obj(&AnilistAccessTokenResponse { - access_token: token.clone(), - }); + when.method(POST) + .header("Authorization", token) + .json_body_obj(&expected_body_sent.clone().into_json()); + then.status(200); }) .await; - let token_requested = anilist.request_access_token(expected_body_sent).await.expect("should not fail"); + let is_valid = anilist.check_credentials_are_valid().await.expect("should not fail"); request.assert_async().await; - assert_eq!(token_requested, token); - } - - //#[tokio::test] - //async fn anilist_checks_its_access_token_is_valid() { - // let server = MockServer::start_async().await; - // - // let token = Uuid::new_v4().to_string(); - // - // let base_url: Url = server.base_url().parse().unwrap(); - // let anilist = Anilist::new(base_url.clone(), base_url); - // //let mut anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), GET_ACCESS_TOKEN_URL.parse().unwrap()); - // - // let expected_body_sent = GetAnilistAccessTokenBody::new("22248", "some_secret", "some_code"); - // - // let request = server - // .mock_async(|when, then| { - // when.method(POST).json_body_obj(&expected_body_sent.clone().into_json()); - // then.status(200).json_body_obj(&AnilistAccessTokenResponse { - // access_token: token.clone(), - // }); - // }) - // .await; - // - // anilist.request_access_token(expected_body_sent).await.expect("should not fail"); - // - // request.assert_async().await; - // - // assert_eq!(token, anilist.access_token); - //} + assert!(is_valid); + } #[tokio::test] async fn anilist_marks_manga_as_reading_with_chapter_and_volume_count() { @@ -458,7 +520,7 @@ mod tests { let access_token = Uuid::new_v4().to_string(); let base_url: Url = server.base_url().parse().unwrap(); - let anilist = Anilist::new(base_url.clone(), base_url).with_token(access_token.clone()); + let anilist = Anilist::new(base_url.clone()).with_token(access_token.clone()); //let anilist = Anilist::new(BASE_ANILIST_API_URL.parse().unwrap(), base_url).with_token(access_token.clone()); let manga_id = 86635; diff --git a/src/cli.rs b/src/cli.rs index 02d2bb4..4b5b7bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,15 +1,19 @@ use std::collections::HashMap; use std::error::Error; +use std::future::Future; use std::io::BufRead; +use std::process::exit; use clap::{crate_version, Parser, Subcommand}; use strum::{Display, IntoEnumIterator}; use crate::backend::filter::Languages; +use crate::backend::secrets::anilist::AnilistStorage; use crate::backend::secrets::SecretStorage; +use crate::backend::tracker::anilist::{self, BASE_ANILIST_API_URL}; use crate::backend::APP_DATA_DIR; use crate::global::PREFERRED_LANGUAGE; -use crate::logger::ILogger; +use crate::logger::{self, ILogger, Logger}; fn read_input(mut input_reader: impl BufRead, logger: &impl ILogger, message: &str) -> Result> { logger.inform(message); @@ -18,21 +22,22 @@ fn read_input(mut input_reader: impl BufRead, logger: &impl ILogger, message: &s Ok(input_provided) } -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AnilistStatus { Setup, MissigCredentials, + InvalidAccessToken, } -#[derive(Subcommand)] +#[derive(Subcommand, Clone, Copy)] pub enum AnilistCommand { /// setup anilist client to be able to sync reading progress Init, /// check wheter or not anilist is setup correctly - Status, + Check, } -#[derive(Subcommand)] +#[derive(Subcommand, Clone)] pub enum Commands { Lang { #[arg(short, long)] @@ -47,7 +52,7 @@ pub enum Commands { }, } -#[derive(Debug, Display)] +#[derive(Debug, Display, Clone, Copy)] enum AnilistCredentials { #[strum(to_string = "anilist_client_id")] ClientId, @@ -65,7 +70,7 @@ impl From for String { } } -#[derive(Parser)] +#[derive(Parser, Clone)] #[command(version = crate_version!())] pub struct CliArgs { #[command(subcommand)] @@ -75,11 +80,16 @@ pub struct CliArgs { } pub struct AnilistCredentialsProvided<'a> { - pub code: &'a str, - pub secret: &'a str, + pub access_token: &'a str, pub client_id: &'a str, } +#[derive(Debug, Clone)] +pub struct Credentials { + pub access_token: String, + pub client_id: String, +} + impl CliArgs { pub fn new() -> Self { Self { @@ -100,34 +110,30 @@ impl CliArgs { }); } - fn save_anilist_credentials( - &self, - credentials: AnilistCredentialsProvided<'_>, - storage: &mut impl SecretStorage, - ) -> Result<(), Box> { - storage.save_multiple_secrets(HashMap::from([ - (AnilistCredentials::ClientId.to_string(), credentials.client_id.to_string()), - (AnilistCredentials::Code.to_string(), credentials.code.to_string()), - (AnilistCredentials::Secret.to_string(), credentials.secret.to_string()), - ]))?; - - Ok(()) - } - pub fn init_anilist( - self, + &self, mut input_reader: impl BufRead, storage: &mut impl SecretStorage, logger: impl ILogger, ) -> Result<(), Box> { - let client_id = read_input(&mut input_reader, &logger, "Provide the client id")?; - let secret = read_input(&mut input_reader, &logger, "Provide the secret")?; - let code = read_input(&mut input_reader, &logger, "Provide the code")?; + let client_id = read_input(&mut input_reader, &logger, "Provide your client id")?; + let client_id = client_id.trim(); + + let anilist_retrieve_access_token_url = + format!("https://anilist.co/api/v2/oauth/authorize?client_id={client_id}&response_type=token"); + + let open_in_browser_message = format!("Opening {anilist_retrieve_access_token_url} to get the access token "); + + logger.inform(open_in_browser_message); + + open::that(anilist_retrieve_access_token_url)?; + + let access_token = read_input(&mut input_reader, &logger, "Enter the access token")?; + let access_token = access_token.trim(); self.save_anilist_credentials( AnilistCredentialsProvided { - code: &code, - secret: &secret, + access_token: &access_token, client_id: &client_id, }, storage, @@ -138,35 +144,87 @@ impl CliArgs { Ok(()) } - fn anilist_status(&self, storage: &impl SecretStorage) -> Result> { - let credentials = [ - storage.get_secret(AnilistCredentials::Code)?, - storage.get_secret(AnilistCredentials::Secret)?, - storage.get_secret(AnilistCredentials::ClientId)?, - ]; + /// This method must check if both client_id and access_token are stored and they are not empty + fn anilist_check_credentials_stored(&self, storage: &impl SecretStorage) -> Result, Box> { + let credentials = + storage.get_multiple_secrets([AnilistCredentials::ClientId, AnilistCredentials::AccessToken].into_iter())?; - for credential in credentials { - if credential.is_none() { - return Ok(AnilistStatus::MissigCredentials); - } + let client_id = credentials.get(&AnilistCredentials::ClientId.to_string()).cloned(); + let access_token = credentials.get(&AnilistCredentials::AccessToken.to_string()).cloned(); + + match client_id.zip(access_token) { + Some((id, token)) => { + if id.is_empty() || token.is_empty() { + return Ok(None); + } + + Ok(Some(Credentials { + access_token: token, + client_id: id.parse().unwrap(), + })) + }, + None => Ok(None), } + } - Ok(AnilistStatus::Setup) + fn save_anilist_credentials( + &self, + credentials: AnilistCredentialsProvided<'_>, + storage: &mut impl SecretStorage, + ) -> Result<(), Box> { + storage.save_multiple_secrets(HashMap::from([ + (AnilistCredentials::AccessToken.to_string(), credentials.access_token.to_string()), + (AnilistCredentials::ClientId.to_string(), credentials.client_id.to_string()), + ]))?; + Ok(()) } - pub fn proccess_args(self) -> Result<(), Box> { + async fn check_anilist_token(&self, token_checker: &impl AnilistTokenChecker, token: String) -> Result> { + token_checker.verify_token(token).await + } + + async fn check_anilist_status(&self, logger: &impl ILogger) -> Result<(), Box> { + let storage = AnilistStorage::new(); + logger.inform("Checking client id and access token are stored"); + + let credentials_are_stored = self.anilist_check_credentials_stored(&storage)?; + if credentials_are_stored.is_none() { + logger.warn("The client id or the access token are empty, it is recommended you run `manga-tui anilist reset`"); + exit(0) + } + + let credentials_are_stored = credentials_are_stored.unwrap(); + logger.inform("Checking your access token is valid, this may take a while"); + + let anilist = anilist::Anilist::new(BASE_ANILIST_API_URL.parse().unwrap()) + .with_token(credentials_are_stored.access_token.clone()) + .with_client_id(credentials_are_stored.client_id); + + let access_token_is_valid = self.check_anilist_token(&anilist, credentials_are_stored.access_token).await?; + + if access_token_is_valid { + logger.inform("Everything is setup correctly :D"); + } else { + logger.error("The anilist access token is not valid, please run `manga-tui anilist reset`".into()); + exit(0) + } + + Ok(()) + } + + pub async fn proccess_args(self) -> Result<(), Box> { if self.data_dir { let app_dir = APP_DATA_DIR.as_ref().unwrap(); println!("{}", app_dir.to_str().unwrap()); return Ok(()); } - match self.command { + match &self.command { Some(command) => match command { Commands::Lang { print, set } => { - if print { + if *print { Self::print_available_languages(); - std::process::exit(1) + exit(0) } match set { @@ -180,7 +238,7 @@ impl CliArgs { env!("CARGO_BIN_NAME") ); - std::process::exit(1) + exit(0) } PREFERRED_LANGUAGE.set(try_lang.unwrap()).unwrap(); @@ -192,7 +250,22 @@ impl CliArgs { Ok(()) }, - Commands::Anilist { command } => todo!(), + Commands::Anilist { command } => match command { + AnilistCommand::Init => { + let mut storage = AnilistStorage::new(); + self.init_anilist(std::io::stdin().lock(), &mut storage, Logger)?; + exit(0) + }, + AnilistCommand::Check => { + let logger = Logger; + if let Err(e) = self.check_anilist_status(&logger).await { + logger.error(e); + exit(1); + } else { + exit(0) + } + }, + }, }, None => { PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); @@ -202,6 +275,10 @@ impl CliArgs { } } +pub trait AnilistTokenChecker { + fn verify_token(&self, token: String) -> impl Future>> + Send; +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -224,78 +301,125 @@ mod tests { Ok(()) } - fn save_multiple_secrets>(&mut self, values: HashMap) -> Result<(), Box> { - for (key, name) in values { - self.save_secret(key, name)?; - } - Ok(()) - } - fn get_secret>(&self, secret_name: T) -> Result, Box> { Ok(self.secrets_stored.get(&secret_name.into()).cloned()) } + + fn remove_secret>(&mut self, secret_name: T) -> Result<(), Box> { + match self.secrets_stored.remove(secret_name.as_ref()) { + Some(_val) => Ok(()), + None => Err("secret did not exist".into()), + } + } } #[test] - fn it_saves_anilist_account_credentials() { + fn it_saves_anilist_access_token_and_user_id() { let cli = CliArgs::new(); - let client_id_provided = Uuid::new_v4().to_string(); - let secret_provided = Uuid::new_v4().to_string(); - let code_provided = Uuid::new_v4().to_string(); + let acess_token = Uuid::new_v4().to_string(); + let user_id = "120398".to_string(); let mut storage = MockStorage::default(); cli.save_anilist_credentials( AnilistCredentialsProvided { - code: &code_provided, - secret: &secret_provided, - client_id: &client_id_provided, + access_token: &acess_token, + client_id: &user_id, }, &mut storage, ) - .expect("should not panic"); + .expect("should not fail"); - let (name, id) = storage.secrets_stored.get_key_value("anilist_client_id").unwrap(); - let (key_name2, secret) = storage.secrets_stored.get_key_value("anilist_secret").unwrap(); - let (key_name3, code) = storage.secrets_stored.get_key_value("anilist_code").unwrap(); + let (secret_name, token) = storage.secrets_stored.get_key_value("anilist_access_token").unwrap(); - assert_eq!("anilist_client_id", name); - assert_eq!(client_id_provided, *id); + assert_eq!("anilist_access_token", secret_name); + assert_eq!(acess_token, *token); - assert_eq!("anilist_secret", key_name2); - assert_eq!(secret_provided, *secret); + let (secret_name, value) = storage.secrets_stored.get_key_value("anilist_client_id").unwrap(); - assert_eq!("anilist_code", key_name3); - assert_eq!(code_provided, *code); + assert_eq!("anilist_client_id", secret_name); + assert_eq!(user_id.parse::().unwrap(), value.parse::().unwrap()); } #[test] - fn it_checks_anilist_is_setup() { + fn it_checks_anilist_credentials_are_stored() -> Result<(), Box> { let cli = CliArgs::new(); let mut storage = MockStorage::default(); - let not_setup = cli.anilist_status(&storage).unwrap(); + let not_stored = cli.anilist_check_credentials_stored(&storage)?; - assert!(!matches!(not_setup, AnilistStatus::Setup)); + assert!(not_stored.is_none()); - let client_id_provided = Uuid::new_v4().to_string(); - let secret_provided = Uuid::new_v4().to_string(); - let code_provided = Uuid::new_v4().to_string(); + storage.secrets_stored.insert(AnilistCredentials::AccessToken.to_string(), "".to_string()); - // after storing the credentials it should have a ok status - cli.save_anilist_credentials( - AnilistCredentialsProvided { - code: &code_provided, - secret: &secret_provided, - client_id: &client_id_provided, - }, - &mut storage, - ) - .expect("should not panic"); + storage.secrets_stored.insert(AnilistCredentials::ClientId.to_string(), "".to_string()); + + let stored_but_empty = cli.anilist_check_credentials_stored(&storage)?; + + assert!(stored_but_empty.is_none()); - let is_setup = cli.anilist_status(&storage).unwrap(); + storage + .secrets_stored + .insert(AnilistCredentials::AccessToken.to_string(), "some_access_token".to_string()); - assert!(matches!(is_setup, AnilistStatus::Setup)); + storage + .secrets_stored + .insert(AnilistCredentials::ClientId.to_string(), "some_client_id".to_string()); + + let stored = cli.anilist_check_credentials_stored(&storage)?; + + assert!(stored.is_some_and(|credentials| credentials.access_token == "some_access_token")); + + Ok(()) + } + + #[derive(Debug)] + struct AnilistCheckerTest { + should_fail: bool, + invalid_token: bool, + } + + impl AnilistCheckerTest { + fn succesful() -> Self { + Self { + should_fail: false, + invalid_token: false, + } + } + + fn failing() -> Self { + Self { + should_fail: true, + invalid_token: true, + } + } + } + impl AnilistTokenChecker for AnilistCheckerTest { + async fn verify_token(&self, _token: String) -> Result> { + if self.invalid_token { + return Ok(false); + } + + Ok(true) + } + } + + #[tokio::test] + async fn it_checks_acess_token_is_valid() -> Result<(), Box> { + let cli = CliArgs::new(); + + let anilist_checker = AnilistCheckerTest::succesful(); + + let token_is_valid = cli.check_anilist_token(&anilist_checker, "some_token".to_string()).await?; + + assert!(token_is_valid); + + let anilist_checker = AnilistCheckerTest::failing(); + + let token_is_valid = cli.check_anilist_token(&anilist_checker, "some_token".to_string()).await?; + + assert!(!token_is_valid); + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index 58676f8..3b5bc66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,10 @@ #![allow(dead_code)] #![allow(deprecated)] -use backend::fetch::ApiClient; use clap::Parser; +use http::StatusCode; +use log::LevelFilter; use ratatui::backend::CrosstermBackend; -use reqwest::StatusCode; use self::backend::build_data_dir; use self::backend::database::Database; @@ -26,10 +26,14 @@ mod view; #[tokio::main(flavor = "multi_thread", worker_threads = 7)] async fn main() -> Result<(), Box> { - simple_logger::init()?; + pretty_env_logger::formatted_builder() + .format_module_path(false) + .filter_level(LevelFilter::Info) + .init(); + let cli_args = CliArgs::parse(); - cli_args.proccess_args()?; + cli_args.proccess_args().await?; match build_data_dir() { Ok(_) => {}, @@ -78,5 +82,6 @@ async fn main() -> Result<(), Box> { init()?; run_app(CrosstermBackend::new(std::io::stdout()), MangadexClient::global().clone()).await?; restore()?; + Ok(()) } From cc60f698a1339c30263307b18557c404cd9ad605 Mon Sep 17 00:00:00 2001 From: josuebarretogit Date: Fri, 20 Dec 2024 22:59:49 -0500 Subject: [PATCH 06/14] feat(MangaPage): add function to update reading progress on anilist --- Cargo.lock | 79 ++++++++++++-- Cargo.toml | 2 +- src/backend/error_log.rs | 6 ++ src/backend/secrets/anilist.rs | 81 ++++++++++++++ src/backend/tracker.rs | 7 +- src/backend/tracker/anilist.rs | 2 +- src/backend/tui.rs | 4 +- src/cli.rs | 99 +++-------------- src/lib.rs | 5 + src/main.rs | 21 +++- src/view/app.rs | 50 ++++++--- src/view/pages/manga.rs | 187 ++++++++++++++++++++++++++------- src/view/tasks/manga.rs | 22 ++++ 13 files changed, 405 insertions(+), 160 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8137cf1..70f9ccb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -904,6 +904,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "libc", + "libdbus-sys", + "winapi", +] + +[[package]] +name = "dbus-secret-service" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" +dependencies = [ + "dbus", + "futures-util", + "num", + "once_cell", + "rand", +] + [[package]] name = "deflate64" version = "0.1.9" @@ -2102,7 +2126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa83d1ca02db069b5fbe94b23b584d588e989218310c9c15015bb5571ef1a94" dependencies = [ "byteorder", - "linux-keyutils", + "dbus-secret-service", "security-framework", "windows-sys 0.59.0", ] @@ -2171,6 +2195,15 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "libdbus-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.7" @@ -2203,16 +2236,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-keyutils" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" -dependencies = [ - "bitflags 2.5.0", - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -2422,6 +2445,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.5" @@ -2432,6 +2469,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2458,6 +2504,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" diff --git a/Cargo.toml b/Cargo.toml index 059caf1..d8ee1f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ zip = "2.1.6" toml = "0.8.19" epub-builder = "0.7.4" http = "1.0" -keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } log = { version = "0.4", features = ["std", "serde"] } pretty_env_logger = "0.4" diff --git a/src/backend/error_log.rs b/src/backend/error_log.rs index 2ed9227..c13386e 100644 --- a/src/backend/error_log.rs +++ b/src/backend/error_log.rs @@ -17,6 +17,12 @@ pub enum ErrorType<'a> { String(&'a str), } +impl<'a> From> for ErrorType<'a> { + fn from(value: Box) -> Self { + Self::Error(value) + } +} + fn get_error_logs_path() -> PathBuf { let path = AppDirectories::ErrorLogs.get_base_directory(); diff --git a/src/backend/secrets/anilist.rs b/src/backend/secrets/anilist.rs index 0b89309..99e5d2e 100644 --- a/src/backend/secrets/anilist.rs +++ b/src/backend/secrets/anilist.rs @@ -1,5 +1,8 @@ +use std::error::Error; + use clap::crate_name; use keyring::Entry; +use strum::Display; use super::SecretStorage; @@ -8,12 +11,57 @@ pub struct AnilistStorage { service_name: &'static str, } +#[derive(Debug, Display, Clone, Copy)] +pub enum AnilistCredentials { + #[strum(to_string = "anilist_client_id")] + ClientId, + #[strum(to_string = "anilist_secret")] + Secret, + #[strum(to_string = "anilist_code")] + Code, + #[strum(to_string = "anilist_access_token")] + AccessToken, +} + +impl From for String { + fn from(value: AnilistCredentials) -> Self { + value.to_string() + } +} + +#[derive(Debug, Clone)] +pub struct Credentials { + pub access_token: String, + pub client_id: String, +} + impl AnilistStorage { pub fn new() -> Self { Self { service_name: crate_name!(), } } + + pub fn anilist_check_credentials_stored(&self) -> Result, Box> { + let credentials = self.get_multiple_secrets([AnilistCredentials::ClientId, AnilistCredentials::AccessToken].into_iter())?; + + let client_id = credentials.get(&AnilistCredentials::ClientId.to_string()).cloned(); + let access_token = credentials.get(&AnilistCredentials::AccessToken.to_string()).cloned(); + + match client_id.zip(access_token) { + Some((id, token)) => { + if id.is_empty() || token.is_empty() { + return Ok(None); + } + + Ok(Some(Credentials { + access_token: token, + client_id: id.parse().unwrap(), + })) + }, + None => Ok(None), + } + } } impl SecretStorage for AnilistStorage { @@ -82,4 +130,37 @@ mod tests { Ok(()) } + + //#[test] + //fn it_checks_anilist_credentials_are_stored() -> Result<(), Box> { + // let cli = CliArgs::new(); + // + // let mut storage = MockStorage::default(); + // + // let not_stored = cli.anilist_check_credentials_stored(&storage)?; + // + // assert!(not_stored.is_none()); + // + // storage.secrets_stored.insert(AnilistCredentials::AccessToken.to_string(), "".to_string()); + // + // storage.secrets_stored.insert(AnilistCredentials::ClientId.to_string(), "".to_string()); + // + // let stored_but_empty = cli.anilist_check_credentials_stored(&storage)?; + // + // assert!(stored_but_empty.is_none()); + // + // storage + // .secrets_stored + // .insert(AnilistCredentials::AccessToken.to_string(), "some_access_token".to_string()); + // + // storage + // .secrets_stored + // .insert(AnilistCredentials::ClientId.to_string(), "some_client_id".to_string()); + // + // let stored = cli.anilist_check_credentials_stored(&storage)?; + // + // assert!(stored.is_some_and(|credentials| credentials.access_token == "some_access_token")); + // + // Ok(()) + //} } diff --git a/src/backend/tracker.rs b/src/backend/tracker.rs index 8c25d7e..244b25c 100644 --- a/src/backend/tracker.rs +++ b/src/backend/tracker.rs @@ -18,7 +18,7 @@ pub struct MarkAsRead<'a> { pub volume_number: Option, } -pub trait MangaTracker { +pub trait MangaTracker: Send + Clone + 'static { fn search_manga_by_title( &self, title: SearchTerm, @@ -29,9 +29,4 @@ pub trait MangaTracker { &self, manga: MarkAsRead<'_>, ) -> impl Future>> + Send; - - /// Used for the user to check wether or not the api key provided is valid - fn verify_authentication(&self) -> impl Future>> + Send { - async { Ok(false) } - } } diff --git a/src/backend/tracker/anilist.rs b/src/backend/tracker/anilist.rs index 03786dc..7d01a92 100644 --- a/src/backend/tracker/anilist.rs +++ b/src/backend/tracker/anilist.rs @@ -163,7 +163,7 @@ impl GraphqlBody for GetUserIdBody { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Anilist { base_url: Url, access_token: String, diff --git a/src/backend/tui.rs b/src/backend/tui.rs index dd95ac8..219767e 100644 --- a/src/backend/tui.rs +++ b/src/backend/tui.rs @@ -12,6 +12,7 @@ use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use super::fetch::ApiClient; +use super::tracker::MangaTracker; use crate::common::{Artist, Author}; use crate::view::app::{App, AppState, MangaToRead}; use crate::view::pages::reader::{ChapterToRead, SearchChapter, SearchMangaPanel}; @@ -110,10 +111,11 @@ fn get_picker() -> Option { pub async fn run_app( backend: impl Backend, api_client: impl ApiClient + SearchChapter + SearchMangaPanel, + manga_tracker: Option, ) -> Result<(), Box> { let mut terminal = Terminal::new(backend)?; - let mut app = App::new(api_client, get_picker()); + let mut app = App::new(api_client, manga_tracker, get_picker()); let tick_rate = std::time::Duration::from_millis(250); diff --git a/src/cli.rs b/src/cli.rs index 4b5b7bd..12aace3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,13 +7,14 @@ use std::process::exit; use clap::{crate_version, Parser, Subcommand}; use strum::{Display, IntoEnumIterator}; +use crate::backend::error_log::write_to_error_log; use crate::backend::filter::Languages; -use crate::backend::secrets::anilist::AnilistStorage; +use crate::backend::secrets::anilist::{AnilistCredentials, AnilistStorage}; use crate::backend::secrets::SecretStorage; use crate::backend::tracker::anilist::{self, BASE_ANILIST_API_URL}; use crate::backend::APP_DATA_DIR; use crate::global::PREFERRED_LANGUAGE; -use crate::logger::{self, ILogger, Logger}; +use crate::logger::{ILogger, Logger}; fn read_input(mut input_reader: impl BufRead, logger: &impl ILogger, message: &str) -> Result> { logger.inform(message); @@ -52,24 +53,6 @@ pub enum Commands { }, } -#[derive(Debug, Display, Clone, Copy)] -enum AnilistCredentials { - #[strum(to_string = "anilist_client_id")] - ClientId, - #[strum(to_string = "anilist_secret")] - Secret, - #[strum(to_string = "anilist_code")] - Code, - #[strum(to_string = "anilist_access_token")] - AccessToken, -} - -impl From for String { - fn from(value: AnilistCredentials) -> Self { - value.to_string() - } -} - #[derive(Parser, Clone)] #[command(version = crate_version!())] pub struct CliArgs { @@ -84,12 +67,6 @@ pub struct AnilistCredentialsProvided<'a> { pub client_id: &'a str, } -#[derive(Debug, Clone)] -pub struct Credentials { - pub access_token: String, - pub client_id: String, -} - impl CliArgs { pub fn new() -> Self { Self { @@ -145,27 +122,6 @@ impl CliArgs { } /// This method must check if both client_id and access_token are stored and they are not empty - fn anilist_check_credentials_stored(&self, storage: &impl SecretStorage) -> Result, Box> { - let credentials = - storage.get_multiple_secrets([AnilistCredentials::ClientId, AnilistCredentials::AccessToken].into_iter())?; - - let client_id = credentials.get(&AnilistCredentials::ClientId.to_string()).cloned(); - let access_token = credentials.get(&AnilistCredentials::AccessToken.to_string()).cloned(); - - match client_id.zip(access_token) { - Some((id, token)) => { - if id.is_empty() || token.is_empty() { - return Ok(None); - } - - Ok(Some(Credentials { - access_token: token, - client_id: id.parse().unwrap(), - })) - }, - None => Ok(None), - } - } fn save_anilist_credentials( &self, @@ -187,20 +143,20 @@ impl CliArgs { let storage = AnilistStorage::new(); logger.inform("Checking client id and access token are stored"); - let credentials_are_stored = self.anilist_check_credentials_stored(&storage)?; + let credentials_are_stored = storage.anilist_check_credentials_stored()?; if credentials_are_stored.is_none() { logger.warn("The client id or the access token are empty, it is recommended you run `manga-tui anilist reset`"); exit(0) } - let credentials_are_stored = credentials_are_stored.unwrap(); + let credentials = credentials_are_stored.unwrap(); logger.inform("Checking your access token is valid, this may take a while"); let anilist = anilist::Anilist::new(BASE_ANILIST_API_URL.parse().unwrap()) - .with_token(credentials_are_stored.access_token.clone()) - .with_client_id(credentials_are_stored.client_id); + .with_token(credentials.access_token.clone()) + .with_client_id(credentials.client_id); - let access_token_is_valid = self.check_anilist_token(&anilist, credentials_are_stored.access_token).await?; + let access_token_is_valid = self.check_anilist_token(&anilist, credentials.access_token).await?; if access_token_is_valid { logger.inform("Everything is setup correctly :D"); @@ -212,11 +168,12 @@ impl CliArgs { Ok(()) } + /// This method should only return `Ok(())` it the app should keep running, otherwise `exit` pub async fn proccess_args(self) -> Result<(), Box> { if self.data_dir { let app_dir = APP_DATA_DIR.as_ref().unwrap(); println!("{}", app_dir.to_str().unwrap()); - return Ok(()); + exit(0) } match &self.command { @@ -259,7 +216,8 @@ impl CliArgs { AnilistCommand::Check => { let logger = Logger; if let Err(e) = self.check_anilist_status(&logger).await { - logger.error(e); + logger.error(format!("Some error ocurred, more details \n {}", e).into()); + write_to_error_log(e.into()); exit(1); } else { exit(0) @@ -341,39 +299,6 @@ mod tests { assert_eq!(user_id.parse::().unwrap(), value.parse::().unwrap()); } - #[test] - fn it_checks_anilist_credentials_are_stored() -> Result<(), Box> { - let cli = CliArgs::new(); - - let mut storage = MockStorage::default(); - - let not_stored = cli.anilist_check_credentials_stored(&storage)?; - - assert!(not_stored.is_none()); - - storage.secrets_stored.insert(AnilistCredentials::AccessToken.to_string(), "".to_string()); - - storage.secrets_stored.insert(AnilistCredentials::ClientId.to_string(), "".to_string()); - - let stored_but_empty = cli.anilist_check_credentials_stored(&storage)?; - - assert!(stored_but_empty.is_none()); - - storage - .secrets_stored - .insert(AnilistCredentials::AccessToken.to_string(), "some_access_token".to_string()); - - storage - .secrets_stored - .insert(AnilistCredentials::ClientId.to_string(), "some_client_id".to_string()); - - let stored = cli.anilist_check_credentials_stored(&storage)?; - - assert!(stored.is_some_and(|credentials| credentials.access_token == "some_access_token")); - - Ok(()) - } - #[derive(Debug)] struct AnilistCheckerTest { should_fail: bool, diff --git a/src/lib.rs b/src/lib.rs index cabe3af..3e00537 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,11 @@ impl SearchTerm { if search_term.is_empty() { None } else { Some(Self(search_term.to_lowercase())) } } + pub fn trimmed(search_term: &str) -> Option { + let search_term = search_term.trim(); + if search_term.is_empty() { None } else { Some(Self(search_term.to_string())) } + } + pub fn get(&self) -> &str { &self.0 } diff --git a/src/main.rs b/src/main.rs index 3b5bc66..730a588 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,12 @@ #![allow(dead_code)] #![allow(deprecated)] +use backend::fetch::ApiClient; +use backend::secrets::anilist::AnilistStorage; +use backend::tracker::anilist::{Anilist, BASE_ANILIST_API_URL}; use clap::Parser; use http::StatusCode; -use log::LevelFilter; +use log::{info, LevelFilter}; use ratatui::backend::CrosstermBackend; use self::backend::build_data_dir; @@ -80,7 +83,21 @@ async fn main() -> Result<(), Box> { init_error_hooks()?; init()?; - run_app(CrosstermBackend::new(std::io::stdout()), MangadexClient::global().clone()).await?; + + let anilist_storage = AnilistStorage::new(); + + let anilist = match anilist_storage.anilist_check_credentials_stored()? { + Some(credentials) => Some( + Anilist::new(BASE_ANILIST_API_URL.parse().unwrap()) + .with_token(credentials.access_token) + .with_client_id(credentials.client_id), + ), + None => None, + }; + + info!("{}", anilist.is_some()); + + run_app(CrosstermBackend::new(std::io::stdout()), MangadexClient::global().clone(), anilist).await?; restore()?; Ok(()) diff --git a/src/view/app.rs b/src/view/app.rs index 47b7c8d..2df7fd8 100644 --- a/src/view/app.rs +++ b/src/view/app.rs @@ -15,6 +15,7 @@ use self::search::{InputMode, SearchPage}; use super::widgets::search::MangaItem; use super::widgets::Component; use crate::backend::fetch::ApiClient; +use crate::backend::tracker::MangaTracker; use crate::backend::tui::{Action, Events}; use crate::config::MangaTuiConfig; use crate::global::INSTRUCTIONS_STYLE; @@ -33,26 +34,27 @@ pub struct MangaToRead { pub list: ListOfChapters, } -pub struct App { +pub struct App { pub global_action_tx: UnboundedSender, pub global_action_rx: UnboundedReceiver, pub global_event_tx: UnboundedSender, pub global_event_rx: UnboundedReceiver, pub state: AppState, pub current_tab: SelectedPage, - pub manga_page: Option, + pub manga_page: Option>, pub manga_reader_page: Option>, pub search_page: SearchPage, pub home_page: Home, pub feed_page: Feed, api_client: T, + manga_tracker: Option, // The picker is what decides how big a image needs to be rendered depending on the user's // terminal font size and the graphics it supports // if the terminal doesn't support any graphics protocol the picker is `None` picker: Option, } -impl Component for App { +impl Component for App { type Actions = Action; fn render(&mut self, area: Rect, frame: &mut Frame<'_>) { @@ -109,8 +111,8 @@ impl Component for App { fn clean_up(&mut self) {} } -impl App { - pub fn new(api_client: T, picker: Option) -> Self { +impl App { + pub fn new(api_client: T, manga_tracker: Option, picker: Option) -> Self { let (global_action_tx, global_action_rx) = unbounded_channel::(); let (global_event_tx, global_event_rx) = unbounded_channel::(); @@ -130,6 +132,7 @@ impl App { global_action_rx, global_event_tx, global_event_rx, + manga_tracker, state: AppState::Runnning, api_client, } @@ -256,7 +259,8 @@ impl App { let manga_page = MangaPage::new(manga.manga, self.picker) .with_global_sender(self.global_event_tx.clone()) - .auto_bookmark(config.auto_bookmark); + .auto_bookmark(config.auto_bookmark) + .with_manga_tracker(self.manga_tracker.clone()); self.manga_page = Some(manga_page); } @@ -389,9 +393,29 @@ mod tests { use super::*; use crate::backend::fetch::fake_api_client::MockMangadexClient; use crate::backend::filter::Languages; + use crate::backend::tracker::MangaTracker; use crate::view::widgets::press_key; - fn tick(app: &mut App) { + #[derive(Debug, Clone, Copy)] + struct TrackerTest; + + impl MangaTracker for TrackerTest { + async fn mark_manga_as_read_with_chapter_count( + &self, + manga: crate::backend::tracker::MarkAsRead<'_>, + ) -> Result<(), Box> { + unimplemented!() + } + + async fn search_manga_by_title( + &self, + title: manga_tui::SearchTerm, + ) -> Result, Box> { + unimplemented!() + } + } + + fn tick(app: &mut App) { let max_amoun_ticks = 10; let mut count = 0; @@ -409,7 +433,7 @@ mod tests { #[test] fn goes_to_home_page() { - let mut app = App::new(MockMangadexClient::new(), None); + let mut app: App = App::new(MockMangadexClient::new(), None, None); let first_event = app.global_event_rx.blocking_recv().expect("no event was sent"); @@ -419,7 +443,7 @@ mod tests { #[test] fn can_go_to_search_page_by_pressing_i() { - let mut app = App::new(MockMangadexClient::new(), None); + let mut app: App = App::new(MockMangadexClient::new(), None, None); press_key(&mut app, KeyCode::Char('i')); @@ -430,7 +454,7 @@ mod tests { #[test] fn can_go_to_home_by_pressing_u() { - let mut app = App::new(MockMangadexClient::new(), None); + let mut app: App = App::new(MockMangadexClient::new(), None, None); app.go_search_page(); @@ -443,7 +467,7 @@ mod tests { #[test] fn can_go_to_feed_by_pressing_o() { - let mut app = App::new(MockMangadexClient::new(), None); + let mut app: App = App::new(MockMangadexClient::new(), None, None); press_key(&mut app, KeyCode::Char('o')); @@ -454,7 +478,7 @@ mod tests { #[test] fn doesnt_listen_to_key_events_if_it_is_downloading_all_chapters() { - let mut app = App::new(MockMangadexClient::new(), None).with_manga_page(); + let mut app: App = App::new(MockMangadexClient::new(), None, None).with_manga_page(); app.manga_page.as_mut().unwrap().start_downloading_all_chapters(); @@ -469,7 +493,7 @@ mod tests { #[test] fn reader_page_is_initialized_corectly() { - let mut app = App::new(MockMangadexClient::new(), Some(Picker::new((8, 8)))); + let mut app: App = App::new(MockMangadexClient::new(), None, Some(Picker::new((8, 8)))); let chapter_to_read = ChapterToRead { id: "some_id".to_string(), diff --git a/src/view/pages/manga.rs b/src/view/pages/manga.rs index 119d9ea..f8501b1 100644 --- a/src/view/pages/manga.rs +++ b/src/view/pages/manga.rs @@ -1,10 +1,12 @@ use std::error::Error; use std::future::Future; -use std::io::Cursor; +use std::io::{Cursor, Write}; +use std::u32; use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind}; use image::io::Reader; use image::DynamicImage; +use manga_tui::SearchTerm; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Style, Stylize}; @@ -19,7 +21,7 @@ use throbber_widgets_tui::{Throbber, ThrobberState}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tokio::task::JoinSet; -use super::reader::ChapterToRead; +use super::reader::{ChapterToRead, Volumes}; use crate::backend::api_responses::{ChapterResponse, MangaStatisticsResponse, Statistics}; use crate::backend::database::{ get_chapters_history_status, save_history, set_chapter_downloaded, Bookmark, ChapterBookmarked, ChapterToBookmark, @@ -29,6 +31,7 @@ use crate::backend::download::DownloadChapter; use crate::backend::error_log::{self, write_to_error_log, ErrorType}; use crate::backend::fetch::{ApiClient, MangadexClient, ITEMS_PER_PAGE_CHAPTERS}; use crate::backend::filter::Languages; +use crate::backend::tracker::MangaTracker; use crate::backend::tui::Events; use crate::backend::AppDirectories; use crate::common::Manga; @@ -37,7 +40,8 @@ use crate::global::{ERROR_STYLE, INSTRUCTIONS_STYLE}; use crate::utils::{set_status_style, set_tags_style}; use crate::view::app::MangaToRead; use crate::view::tasks::manga::{ - download_all_chapters, download_chapter_task, read_chapter, search_chapters_operation, ChapterArgs, DownloadAllChapters, + download_all_chapters, download_chapter_task, read_chapter, search_chapters_operation, update_reading_progress, ChapterArgs, + DownloadAllChapters, }; use crate::view::widgets::manga::{ ChapterItem, ChaptersListWidget, DownloadAllChaptersState, DownloadAllChaptersWidget, DownloadPhase, @@ -116,9 +120,10 @@ pub enum MangaPageEvents { /// id_chapter DownloadError(String), ReadError(String), - ReadSuccesful, + ReadSuccesful(String, u32, Option), LoadChapters(Option), LoadStatistics(Option), + TrackingFailed(String), } #[derive(Display, Default, Clone, Copy, Debug, PartialEq, Eq)] @@ -146,7 +151,7 @@ pub trait FetchChapterBookmarked: Send + Clone + 'static { ) -> impl Future>> + Send; } -pub struct MangaPage { +pub struct MangaPage { pub manga: Manga, image_state: Option>, cover_area: Rect, @@ -166,6 +171,7 @@ pub struct MangaPage { available_languages_state: ListState, is_list_languages_open: bool, download_all_chapters_state: DownloadAllChaptersState, + manga_tracker: Option, } struct MangaStatistics { @@ -187,7 +193,7 @@ struct ChaptersData { total_result: u32, } -impl MangaPage { +impl MangaPage { pub fn new(manga: Manga, picker: Option) -> Self { let (local_action_tx, local_action_rx) = mpsc::unbounded_channel::(); let (local_event_tx, local_event_rx) = mpsc::unbounded_channel::(); @@ -224,6 +230,7 @@ impl MangaPage { download_all_chapters_state: DownloadAllChaptersState::new(local_event_tx), chapter_language: chapter_language.unwrap_or(Languages::default()), cover_area, + manga_tracker: None, } } @@ -237,6 +244,11 @@ impl MangaPage { self } + pub fn with_manga_tracker(mut self, tracker: Option) -> Self { + self.manga_tracker = tracker; + self + } + fn render_cover(&mut self, area: Rect, buf: &mut Buffer) { let [cover_area, more_details_area] = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(area); @@ -640,9 +652,15 @@ impl MangaPage { match search_chapter_response { Ok((chapter, manga_to_read)) => { - tx.send(Events::ReadChapter(chapter, manga_to_read)).ok(); + local_tx + .send(MangaPageEvents::ReadSuccesful( + manga_to_read.title.clone(), + chapter.number as u32, + chapter.volume_number.clone(), + )) + .ok(); local_tx.send(MangaPageEvents::CheckChapterStatus).ok(); - local_tx.send(MangaPageEvents::ReadSuccesful).ok(); + tx.send(Events::ReadChapter(chapter, manga_to_read)).ok(); }, Err(e) => { write_to_error_log(error_log::ErrorType::Error( @@ -1121,6 +1139,26 @@ impl MangaPage { } } + fn track_manga(&self, tracker: Option, manga_title: String, chapter_number: u32, volume_number: Option) { + if let Some(tracker) = tracker { + let local_sender = self.local_event_tx.clone(); + tokio::spawn(async move { + let title = SearchTerm::trimmed(&manga_title); + if let Some(search_term) = title { + let response = update_reading_progress(search_term, chapter_number, volume_number, tracker).await; + if let Err(e) = response { + let error_message = format!( + "Failed to mark chapter {chapter_number} volume {} of manga {manga_title} more details of the error : {}", + volume_number.unwrap_or(0), + e + ); + local_sender.send(MangaPageEvents::TrackingFailed(error_message)).ok(); + } + } + }); + } + } + fn tick(&mut self) { if self.download_process_started() { self.download_all_chapters_state.tick(); @@ -1130,6 +1168,7 @@ impl MangaPage { while let Ok(background_event) = self.local_event_rx.try_recv() { match background_event { + MangaPageEvents::TrackingFailed(error_message) => write_to_error_log(ErrorType::String(&error_message)), MangaPageEvents::ReadChapterBookmarked(chapter, manga) => self.read_chapter_bookmarked(chapter, manga), MangaPageEvents::FetchBookmarkFailed => self.fetch_bookmarked_chapter_failed(), MangaPageEvents::FetchChapterBookmarked(chapter_bookmarked) => { @@ -1167,7 +1206,11 @@ impl MangaPage { )); } }, - MangaPageEvents::ReadSuccesful => self.state = PageState::DisplayingChapters, + MangaPageEvents::ReadSuccesful(manga_title, chapter_number, volume_number) => { + self.state = PageState::DisplayingChapters; + let volume = volume_number.map(|vol| vol.parse::().ok()).flatten(); + self.track_manga(self.manga_tracker.clone(), manga_title, chapter_number, volume); + }, } } } @@ -1188,7 +1231,7 @@ impl MangaPage { } } -impl Component for MangaPage { +impl Component for MangaPage { type Actions = MangaPageActions; fn render(&mut self, area: Rect, frame: &mut Frame<'_>) { @@ -1271,6 +1314,7 @@ impl Component for MangaPage { #[cfg(test)] mod test { + use std::borrow::BorrowMut; use std::time::Duration; use pretty_assertions::assert_eq; @@ -1280,6 +1324,7 @@ mod test { use super::*; use crate::backend::api_responses::ChapterData; use crate::backend::database::ChapterBookmarked; + use crate::backend::tracker::MangaTracker; use crate::view::widgets::press_key; fn get_chapters_response() -> ChapterResponse { @@ -1290,14 +1335,14 @@ mod test { } } - fn render_chapters(manga_page: &mut MangaPage) { + fn render_chapters(manga_page: &mut MangaPage) { let area = Rect::new(0, 0, 50, 50); let mut buf = Buffer::empty(area); let chapters = manga_page.chapters.as_mut().unwrap(); StatefulWidget::render(chapters.widget.clone(), area, &mut buf, &mut chapters.state); } - fn render_available_languages_list(manga_page: &mut MangaPage) { + fn render_available_languages_list(manga_page: &mut MangaPage) { let area = Rect::new(0, 0, 50, 50); let mut buf = Buffer::empty(area); let languages: Vec = manga_page.manga.available_languages.iter().map(|lang| lang.as_human_readable()).collect(); @@ -1305,10 +1350,57 @@ mod test { StatefulWidget::render(list, area, &mut buf, &mut manga_page.available_languages_state); } + #[derive(Debug, Clone)] + struct TrackerTest { + should_fail: bool, + title_manga_tracked: Option, + } + + impl TrackerTest { + fn new() -> Self { + Self { + title_manga_tracked: None, + should_fail: false, + } + } + + fn failing() -> Self { + Self { + should_fail: true, + title_manga_tracked: None, + } + } + + fn get_manga_tracked(self) -> Option { + self.title_manga_tracked + } + } + + impl MangaTracker for TrackerTest { + async fn search_manga_by_title( + &self, + _title: manga_tui::SearchTerm, + ) -> Result, Box> { + if self.should_fail { + return Err("".into()); + } + Ok(None) + } + + async fn mark_manga_as_read_with_chapter_count( + &self, + _manga: crate::backend::tracker::MarkAsRead<'_>, + ) -> Result<(), Box> { + if self.should_fail { + return Err("".into()); + } + Ok(()) + } + } #[tokio::test] async fn key_events_trigger_expected_actions() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); // Scroll down chapters list press_key(&mut manga_page, KeyCode::Char('j')); @@ -1425,18 +1517,18 @@ mod test { #[tokio::test] async fn listen_to_key_events_based_on_conditions() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); assert!(!manga_page.is_list_languages_open); + manga_page.handle_events(Events::Key(KeyCode::Char('j').into())); - press_key(&mut manga_page, KeyCode::Char('j')); let action = manga_page.local_action_rx.recv().await.unwrap(); assert_eq!(MangaPageActions::ScrollChapterDown, action); manga_page.toggle_available_languages_list(); + manga_page.handle_events(Events::Key(KeyCode::Char('j').into())); - press_key(&mut manga_page, KeyCode::Char('j')); let action = manga_page.local_action_rx.recv().await.unwrap(); assert_eq!(MangaPageActions::ScrollDownAvailbleLanguages, action); @@ -1444,13 +1536,14 @@ mod test { manga_page.toggle_available_languages_list(); manga_page.ask_download_all_chapters(); - press_key(&mut manga_page, KeyCode::Enter); + manga_page.handle_events(Events::Key(KeyCode::Enter.into())); + let action = manga_page.local_action_rx.recv().await.unwrap(); assert_eq!(MangaPageActions::ConfirmDownloadAll, action); } - async fn manga_page_initialized_correctly(manga_page: &mut MangaPage) { + async fn manga_page_initialized_correctly(manga_page: &mut MangaPage) { assert_eq!(manga_page.chapter_language, Languages::default()); assert_eq!(ChapterOrder::default(), manga_page.chapter_order); @@ -1468,14 +1561,14 @@ mod test { #[tokio::test] async fn handle_events() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); manga_page_initialized_correctly(&mut manga_page).await; } #[tokio::test] async fn handle_key_events() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); manga_page.state = PageState::SearchingChapters; manga_page.manga.available_languages = vec![Languages::default(), Languages::Spanish, Languages::German, Languages::Japanese]; @@ -1565,7 +1658,7 @@ mod test { #[test] fn doesnt_go_to_reader_if_picker_is_none() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); manga_page.load_chapters(Some(get_chapters_response())); @@ -1578,7 +1671,7 @@ mod test { #[test] fn doesnt_search_manga_cover_if_picker_is_none() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); manga_page.search_cover(); } @@ -1633,9 +1726,8 @@ mod test { #[tokio::test] async fn it_sends_event_to_bookmark_currently_selected_chapter_on_key_press_if_auto_bookmark_is_false() { - let mut manga_page = MangaPage::new(Manga::default(), None); - - press_key(&mut manga_page, KeyCode::Char('m')); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); + manga_page.handle_events(Events::Key(KeyCode::Char('m').into())); let result = timeout(Duration::from_millis(250), manga_page.local_action_rx.recv()) .await @@ -1647,16 +1739,15 @@ mod test { #[tokio::test] async fn it_does_not_send_event_bookmark_chapter_selected_if_auto_bookmark_is_true() { - let mut manga_page = MangaPage::new(Manga::default(), None).auto_bookmark(true); - - press_key(&mut manga_page, KeyCode::Char('m')); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None).auto_bookmark(true); + manga_page.handle_events(Events::Key(KeyCode::Char('m').into())); assert!(manga_page.local_action_rx.is_empty()); } #[test] fn it_bookmarks_currently_selected_chapter() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); let chapter_to_bookmark: ChapterItem = ChapterItem { id: "id_chapter_bookmarked".to_string(), @@ -1694,7 +1785,7 @@ mod test { #[test] fn it_only_bookmarks_one_chapter_at_a_time() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); let mut list_state = tui_widget_list::ListState::default(); @@ -1728,13 +1819,13 @@ mod test { } // clear all the events from initialization - fn flush_events(manga_page: &mut MangaPage) { + fn flush_events(manga_page: &mut MangaPage) { while manga_page.local_event_rx.try_recv().is_ok() {} } #[tokio::test] async fn it_sends_event_to_fetch_chapter_bookmarked_if_there_is_any() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); flush_events(&mut manga_page); @@ -1759,7 +1850,7 @@ mod test { #[test] fn it_is_set_as_bookmark_not_found_when_no_chapter_is_bookmarked() { - let mut manga_page = MangaPage::new(Manga::default(), None); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); flush_events(&mut manga_page); @@ -1806,9 +1897,8 @@ mod test { #[tokio::test] async fn it_send_event_to_read_bookmark_chapter_by_pressing_tab() { - let mut manga_page = MangaPage::new(Manga::default(), None); - - press_key(&mut manga_page, KeyCode::Tab); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None); + manga_page.handle_events(Events::Key(KeyCode::Tab.into())); let expected = MangaPageActions::GoToReadBookmarkedChapter; @@ -1823,7 +1913,7 @@ mod test { #[tokio::test] async fn it_sends_event_to_go_reader_page_from_bookmarked_chapter() { let (tx, _) = unbounded_channel(); - let mut manga_page = MangaPage::new(Manga::default(), None).with_global_sender(tx); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None).with_global_sender(tx); flush_events(&mut manga_page); let chapter_bookmarked: ChapterBookmarked = ChapterBookmarked { @@ -1857,7 +1947,7 @@ mod test { #[tokio::test] async fn it_sends_event_chapter_bookmarked_failed_to_fetch() { let (tx, _) = unbounded_channel(); - let mut manga_page = MangaPage::new(Manga::default(), None).with_global_sender(tx); + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), None).with_global_sender(tx); flush_events(&mut manga_page); @@ -1874,4 +1964,25 @@ mod test { assert_eq!(expected, result); } + + #[tokio::test] + async fn if_manga_tracking_fails_it_sends_event_to_write_error_to_error_log_file() -> Result<(), Box> { + let failing_tracker = TrackerTest::failing(); + + let mut manga_page: MangaPage = MangaPage::new(Manga::default(), Some(Picker::new((1, 2)))); + + flush_events(&mut manga_page); + + manga_page.track_manga(Some(failing_tracker), "manga-test".to_string(), 1, Some(3)); + + let expected = MangaPageEvents::TrackingFailed( + "Failed to mark chapter 1 volume 3 of manga manga-test more details of the error : ".to_string(), + ); + + let result = timeout(Duration::from_millis(500), manga_page.local_event_rx.recv()).await?.unwrap(); + + assert_eq!(expected, result); + + Ok(()) + } } diff --git a/src/view/tasks/manga.rs b/src/view/tasks/manga.rs index fb03711..67de6d3 100644 --- a/src/view/tasks/manga.rs +++ b/src/view/tasks/manga.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::thread::sleep; use std::time::{Duration, Instant}; +use manga_tui::SearchTerm; use reqwest::Url; use tokio::sync::mpsc::UnboundedSender; @@ -16,6 +17,8 @@ use crate::backend::fetch::ApiClient; #[cfg(not(test))] use crate::backend::fetch::MangadexClient; use crate::backend::filter::Languages; +use crate::backend::tracker::anilist::MarkMangaAsReadQuery; +use crate::backend::tracker::{MangaTracker, MarkAsRead}; use crate::config::{DownloadType, ImageQuality, MangaTuiConfig}; use crate::view::app::MangaToRead; use crate::view::pages::manga::{ChapterOrder, MangaPageEvents}; @@ -408,6 +411,25 @@ pub async fn read_chapter(chapter: &ChapterArgs) -> Result<(ChapterToRead, Manga Ok((chapter_to_read, manga_to_read)) } +pub async fn update_reading_progress( + manga_title: SearchTerm, + chapter_number: u32, + volume_number: Option, + tracker: impl MangaTracker + Send, +) -> Result<(), Box> { + let response = tracker.search_manga_by_title(manga_title).await?; + if let Some(manga) = response { + tracker + .mark_manga_as_read_with_chapter_count(MarkAsRead { + id: &manga.id, + chapter_number, + volume_number, + }) + .await?; + } + Ok(()) +} + #[cfg(test)] mod tests { use std::fs; From 8a9d5b32a4567dbf4d39e9fbbecf4e8840cf09da Mon Sep 17 00:00:00 2001 From: josuebarretogit Date: Mon, 23 Dec 2024 11:16:11 -0500 Subject: [PATCH 07/14] feat(ReaderPage): add anilist reading tracking --- src/backend/error_log.rs | 6 ++ src/backend/tracker.rs | 37 +++++++ src/backend/tracker/anilist.rs | 2 +- src/cli.rs | 12 +-- src/common.rs | 15 +++ src/global.rs | 62 ++++++++++++ src/main.rs | 38 +++---- src/view/app.rs | 62 ++++++------ src/view/pages/manga.rs | 125 ++++++----------------- src/view/pages/reader.rs | 177 +++++++++++++++++++++++++++------ src/view/tasks/manga.rs | 19 ---- 11 files changed, 354 insertions(+), 201 deletions(-) diff --git a/src/backend/error_log.rs b/src/backend/error_log.rs index c13386e..3e5f3d7 100644 --- a/src/backend/error_log.rs +++ b/src/backend/error_log.rs @@ -23,6 +23,12 @@ impl<'a> From> for ErrorType<'a> { } } +impl<'a> From for ErrorType<'a> { + fn from(value: String) -> Self { + Self::Error(value.into()) + } +} + fn get_error_logs_path() -> PathBuf { let path = AppDirectories::ErrorLogs.get_base_directory(); diff --git a/src/backend/tracker.rs b/src/backend/tracker.rs index 244b25c..198578c 100644 --- a/src/backend/tracker.rs +++ b/src/backend/tracker.rs @@ -30,3 +30,40 @@ pub trait MangaTracker: Send + Clone + 'static { manga: MarkAsRead<'_>, ) -> impl Future>> + Send; } + +pub async fn update_reading_progress( + manga_title: SearchTerm, + chapter_number: u32, + volume_number: Option, + tracker: impl MangaTracker + Send, +) -> Result<(), Box> { + let response = tracker.search_manga_by_title(manga_title).await?; + if let Some(manga) = response { + tracker + .mark_manga_as_read_with_chapter_count(MarkAsRead { + id: &manga.id, + chapter_number, + volume_number, + }) + .await?; + } + Ok(()) +} + +pub fn track_manga(tracker: Option, manga_title: String, chapter_number: u32, volume_number: Option, on_error: F) +where + T: MangaTracker, + F: Fn(String) -> () + Send + 'static, +{ + if let Some(tracker) = tracker { + tokio::spawn(async move { + let title = SearchTerm::trimmed(&manga_title); + if let Some(search_term) = title { + let response = update_reading_progress(search_term, chapter_number, volume_number, tracker).await; + if let Err(e) = response { + on_error(e.to_string()); + } + } + }); + } +} diff --git a/src/backend/tracker/anilist.rs b/src/backend/tracker/anilist.rs index 7d01a92..a2c0ddb 100644 --- a/src/backend/tracker/anilist.rs +++ b/src/backend/tracker/anilist.rs @@ -309,7 +309,7 @@ impl MangaTracker for Anilist { } impl AnilistTokenChecker for Anilist { - async fn verify_token(&self, token: String) -> Result> { + async fn verify_token(&self, _token: String) -> Result> { self.check_credentials_are_valid().await } } diff --git a/src/cli.rs b/src/cli.rs index 12aace3..486802a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,7 +5,7 @@ use std::io::BufRead; use std::process::exit; use clap::{crate_version, Parser, Subcommand}; -use strum::{Display, IntoEnumIterator}; +use strum::IntoEnumIterator; use crate::backend::error_log::write_to_error_log; use crate::backend::filter::Languages; @@ -23,13 +23,6 @@ fn read_input(mut input_reader: impl BufRead, logger: &impl ILogger, message: &s Ok(input_provided) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AnilistStatus { - Setup, - MissigCredentials, - InvalidAccessToken, -} - #[derive(Subcommand, Clone, Copy)] pub enum AnilistCommand { /// setup anilist client to be able to sync reading progress @@ -121,8 +114,6 @@ impl CliArgs { Ok(()) } - /// This method must check if both client_id and access_token are stored and they are not empty - fn save_anilist_credentials( &self, credentials: AnilistCredentialsProvided<'_>, @@ -244,7 +235,6 @@ mod tests { use pretty_assertions::assert_eq; use uuid::Uuid; - use Commands::*; use super::*; diff --git a/src/common.rs b/src/common.rs index 3586391..3dcb4dc 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,10 +1,14 @@ use std::collections::HashMap; +use std::error::Error; +use std::fmt::Display; +use manga_tui::SearchTerm; use ratatui::layout::Rect; use ratatui_image::protocol::Protocol; use strum::{Display, EnumIter}; use crate::backend::filter::Languages; +use crate::backend::tracker::{MangaTracker, MarkAsRead}; #[derive(Default, Clone, Debug, PartialEq)] pub struct Author { @@ -89,3 +93,14 @@ impl ImageState { self.image_state.is_empty() } } + +pub fn format_error_message_tracking_reading_history( + chapter: A, + manga_title: B, + error: C, +) -> String { + format!( + "Could not track reading progress of chapter : {} \n of manga : {}, more details about the error : \n ERROR | {}", + chapter, manga_title, error + ) +} diff --git a/src/global.rs b/src/global.rs index 075ccc3..7ed15c2 100644 --- a/src/global.rs +++ b/src/global.rs @@ -10,3 +10,65 @@ pub static INSTRUCTIONS_STYLE: Lazy