diff --git a/src/jnovel/mod.rs b/src/jnovel/mod.rs new file mode 100644 index 0000000..3e19ac6 --- /dev/null +++ b/src/jnovel/mod.rs @@ -0,0 +1,359 @@ +use std::cmp::Ordering; +use std::collections::HashMap; +use std::error::Error; +use std::ops::Sub; +use chrono::{DateTime, Duration, Utc}; +use lemmy_db_schema::source::post::Post; +use serde_derive::{Deserialize, Serialize}; +use url::Url; +use crate::{HTTP_CLIENT}; +use crate::jnovel::PartInfo::{NoParts, Part}; +use crate::jnovel::PostInfo::{Chapter, Volume}; + +static PAST_DAYS_ELIGIBLE: u8 = 4; + +macro_rules! api_url { + () => { + "https://labs.j-novel.club/app/v1".to_string() + }; +} + +macro_rules! jnc_base_url { + () => { + "https://j-novel.club".to_string() + }; +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +struct VolumesWrapper { + volumes: Vec, + pagination: PaginationInfo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +struct ChapterWrapper { + parts: Vec, + pagination: PaginationInfo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +struct PaginationInfo { + limit: usize, + skip: usize, + #[serde(alias = "lastPage")] + last_page: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub(crate) struct Cover { + #[serde(alias = "coverUrl")] + pub(crate) cover: String, + #[serde(alias = "thumbnailUrl")] + pub(crate) thumbnail: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub(crate) struct VolumeDetail { + pub(crate) title: String, + pub(crate) slug: String, + pub(crate) number: u8, + pub(crate) publishing: String, + #[serde(alias = "shortDescription")] + pub(crate) short_description: String, + pub(crate) cover: Cover, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub(crate) struct ChapterDetail { + pub(crate) title: String, + pub(crate) slug: String, + pub(crate) launch: String, + pub(crate) cover: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct LemmyPostInfo { + title: String, + url: Url, +} + +#[derive(Debug, Copy, Clone)] +pub(crate) enum PartInfo { + NoParts, + Part(u8), +} + +impl PartInfo { + pub(crate) fn is_parts(&self) -> bool { + match self { + Part(_) => true, + NoParts => false, + } + } + + pub(crate) fn is_no_parts(&self) -> bool { + !self.is_parts() + } +} + +impl PartialEq for PartInfo { + fn eq(&self, other: &Self) -> bool { + let self_numeric = match self { + Part(number) => number, + NoParts => &0, + }; + + let other_numeric = match other { + Part(number) => number, + NoParts => &0, + }; + + self_numeric == other_numeric + } +} + +impl PartialOrd for PartInfo { + fn partial_cmp(&self, other: &Self) -> Option { + if self.gt(other) { Some(Ordering::Greater) } + else if self.eq(other) { Some(Ordering::Equal) } + else { Some(Ordering::Less) } + } + + fn lt(&self, other: &Self) -> bool { + let self_numeric = match self { + Part(number) => number, + NoParts => &0, + }; + + let other_numeric = match other { + Part(number) => number, + NoParts => &0, + }; + + self_numeric < other_numeric + } + + fn le(&self, other: &Self) -> bool { + !self.gt(other) + } + + fn gt(&self, other: &Self) -> bool { + let self_numeric = match self { + Part(number) => number, + NoParts => &0, + }; + + let other_numeric = match other { + Part(number) => number, + NoParts => &0, + }; + + self_numeric > other_numeric + } + + fn ge(&self, other: &Self) -> bool { + !self.lt(other) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum PostInfo { + Chapter { part: PartInfo, volume: u8, lemmy_info: LemmyPostInfo }, + Volume { part: PartInfo, lemmy_info: LemmyPostInfo }, +} + +impl PartialEq for PostInfo { + fn eq(&self, other: &Self) -> bool { + let self_part = match self { + Chapter {part, ..} => part, + Volume {part, ..} => part, + }; + + let other_part = match other { + Chapter {part, ..} => part, + Volume {part, ..} => part, + }; + + self_part.eq(other_part) + } +} + +impl PartialOrd for PostInfo { + fn partial_cmp(&self, other: &Self) -> Option { + if self.gt(other) { Some(Ordering::Greater) } + else if self.eq(other) { Some(Ordering::Equal) } + else { Some(Ordering::Less) } + } + + fn lt(&self, other: &Self) -> bool { + let self_part = match self { + Chapter {part, ..} => part, + Volume {part, ..} => part, + }; + + let other_part = match other { + Chapter {part, ..} => part, + Volume {part, ..} => part, + }; + + self_part < other_part + } + + fn le(&self, other: &Self) -> bool { + !self.gt(other) + } + + fn gt(&self, other: &Self) -> bool { + let self_part = match self { + Chapter {part, ..} => part, + Volume {part, ..} => part, + }; + + let other_part = match other { + Chapter {part, ..} => part, + Volume {part, ..} => part, + }; + + self_part > other_part + } + + fn ge(&self, other: &Self) -> bool { + !self.lt(other) + } +} + +pub(crate) async fn check_feed(series_slug: &str, series_has_parts: bool) -> Result, Box> { + let response = HTTP_CLIENT + .get(api_url!() + "/series/" + series_slug + "/volumes?format=json") + .send() + .await? + .text() + .await?; + + let mut volume_brief_data: VolumesWrapper = serde_json::from_str(&response)?; + volume_brief_data.volumes.reverse(); // Makes breaking out of the volume loop easier + + // If no parts just use 0 as Part indicator as no Series with Parts has a Part 0 + let mut volume_map: HashMap = HashMap::new(); + let mut chapter_map: HashMap = HashMap::new(); + + for volume in volume_brief_data.volumes.iter() { + let publishing_date = DateTime::parse_from_rfc3339(&volume.publishing).unwrap(); + if publishing_date < Utc::now().sub(Duration::days(PAST_DAYS_ELIGIBLE as i64)) { + match series_has_parts { + true => continue, + false => break, + } + } + let new_part_info: PartInfo; + + if series_has_parts { + let mut part_number: Option = None; + let splits: Vec<&str> = volume.slug.split('-').collect(); + for (index, split) in splits.clone().into_iter().enumerate() { + + if split == "part" { + part_number = Some(splits[index +1] + .parse::() + .expect("Split Element after 'Part' should always be a number")); + break; + } + } + + match part_number { + Some(number) => new_part_info = Part(number), + None => { + println!("No Part found, assuming 1"); + new_part_info = Part(1); + }, + } + } + else { + new_part_info = NoParts; + } + + let post_url = format!("{}/series/{series_slug}#volume-{}", jnc_base_url!(), volume.number); + let post_details = LemmyPostInfo { + title: volume.title.clone(), + url: Url::parse(&post_url).unwrap(), + }; + + let new_post_info = Volume { + part: new_part_info, + lemmy_info: post_details, + }; + + let part_id = match new_part_info { + Part(number) => number, + NoParts => 0, + }; + + if publishing_date <= Utc::now() { + volume_map + .entry(part_id) + .and_modify(|val| { + if *val < new_post_info { + *val = new_post_info.clone() + } + }) + .or_insert(new_post_info); + } + + if let Some(part_info) = get_latest_part(&volume.slug).await? { + let part_post_info = Chapter { + part: new_part_info, + volume: volume.number, + lemmy_info: part_info + }; + + chapter_map + .entry(part_id) + .and_modify(|val| { + if *val < part_post_info { + *val = part_post_info.clone() + } + }) + .or_insert(part_post_info); + } + } + + let mut result_vec: Vec = volume_map.values().cloned().collect(); + let mut chapter_vec: Vec = chapter_map.values().cloned().collect(); + result_vec.append(&mut chapter_vec); + + Ok(result_vec) +} + +async fn get_latest_part(volume_slug: &str) -> Result, Box> { + let response = HTTP_CLIENT + .get(api_url!() + "/volumes/" + volume_slug + "/parts?format=json") + .send() + .await? + .text() + .await?; + + let mut volume_parts_data: ChapterWrapper = serde_json::from_str(&response)?; + volume_parts_data.parts.reverse(); // Makes breaking out of the parts loop easier + + let mut post_details: Option = None; + + for part in volume_parts_data.parts.iter() { + let publishing_date = DateTime::parse_from_rfc3339(&part.launch).unwrap(); + if publishing_date > Utc::now() { + break + } + else if publishing_date < Utc::now().sub(Duration::days(PAST_DAYS_ELIGIBLE as i64)) { + continue + } + + let post_url = format!("{}/read/{}", jnc_base_url!(), part.slug); + post_details = Some(LemmyPostInfo { + title: part.title.clone(), + url: Url::parse(&post_url).unwrap(), + }); + + } + + Ok(post_details) +}