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<VolumeDetail>, pagination: PaginationInfo, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] struct ChapterWrapper { parts: Vec<ChapterDetail>, 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<Cover>, } #[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<Ordering> { 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<Ordering> { 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<Vec<PostInfo>, Box<dyn Error>> { 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<u8, PostInfo> = HashMap::new(); let mut chapter_map: HashMap<u8, PostInfo> = 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<u8> = 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::<u8>() .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<PostInfo> = volume_map.values().cloned().collect(); let mut chapter_vec: Vec<PostInfo> = chapter_map.values().cloned().collect(); result_vec.append(&mut chapter_vec); Ok(result_vec) } async fn get_latest_part(volume_slug: &str) -> Result<Option<LemmyPostInfo>, Box<dyn Error>> { 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<LemmyPostInfo> = 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) }