Skip to content

Commit

Permalink
feat(SearchPage): Add plan to read functionality on anilist
Browse files Browse the repository at this point in the history
  • Loading branch information
josueBarretogit committed Dec 31, 2024
1 parent f17396b commit f04afc6
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 12 deletions.
39 changes: 38 additions & 1 deletion src/backend/tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ pub struct MarkAsRead<'a> {
pub volume_number: Option<u32>,
}

#[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,
Expand All @@ -29,9 +34,15 @@ pub trait MangaTracker: Send + Clone + 'static {
&self,
manga: MarkAsRead<'_>,
) -> impl Future<Output = Result<(), Box<dyn Error>>> + 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<Output = Result<(), Box<dyn Error>>> + Send;
}

pub async fn update_reading_progress(
async fn update_reading_progress(
manga_title: SearchTerm,
chapter_number: u32,
volume_number: Option<u32>,
Expand All @@ -50,6 +61,14 @@ pub async fn update_reading_progress(
Ok(())
}

async fn update_plan_to_read(manga_title: SearchTerm, tracker: impl MangaTracker) -> Result<(), Box<dyn Error>> {
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<T, F>(tracker: Option<T>, manga_title: String, chapter_number: u32, volume_number: Option<u32>, on_error: F)
where
T: MangaTracker,
Expand All @@ -67,3 +86,21 @@ where
});
}
}

pub fn track_manga_plan_to_read<T, F>(tracker: Option<T>, 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());
}
}
});
}
}
103 changes: 103 additions & 0 deletions src/backend/tracker/anilist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,33 @@ impl GraphqlBody for GetUserIdBody {
}
}

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,
Expand Down Expand Up @@ -306,6 +333,28 @@ impl MangaTracker for Anilist {

Ok(())
}

async fn mark_manga_as_plan_to_read(&self, manga_to_plan_to_read: super::PlanToReadArgs<'_>) -> Result<(), Box<dyn Error>> {
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 {
Expand All @@ -322,6 +371,7 @@ mod tests {
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
Expand Down Expand Up @@ -470,6 +520,33 @@ mod tests {
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!({
Expand Down Expand Up @@ -547,4 +624,30 @@ mod tests {

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;
}
}
9 changes: 8 additions & 1 deletion src/global.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub static CURRENT_LIST_ITEM_STYLE: Lazy<Style> = Lazy::new(|| Style::default().
pub mod test_utils {
use std::error::Error;

use crate::backend::tracker::MangaTracker;
use crate::backend::tracker::{MangaTracker, PlanToReadArgs};

#[derive(Debug, Clone)]
pub struct TrackerTest {
Expand Down Expand Up @@ -70,5 +70,12 @@ pub mod test_utils {
}
Ok(())
}

async fn mark_manga_as_plan_to_read(&self, _manga_to_plan_to_read: PlanToReadArgs<'_>) -> Result<(), Box<dyn Error>> {
if self.should_fail {
return Err(self.error_message.clone().unwrap_or("".to_string()).into());
}
Ok(())
}
}
}
5 changes: 3 additions & 2 deletions src/view/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ where
pub current_tab: SelectedPage,
pub manga_page: Option<MangaPage<S>>,
pub manga_reader_page: Option<MangaReader<T, S>>,
pub search_page: SearchPage,
pub search_page: SearchPage<T, S>,
pub home_page: Home,
pub feed_page: Feed<T>,
api_client: T,
Expand Down Expand Up @@ -131,7 +131,8 @@ impl<T: ApiClient + SearchChapter + SearchMangaPanel, S: MangaTracker> App<T, S>
App {
picker,
current_tab: SelectedPage::default(),
search_page: SearchPage::new(picker).with_global_sender(global_event_tx.clone()),
search_page: SearchPage::new(picker, api_client.clone(), manga_tracker.clone())
.with_global_sender(global_event_tx.clone()),
feed_page: Feed::new()
.with_global_sender(global_event_tx.clone())
.with_api_client(api_client.clone()),
Expand Down
49 changes: 41 additions & 8 deletions src/view/pages/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ use crate::backend::database::{save_plan_to_read, MangaPlanToReadSave, DBCONN};
use crate::backend::error_log::{write_to_error_log, ErrorType};
#[cfg(test)]
use crate::backend::fetch::fake_api_client::MockMangadexClient;
use crate::backend::fetch::ApiClient;
#[cfg(not(test))]
use crate::backend::fetch::MangadexClient;
use crate::backend::tracker::{track_manga_plan_to_read, MangaTracker};
use crate::backend::tui::Events;
use crate::common::{Artist, Author, ImageState};
use crate::global::{ERROR_STYLE, INSTRUCTIONS_STYLE};
Expand Down Expand Up @@ -77,7 +79,12 @@ pub enum InputMode {
Idle,
}

pub struct SearchPage {
pub struct SearchPage<T, S>
where
// TODO replace this trait for one containing only search functionality
T: ApiClient,
S: MangaTracker,
{
/// This tx "talks" to the app
global_event_tx: Option<UnboundedSender<Events>>,
local_action_tx: UnboundedSender<SearchPageActions>,
Expand All @@ -94,6 +101,8 @@ pub struct SearchPage {
picker: Option<Picker>,
manga_cover_state: ImageState,
tasks: JoinSet<()>,
api_client: T,
manga_tracker: Option<S>,
}

/// This contains the data the application gets when doing a search
Expand All @@ -105,7 +114,11 @@ struct MangasFoundList {
page: u32,
}

impl Component for SearchPage {
impl<T, S> Component for SearchPage<T, S>
where
T: ApiClient,
S: MangaTracker,
{
type Actions = SearchPageActions;

fn render(&mut self, area: Rect, frame: &mut Frame<'_>) {
Expand Down Expand Up @@ -171,8 +184,12 @@ impl Component for SearchPage {
}
}

impl SearchPage {
pub fn new(picker: Option<Picker>) -> Self {
impl<T, S> SearchPage<T, S>
where
T: ApiClient,
S: MangaTracker,
{
pub fn new(picker: Option<Picker>, api_client: T, manga_tracker: Option<S>) -> Self {
let (action_tx, action_rx) = mpsc::unbounded_channel::<SearchPageActions>();
let (local_event_tx, local_event) = mpsc::unbounded_channel::<SearchPageEvents>();

Expand All @@ -192,6 +209,8 @@ impl SearchPage {
manga_added_to_plan_to_read: None,
picker,
manga_cover_state: ImageState::default(),
api_client,
manga_tracker,
}
}

Expand Down Expand Up @@ -379,6 +398,17 @@ impl SearchPage {

fn plan_to_read(&mut self) {
if let Some(item) = self.get_current_manga_selected() {
let manga_selected = item.clone();
track_manga_plan_to_read(self.manga_tracker.clone(), manga_selected.manga.title.clone(), move |error| {
write_to_error_log(
format!(
"Could not add manga {} as plan to read, more details about the error : \n {}",
manga_selected.manga.title.clone(),
error
)
.into(),
);
});
let binding = DBCONN.lock().unwrap();
let conn = binding.as_ref().unwrap();
let plan_to_read_operation = save_plan_to_read(
Expand Down Expand Up @@ -607,11 +637,13 @@ mod test {

use super::*;
use crate::backend::api_responses::{Data, MangaSearchAttributes, MangaSearchRelationship};
use crate::global::test_utils::TrackerTest;
use crate::view::widgets::press_key;

#[tokio::test]
async fn search_page_events() {
let mut search_page = SearchPage::new(Some(Picker::new((8, 9))));
let mut search_page: SearchPage<MockMangadexClient, TrackerTest> =
SearchPage::new(Some(Picker::new((8, 9))), MockMangadexClient::new(), None);

let mock_search_result = SearchMangaResponse {
data: vec![
Expand Down Expand Up @@ -670,7 +702,7 @@ mod test {

#[tokio::test]
async fn search_page_key_events() {
let mut search_page = SearchPage::new(None);
let mut search_page: SearchPage<MockMangadexClient, TrackerTest> = SearchPage::new(None, MockMangadexClient::new(), None);

assert!(search_page.state == PageState::Normal);
assert!(!search_page.filter_state.is_open);
Expand Down Expand Up @@ -787,7 +819,8 @@ mod test {

#[test]
fn search_manga_cover_if_picker_is_some_after_mangas_were_found() {
let mut search_page = SearchPage::new(Some(Picker::new((8, 9))));
let mut search_page: SearchPage<MockMangadexClient, TrackerTest> =
SearchPage::new(Some(Picker::new((8, 9))), MockMangadexClient::new(), None);

search_page.load_mangas_found(Some(SearchMangaResponse {
data: vec![Data::default()],
Expand All @@ -801,7 +834,7 @@ mod test {

#[test]
fn doesnt_search_cover_if_picker_is_none_after_mangas_were_found() {
let mut search_page = SearchPage::new(None);
let mut search_page: SearchPage<MockMangadexClient, TrackerTest> = SearchPage::new(None, MockMangadexClient::new(), None);

search_page.load_mangas_found(Some(SearchMangaResponse {
data: vec![Data::default()],
Expand Down

0 comments on commit f04afc6

Please sign in to comment.