pub mod suggestion;
mod parser;
use serde::{Deserialize, Serialize};
use tl::ParserOptions;
use crate::{directory::DirectoryKind, DynastyReaderRoute, TagItem, DYNASTY_READER_BASE};
use self::suggestion::SearchSuggestion;
#[allow(missing_docs)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SearchConfig {
pub query: String,
pub page_number: u64,
pub sort: Option<SearchSort>,
pub categories: Vec<SearchCategory>,
pub with_tags: Vec<SearchTag>,
pub without_tags: Vec<SearchTag>,
}
impl From<String> for SearchConfig {
fn from(s: String) -> Self {
SearchConfig {
query: s,
page_number: 1,
sort: None,
categories: vec![],
with_tags: vec![],
without_tags: vec![],
}
}
}
impl DynastyReaderRoute for SearchConfig {
fn request_builder(
&self,
client: &reqwest::Client,
url: reqwest::Url,
) -> reqwest::RequestBuilder {
let mut queries = Vec::with_capacity(
3 + self.categories.len() + self.with_tags.len() + self.without_tags.len(),
);
queries.extend([
("q", self.query.clone()),
("sort", self.sort.unwrap_or_default().to_string()),
("page", self.page_number.to_string()),
]);
queries.extend(
self.categories
.iter()
.map(|kind| ("classes[]", kind.to_string())),
);
queries.extend(
self.with_tags
.iter()
.map(|tag| ("with[]", tag.0.to_string())),
);
queries.extend(
self.without_tags
.iter()
.map(|tag| ("without[]", tag.0.to_string())),
);
client.get(url).query(&queries)
}
fn request_url(&self) -> reqwest::Url {
DYNASTY_READER_BASE.join("search").unwrap()
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
pub enum SearchCategory {
Chapter,
Directory(DirectoryKind),
}
impl std::fmt::Display for SearchCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
SearchCategory::Chapter => "Chapter",
SearchCategory::Directory(kind) => {
use DirectoryKind::*;
match kind {
Anthology => "Anthology",
Doujin => "Doujin",
Issue => "Issue",
Series => "Series",
Author => "Author",
Scanlator => "Scanlator",
Tag => "General",
Pairing => "Pairing",
}
}
};
write!(f, "{s}")
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchSort {
Alphabetical,
BestMatch,
DateAdded,
ReleaseDate,
}
impl std::default::Default for SearchSort {
fn default() -> Self {
SearchSort::BestMatch
}
}
impl std::fmt::Display for SearchSort {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = {
use SearchSort::*;
match self {
Alphabetical => "name",
BestMatch => "",
DateAdded => "created_at",
ReleaseDate => "released_on",
}
};
write!(f, "{s}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SearchTag(u64);
impl From<SearchSuggestion> for SearchTag {
fn from(item: SearchSuggestion) -> Self {
SearchTag(item.id)
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct Search {
pub items: Vec<SearchItem>,
pub page_number: u64,
pub max_page_number: u64,
}
impl std::str::FromStr for Search {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let dom = tl::parse(s, ParserOptions::new().track_classes())?;
let parser = dom.parser();
let items = parser::parse_items(&dom, parser)?;
let (page_number, max_page_number) = parser::parse_page_numbers(&dom, parser)?;
Ok(Search {
items,
page_number,
max_page_number,
})
}
}
#[allow(missing_docs)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct SearchItem {
pub title: String,
pub kind: SearchCategory,
pub permalink: String,
pub tags: Vec<TagItem>,
}
#[cfg(test)]
mod tests {
use anyhow::Result;
use crate::{test_utils::tryhard_configs, DynastyApi};
use super::*;
#[tokio::test]
#[ignore = "requires internet"]
async fn response_structure() -> Result<()> {
let configs = {
use SearchSort::*;
[Alphabetical, BestMatch, DateAdded, ReleaseDate].map(|sort| SearchConfig {
query: "a".to_string(),
page_number: 1,
sort: Some(sort),
..Default::default()
})
};
tryhard_configs(configs, |client, config| client.search(config)).await?;
Ok(())
}
async fn check_response(
client: &DynastyApi,
(config, check): (SearchConfig, impl Fn(SearchItem)),
) -> Result<()> {
client
.search(config)
.await
.map(|Search { items, .. }| items.into_iter().for_each(check))
}
#[tokio::test]
#[ignore = "requires internet"]
async fn filtered_response_structure() -> Result<()> {
let categories = {
use DirectoryKind::*;
use SearchCategory::*;
[
Chapter,
Directory(Anthology),
Directory(Doujin),
Directory(Issue),
Directory(Series),
Directory(Author),
Directory(Scanlator),
Directory(Tag),
Directory(Pairing),
]
.map(|category| {
(
SearchConfig {
page_number: 1,
categories: vec![category],
..Default::default()
},
move |item: SearchItem| {
assert_eq!(
item.kind, category,
"category: {category} result should not contains other category"
)
},
)
})
};
tryhard_configs(categories, check_response).await?;
let with_tags = [
(5175, DirectoryKind::Tag, "aaaaaangst"),
(18109, DirectoryKind::Author, "manio"),
(16084, DirectoryKind::Doujin, "bloom_into_you"),
]
.map(|(t, kind, permalink)| {
(
SearchConfig {
page_number: 1,
with_tags: vec![SearchTag(t)],
..Default::default()
},
move |item: SearchItem| {
assert!(
item.tags
.iter()
.any(|tag| kind == tag.kind && permalink == tag.permalink),
"with_tags: {t} should contains {kind} {permalink}"
)
},
)
});
tryhard_configs(with_tags, check_response).await?;
let without_tags = [
(5182, DirectoryKind::Tag, "love_triangle"),
(9811, DirectoryKind::Tag, "guro"),
(5367, DirectoryKind::Tag, "vampire"),
]
.map(|(t, kind, permalink)| {
(
SearchConfig {
page_number: 1,
without_tags: vec![SearchTag(t)],
..Default::default()
},
move |item: SearchItem| {
assert!(
!item
.tags
.iter()
.any(|tag| kind == tag.kind && permalink == tag.permalink),
"without_tags: {t} should not contains {kind} {permalink}"
)
},
)
});
tryhard_configs(without_tags, check_response).await?;
Ok(())
}
}