diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d56966f..5657cf7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,7 @@ on: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: full + RUST_TOOLCHAIN_VERSION : "nightly" jobs: check_code_format_and_lint: @@ -21,7 +22,10 @@ jobs: - name: setup toolchain uses: hecrj/setup-rust-action@v1 with: - rust-version: nightly-2024-06-25 + rust-version: ${{ env.RUST_TOOLCHAIN_VERSION }} + + - name: install_dependencies + run: sudo apt install libdbus-1-dev pkg-config - name: check-fmt run: cargo fmt --check @@ -29,6 +33,8 @@ jobs: - name: clippy run: cargo clippy -- -D warnings + + build_and_test: strategy: fail-fast: false @@ -44,7 +50,11 @@ jobs: - name: setup toolchain uses: hecrj/setup-rust-action@v1 with: - rust-version: nightly-2024-06-25 + rust-version: ${{ env.RUST_TOOLCHAIN_VERSION }} + + - name: Install dependencies (Linux only) + if: runner.os == 'Linux' + run: sudo apt install libdbus-1-dev pkg-config - name: check run: cargo check --locked @@ -53,7 +63,7 @@ jobs: run: cargo build --release --verbose - name: install cargo nextest - run: cargo install cargo-nextest + run: cargo install cargo-nextest@0.9.82 - name: test run: cargo nextest run --no-fail-fast @@ -78,5 +88,9 @@ jobs: with: use-flakehub: false + - name: install_dependencies + run: sudo apt install libdbus-1-dev pkg-config + + - name: Build default package run: nix build diff --git a/Cargo.lock b/Cargo.lock index 7d877e6..70f9ccb 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" @@ -893,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" @@ -1025,6 +1060,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" @@ -1480,6 +1528,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" @@ -1606,6 +1663,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" @@ -2053,6 +2119,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 +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" @@ -2189,6 +2276,7 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" dependencies = [ + "serde", "value-bag", ] @@ -2236,9 +2324,12 @@ dependencies = [ "http 1.1.0", "httpmock", "image", + "keyring", + "log", "once_cell", "open", "pretty_assertions", + "pretty_env_logger", "ratatui", "ratatui-image", "reqwest", @@ -2354,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" @@ -2364,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" @@ -2390,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" @@ -2770,6 +2895,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" @@ -2807,6 +2942,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" @@ -2934,7 +3075,7 @@ dependencies = [ "avif-serialize", "imgref", "loop9", - "quick-error", + "quick-error 2.0.1", "rav1e", "rayon", "rgb", @@ -3538,6 +3679,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" diff --git a/Cargo.toml b/Cargo.toml index ff45898..d8ee1f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ clap = { version = "4.5.18", features = ["derive", "cargo"] } 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"] } +log = { version = "0.4", features = ["std", "serde"] } +pretty_env_logger = "0.4" [dev-dependencies] httpmock = "0.7.0-rc.1" @@ -48,8 +52,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/manga-tui-config.toml b/manga-tui-config.toml index 6f857b8..a27c2bb 100644 --- a/manga-tui-config.toml +++ b/manga-tui-config.toml @@ -17,3 +17,8 @@ amount_pages = 5 # values : true, false # default : true auto_bookmark = true + +# Whether or not downloading a manga counts as reading it on services like anilist +# values : true, false +# default : false +track_reading_when_download = false diff --git a/src/backend.rs b/src/backend.rs index 6bdc815..bd86a34 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -15,6 +15,9 @@ pub mod error_log; pub mod fetch; pub mod filter; pub mod migration; +pub mod release_notifier; +pub mod secrets; +pub mod tracker; pub mod tui; #[derive(Display, EnumIter)] diff --git a/src/backend/error_log.rs b/src/backend/error_log.rs index 2ed9227..3e5f3d7 100644 --- a/src/backend/error_log.rs +++ b/src/backend/error_log.rs @@ -17,6 +17,18 @@ pub enum ErrorType<'a> { String(&'a str), } +impl<'a> From> for ErrorType<'a> { + fn from(value: Box) -> Self { + Self::Error(value) + } +} + +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/fetch.rs b/src/backend/fetch.rs index c627bea..aebde0f 100644 --- a/src/backend/fetch.rs +++ b/src/backend/fetch.rs @@ -15,6 +15,7 @@ use super::filter::Languages; use crate::backend::api_responses::OneChapterResponse; use crate::backend::filter::{Filters, IntoParam}; use crate::config::ImageQuality; +use crate::global::USER_AGENT; use crate::view::app::MangaToRead; use crate::view::pages::manga::{ChapterOrder, FetchChapterBookmarked}; use crate::view::pages::reader::{ChapterToRead, ListOfChapters, MangaPanel, SearchChapter, SearchMangaPanel}; @@ -96,16 +97,9 @@ impl MangadexClient { } pub fn new(api_url_base: Url, cover_img_url_base: Url) -> Self { - let user_agent = format!( - "manga-tui/{} ({}/{}/{})", - env!("CARGO_PKG_VERSION"), - std::env::consts::FAMILY, - std::env::consts::OS, - std::env::consts::ARCH - ); let client = Client::builder() .timeout(StdDuration::from_secs(10)) - .user_agent(user_agent) + .user_agent(&*USER_AGENT) .build() .unwrap(); diff --git a/src/backend/release_notifier.rs b/src/backend/release_notifier.rs new file mode 100644 index 0000000..ca8c994 --- /dev/null +++ b/src/backend/release_notifier.rs @@ -0,0 +1,144 @@ +use std::error::Error; +use std::time::Duration; + +use http::header::ACCEPT; +use http::{HeaderMap, HeaderValue, StatusCode}; +use reqwest::{Client, Url}; +use serde_json::Value; + +use crate::global::USER_AGENT; +use crate::logger::ILogger; + +#[derive(Debug)] +pub struct ReleaseNotifier { + github_url: Url, + client: Client, +} + +pub static GITHUB_URL: &str = "https://api.github.com/repos/josueBarretogit/manga-tui"; + +impl ReleaseNotifier { + pub fn new(github_url: Url) -> Self { + let mut default_headers = HeaderMap::new(); + + default_headers.insert("X-GitHub-Api-Version", HeaderValue::from_static("2022-11-28")); + default_headers.insert(ACCEPT, HeaderValue::from_static("application/vnd.github+json")); + + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .default_headers(default_headers) + .user_agent(&*USER_AGENT) + .build() + .unwrap(); + + Self { github_url, client } + } + + async fn get_latest_release(&self) -> Result> { + let endpoint = format!("{}/releases/latest", self.github_url); + + let response = self.client.get(endpoint).send().await?; + + if response.status() != StatusCode::OK { + return Err(format!( + "could not retrieve latest manga-tui version, more details about the api response : \n {:#?} ", + response + ) + .into()); + } + + let response: Value = response.json().await?; + + let response = response.get("name").cloned().unwrap(); + + Ok(response.as_str().unwrap().to_string()) + } + + /// returns `true` if there is a new version + fn new_version(&self, latest: &str, current: &str) -> bool { + latest != current + } + + pub async fn check_new_releases(self, logger: &impl ILogger) -> Result<(), Box> { + logger.inform("Checking for updates"); + + let latest_release = self.get_latest_release().await?; + let current_version = format!("v{}", env!("CARGO_PKG_VERSION")); + + if self.new_version(&latest_release, ¤t_version) { + let github_url = format!("https://github.com/josueBarretogit/manga-tui/releases/tag/{latest_release}"); + logger.inform(format!("There is a new version : {latest_release} to update go to the releases page: {github_url} ")); + tokio::time::sleep(Duration::from_secs(2)).await; + } else { + logger.inform("Up to date"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use httpmock::Method::GET; + use httpmock::MockServer; + use pretty_assertions::assert_str_eq; + use serde_json::json; + + use super::*; + + #[tokio::test] + async fn it_get_latest_version_from_github() -> Result<(), Box> { + let server = MockServer::start_async().await; + let notifier = ReleaseNotifier::new(server.base_url().parse()?); + + let release = "v0.4.0"; + + println!("{release}"); + let request = server + .mock_async(|when, then| { + when.method(GET) + .header("X-GitHub-Api-Version", "2022-11-28") + .header("Accept", "application/vnd.github+json") + .header("User-Agent", &*USER_AGENT) + .path_contains("releases/latest"); + then.status(200).json_body(json!({ "name" : release })); + }) + .await; + + let latest_release = notifier.get_latest_release().await?; + + request.assert_async().await; + + assert_str_eq!(release, latest_release); + + Ok(()) + } + + #[test] + fn it_compares_latest_version_from_current_version() -> Result<(), Box> { + let notifier = ReleaseNotifier::new("http:/localhost".parse()?); + + let latest_version = "v0.5.0"; + let current = "v0.4.0"; + + let new_version = notifier.new_version(latest_version, current); + + assert!(new_version); + + let latest_version = "v1.5.0"; + let current = "v0.4.2"; + + let new_version = notifier.new_version(latest_version, current); + + assert!(new_version); + + let latest_version = "v1.5.0"; + let current = "v1.5.0"; + + let new_version = notifier.new_version(latest_version, current); + + assert!(!new_version); + + Ok(()) + } +} diff --git a/src/backend/secrets.rs b/src/backend/secrets.rs new file mode 100644 index 0000000..25cb733 --- /dev/null +++ b/src/backend/secrets.rs @@ -0,0 +1,43 @@ +pub mod anilist; + +use std::collections::HashMap; +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> { + 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 new file mode 100644 index 0000000..7d6b7a5 --- /dev/null +++ b/src/backend/secrets/anilist.rs @@ -0,0 +1,143 @@ +use std::error::Error; + +use clap::crate_name; +use keyring::Entry; +use strum::Display; + +use super::SecretStorage; + +#[derive(Debug)] +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 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 { + 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 remove_secret>(&mut self, secret_name: T) -> Result<(), Box> { + let secret = Entry::new(self.service_name, secret_name.as_ref())?; + + secret.delete_credential()?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + //use std::collections::HashMap; + //use std::error::Error; + // + //use super::*; + // + // commented out because dont know how to mock keyring's functionality itself + + //#[test] + //fn it_stores_anilist_account_secrets() -> Result<(), Box> { + // let id = "some_string".to_string(); + // let code = "some_string".to_string(); + // let secret = "some_string".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()), + // ]))?; + // + // 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(()) + //} + + //#[test] + //fn it_retrieves_anilist_credential() -> Result<(), Box> { + // set_default_credential_builder(mock::default_credential_builder()); + // let anilist_storage = AnilistStorage::new(); + // + // let should_be_empty = anilist_storage.check_credentials_stored()?; + // + // assert!(should_be_empty.is_none()); + // + // Ok(()) + //} +} diff --git a/src/backend/tracker.rs b/src/backend/tracker.rs new file mode 100644 index 0000000..5501dd4 --- /dev/null +++ b/src/backend/tracker.rs @@ -0,0 +1,106 @@ +use std::error::Error; + +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, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct PlanToReadArgs<'a> { + pub id: &'a str, +} + +pub trait MangaTracker: Send + Clone + 'static { + 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; + + /// Implementors may require api key / account token in order to perform this operation + fn mark_manga_as_plan_to_read( + &self, + manga_to_plan_to_read: PlanToReadArgs<'_>, + ) -> impl Future>> + Send; +} + +async fn update_reading_progress( + manga_title: SearchTerm, + chapter_number: u32, + volume_number: Option, + tracker: impl MangaTracker, +) -> 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(()) +} + +async fn update_plan_to_read(manga_title: SearchTerm, tracker: impl MangaTracker) -> Result<(), Box> { + let response = tracker.search_manga_by_title(manga_title).await?; + if let Some(manga) = response { + tracker.mark_manga_as_plan_to_read(PlanToReadArgs { id: &manga.id }).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()); + } + } + }); + } +} + +pub fn track_manga_plan_to_read(tracker: Option, manga_title: String, 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_plan_to_read(search_term, 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 new file mode 100644 index 0000000..0bf9f10 --- /dev/null +++ b/src/backend/tracker/anilist.rs @@ -0,0 +1,655 @@ +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" + +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}; +use crate::cli::AnilistTokenChecker; +use crate::global::USER_AGENT; + +#[derive(Debug, Deserialize, Serialize)] +pub 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, sort : SEARCH_MATCH) { + id + } + } + "# + } + + fn variables(&self) -> serde_json::Value { + json!({ + "search" : self.title + }) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub 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)] +pub struct GetMangaByTitleResponse { + data: GetMangaByTitleData, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +pub struct GetMangaByTitleData { + #[serde(rename = "Media")] + media: GetMangaByTitleMedia, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +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 + }) + } +} + +struct MarkMangaAsPlanToRead(u32); + +impl MarkMangaAsPlanToRead { + pub fn new(id: u32) -> Self { + Self(id) + } +} + +impl GraphqlBody for MarkMangaAsPlanToRead { + fn query(&self) -> &'static str { + r#" + mutation ($id: Int) { + SaveMediaListEntry( + mediaId: $id + status: PLANNING + ) { + id + } + } + "# + } + + fn variables(&self) -> serde_json::Value { + json!({ "id" : self.0 }) + } +} + +#[derive(Debug, Clone)] +pub struct Anilist { + base_url: Url, + access_token: String, + client_id: String, + client: Client, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAnilistAccessTokenBody { + id: String, + secret: String, + code: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub 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(), + } + } +} + +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, + }) + } +} + +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(); + + 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)) + .user_agent(&*USER_AGENT) + .build() + .unwrap(); + + Self { + base_url, + client, + client_id: String::default(), + access_token: "".to_string(), + } + } + + pub fn with_token(mut self, token: String) -> Self { + self.access_token = token; + self + } + + 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(); + + 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) + } +} + +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)); + + 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() + ); + } + + Ok(()) + } + + async fn mark_manga_as_plan_to_read(&self, manga_to_plan_to_read: super::PlanToReadArgs<'_>) -> Result<(), Box> { + let query = MarkMangaAsPlanToRead::new(manga_to_plan_to_read.id.parse()?); + + 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 mark manga as plan to read in anilist, more details of the response : \n {:#?} ", + response + ) + .into()); + } + + Ok(()) + } +} + +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; + use httpmock::MockServer; + use pretty_assertions::{assert_eq, assert_str_eq}; + use uuid::Uuid; + + use super::*; + use crate::backend::tracker::PlanToReadArgs; + + 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() + } + } + + #[test] + fn get_manga_by_title_query_is_built_as_expected() { + let expected = json!({ + "query" : r#" + query ($search: String) { + Media (search: $search, type: MANGA, sort : SEARCH_MATCH) { + 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")); + } + + #[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()); + + 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 base_url: Url = server.base_url().parse().unwrap(); + let anilist = Anilist::new(base_url.clone()); + + 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")); + } + + #[test] + fn mark_as_plan_to_read_query_is_built_as_expected() { + let expected = json!({ + "query" : r#" + mutation ($id: Int) { + SaveMediaListEntry( + mediaId: $id + status: PLANNING + ) { + id + } + } + "#, + "variables" : { + "id" : 123, + } + }); + + let mark_as_plan_to_read_query = MarkMangaAsPlanToRead::new(123); + + let as_json = mark_as_plan_to_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")); + } + + #[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_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).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 = GetUserIdBody::new(user_id.clone()); + + let request = server + .mock_async(|when, then| { + when.method(POST) + .header("Authorization", token) + .json_body_obj(&expected_body_sent.clone().into_json()); + then.status(200); + }) + .await; + + let is_valid = anilist.check_credentials_are_valid().await.expect("should not fail"); + + request.assert_async().await; + + assert!(is_valid); + } + + #[tokio::test] + async fn anilist_marks_manga_as_reading_with_chapter_and_volume_count() { + let server = MockServer::start_async().await; + + let access_token = Uuid::new_v4().to_string(); + let base_url: Url = server.base_url().parse().unwrap(); + 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; + 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).header("Authorization", access_token).json_body_obj(&expected_body_sent); + then.status(200); + }) + .await; + + anilist + .mark_manga_as_read_with_chapter_count(MarkAsRead { + id: &manga_id.to_string(), + chapter_number: chapter, + volume_number: Some(volume_number), + }) + .await + .expect("should be marked as read"); + + request.assert_async().await; + } + + #[tokio::test] + async fn anilist_marks_manga_as_plan_to_read() { + let server = MockServer::start_async().await; + + let access_token = Uuid::new_v4().to_string(); + let base_url: Url = server.base_url().parse().unwrap(); + let anilist = Anilist::new(base_url.clone()).with_token(access_token.clone()); + let manga_id = "86635"; + + let expected_body_sent = MarkMangaAsPlanToRead::new(manga_id.parse().unwrap()).into_json(); + + let request = server + .mock_async(|when, then| { + when.method(POST).header("Authorization", access_token).json_body_obj(&expected_body_sent); + then.status(200); + }) + .await; + + anilist + .mark_manga_as_plan_to_read(PlanToReadArgs { id: &manga_id }) + .await + .expect("should not error"); + + request.assert_async().await; + } +} 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 9c2af95..3a36043 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,9 +1,37 @@ +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::IntoEnumIterator; +use crate::backend::error_log::write_to_error_log; use crate::backend::filter::Languages; +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::{ILogger, Logger}; + +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(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 + Check, +} -#[derive(Subcommand)] +#[derive(Subcommand, Clone)] pub enum Commands { Lang { #[arg(short, long)] @@ -11,9 +39,14 @@ pub enum Commands { #[arg(short, long)] set: Option, }, + + Anilist { + #[command(subcommand)] + command: AnilistCommand, + }, } -#[derive(Parser)] +#[derive(Parser, Clone)] #[command(version = crate_version!())] pub struct CliArgs { #[command(subcommand)] @@ -22,11 +55,286 @@ pub struct CliArgs { pub data_dir: bool, } +pub struct AnilistCredentialsProvided<'a> { + pub access_token: &'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()) }); } + + 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 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 { + access_token, + client_id, + }, + storage, + )?; + + logger.inform("Anilist was correctly setup :D"); + + Ok(()) + } + + 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(()) + } + + 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 = storage.check_credentials_stored()?; + if credentials_are_stored.is_none() { + logger.warn("The client id or the access token are empty, run `manga-tui anilist init`"); + exit(0) + } + + 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.access_token.clone()) + .with_client_id(credentials.client_id); + + 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"); + } else { + logger.error("The anilist access token is not valid, please run `manga-tui anilist init`".into()); + exit(0) + } + + 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()); + exit(0) + } + + match &self.command { + Some(command) => match command { + Commands::Lang { print, set } => { + if *print { + Self::print_available_languages(); + exit(0) + } + + 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") + ); + + exit(0) + } + + PREFERRED_LANGUAGE.set(try_lang.unwrap()).unwrap(); + }, + None => { + PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); + }, + } + Ok(()) + }, + + 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(format!("Some error ocurred, more details \n {}", e).into()); + write_to_error_log(e.into()); + exit(1); + } else { + exit(0) + } + }, + }, + }, + None => { + PREFERRED_LANGUAGE.set(Languages::default()).unwrap(); + Ok(()) + }, + } + } +} + +pub trait AnilistTokenChecker { + fn verify_token(&self, token: String) -> impl Future>> + Send; +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::error::Error; + + use pretty_assertions::assert_eq; + use uuid::Uuid; + + use super::*; + + #[derive(Default, Clone)] + struct MockStorage { + secrets_stored: HashMap, + } + + impl SecretStorage for MockStorage { + fn save_secret>(&mut self, name: T, value: T) -> Result<(), Box> { + self.secrets_stored.insert(name.into(), value.into()); + 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_access_token_and_user_id() { + let cli = CliArgs::new(); + let acess_token = Uuid::new_v4().to_string(); + let user_id = "120398".to_string(); + + let mut storage = MockStorage::default(); + + cli.save_anilist_credentials( + AnilistCredentialsProvided { + access_token: &acess_token, + client_id: &user_id, + }, + &mut storage, + ) + .expect("should not fail"); + + let (secret_name, token) = storage.secrets_stored.get_key_value("anilist_access_token").unwrap(); + + assert_eq!("anilist_access_token", secret_name); + assert_eq!(acess_token, *token); + + let (secret_name, value) = storage.secrets_stored.get_key_value("anilist_client_id").unwrap(); + + assert_eq!("anilist_client_id", secret_name); + assert_eq!(user_id.parse::().unwrap(), value.parse::().unwrap()); + } + + #[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/common.rs b/src/common.rs index 3586391..8800934 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; +use std::fmt::Display; use ratatui::layout::Rect; use ratatui_image::protocol::Protocol; use strum::{Display, EnumIter}; use crate::backend::filter::Languages; - #[derive(Default, Clone, Debug, PartialEq)] pub struct Author { pub id: String, @@ -89,3 +89,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/config.rs b/src/config.rs index 034cef6..3622175 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,7 @@ pub struct MangaTuiConfig { pub image_quality: ImageQuality, pub auto_bookmark: bool, pub amount_pages: u8, + pub track_reading_when_download: bool, } impl Default for MangaTuiConfig { @@ -53,6 +54,7 @@ impl Default for MangaTuiConfig { auto_bookmark: true, download_type: DownloadType::default(), image_quality: ImageQuality::default(), + track_reading_when_download: false, } } } @@ -122,6 +124,18 @@ auto_bookmark = true )?; } + if !existing_config.contains_key("track_reading_when_download") { + file.write_all( + " +# Whether or not downloading a manga counts as reading it on services like anilist +# values : true, false +# default : false +track_reading_when_download = false +" + .as_bytes(), + )?; + } + let mut contents = String::new(); file.read_to_string(&mut contents)?; @@ -183,6 +197,11 @@ mod tests { # values : 0-255 #default : 5 amount_pages = 5 + +# Whether or not downloading a manga counts as reading it on services like anilist +# values : true, false +# default : false +track_reading_when_download = false "#; MangaTuiConfig::add_missing_fields(&mut test_file, current_contents.parse::().unwrap()).unwrap(); @@ -207,6 +226,11 @@ auto_bookmark = true # values : 0-255 #default : 5 amount_pages = 5 + +# Whether or not downloading a manga counts as reading it on services like anilist +# values : true, false +# default : false +track_reading_when_download = false "#; let mut test_file = Cursor::new(Vec::new()); @@ -223,6 +247,11 @@ auto_bookmark = true # values : 0-255 #default : 5 amount_pages = 5 + +# Whether or not downloading a manga counts as reading it on services like anilist +# values : true, false +# default : false +track_reading_when_download = false "#; MangaTuiConfig::add_missing_fields(&mut test_file, current_contents.parse::
().unwrap()).unwrap(); diff --git a/src/global.rs b/src/global.rs index 075ccc3..dc3760e 100644 --- a/src/global.rs +++ b/src/global.rs @@ -1,3 +1,5 @@ +use std::sync::LazyLock; + use once_cell::sync::{Lazy, OnceCell}; use ratatui::style::{Style, Stylize}; @@ -10,3 +12,82 @@ pub static INSTRUCTIONS_STYLE: Lazy