1pub mod suggestion;
3
4mod parser;
5
6use serde::{Deserialize, Serialize};
7use tl::ParserOptions;
8
9use crate::{directory::DirectoryKind, DynastyReaderRoute, TagItem, DYNASTY_READER_BASE};
10
11use self::suggestion::SearchSuggestion;
12
13#[allow(missing_docs)]
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
16pub struct SearchConfig {
17 pub query: String,
18 pub page_number: u64,
19 pub sort: Option<SearchSort>,
20 pub categories: Vec<SearchCategory>,
21 pub with_tags: Vec<SearchTag>,
22 pub without_tags: Vec<SearchTag>,
23}
24
25impl From<String> for SearchConfig {
26 fn from(s: String) -> Self {
27 SearchConfig {
28 query: s,
29 page_number: 1,
30 sort: None,
31 categories: vec![],
32 with_tags: vec![],
33 without_tags: vec![],
34 }
35 }
36}
37
38impl DynastyReaderRoute for SearchConfig {
39 fn request_builder(
40 &self,
41 client: &reqwest::Client,
42 url: reqwest::Url,
43 ) -> reqwest::RequestBuilder {
44 let mut queries = Vec::with_capacity(
45 3 + self.categories.len() + self.with_tags.len() + self.without_tags.len(),
47 );
48
49 queries.extend([
50 ("q", self.query.clone()),
52 ("sort", self.sort.unwrap_or_default().to_string()),
54 ("page", self.page_number.to_string()),
56 ]);
57
58 queries.extend(
60 self.categories
61 .iter()
62 .map(|kind| ("classes[]", kind.to_string())),
63 );
64
65 queries.extend(
67 self.with_tags
68 .iter()
69 .map(|tag| ("with[]", tag.0.to_string())),
70 );
71 queries.extend(
72 self.without_tags
73 .iter()
74 .map(|tag| ("without[]", tag.0.to_string())),
75 );
76
77 client.get(url).query(&queries)
78 }
79
80 fn request_url(&self) -> reqwest::Url {
81 DYNASTY_READER_BASE.join("search").unwrap()
82 }
83}
84
85#[allow(missing_docs)]
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
88pub enum SearchCategory {
89 Chapter,
90 Directory(DirectoryKind),
91}
92
93impl std::fmt::Display for SearchCategory {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 let s = match self {
96 SearchCategory::Chapter => "Chapter",
97 SearchCategory::Directory(kind) => {
98 use DirectoryKind::*;
99
100 match kind {
101 Anthology => "Anthology",
102 Doujin => "Doujin",
103 Issue => "Issue",
104 Series => "Series",
105 Author => "Author",
106 Scanlator => "Scanlator",
107 Tag => "General",
108 Pairing => "Pairing",
109 }
110 }
111 };
112
113 write!(f, "{s}")
114 }
115}
116
117#[allow(missing_docs)]
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum SearchSort {
121 Alphabetical,
122 BestMatch,
123 DateAdded,
124 ReleaseDate,
125}
126
127impl std::default::Default for SearchSort {
128 fn default() -> Self {
129 SearchSort::BestMatch
130 }
131}
132
133impl std::fmt::Display for SearchSort {
134 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135 let s = {
136 use SearchSort::*;
137
138 match self {
139 Alphabetical => "name",
140 BestMatch => "",
141 DateAdded => "created_at",
142 ReleaseDate => "released_on",
143 }
144 };
145
146 write!(f, "{s}")
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct SearchTag(u64);
155
156impl From<SearchSuggestion> for SearchTag {
157 fn from(item: SearchSuggestion) -> Self {
158 SearchTag(item.id)
159 }
160}
161
162#[allow(missing_docs)]
166#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
167pub struct Search {
168 pub items: Vec<SearchItem>,
169 pub page_number: u64,
170 pub max_page_number: u64,
171}
172
173impl std::str::FromStr for Search {
174 type Err = anyhow::Error;
175
176 fn from_str(s: &str) -> Result<Self, Self::Err> {
177 let dom = tl::parse(s, ParserOptions::new().track_classes())?;
178 let parser = dom.parser();
179
180 let items = parser::parse_items(&dom, parser)?;
181 let (page_number, max_page_number) = parser::parse_page_numbers(&dom, parser)?;
182
183 Ok(Search {
184 items,
185 page_number,
186 max_page_number,
187 })
188 }
189}
190
191#[allow(missing_docs)]
193#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
194pub struct SearchItem {
195 pub title: String,
196 pub kind: SearchCategory,
197 pub permalink: String,
198 pub tags: Vec<TagItem>,
199}
200
201#[cfg(test)]
202mod tests {
203 use anyhow::Result;
204
205 use crate::{test_utils::tryhard_configs, DynastyApi};
206
207 use super::*;
208
209 #[tokio::test]
210 #[ignore = "requires internet"]
211 async fn response_structure() -> Result<()> {
212 let configs = {
213 use SearchSort::*;
214
215 [Alphabetical, BestMatch, DateAdded, ReleaseDate].map(|sort| SearchConfig {
216 query: "a".to_string(),
217 page_number: 1,
218 sort: Some(sort),
219 ..Default::default()
220 })
221 };
222
223 tryhard_configs(configs, |client, config| client.search(config)).await?;
224
225 Ok(())
226 }
227
228 async fn check_response(
229 client: &DynastyApi,
230 (config, check): (SearchConfig, impl Fn(SearchItem)),
231 ) -> Result<()> {
232 client
233 .search(config)
234 .await
235 .map(|Search { items, .. }| items.into_iter().for_each(check))
236 }
237
238 #[tokio::test]
239 #[ignore = "requires internet"]
240 async fn filtered_response_structure() -> Result<()> {
241 let categories = {
242 use DirectoryKind::*;
243 use SearchCategory::*;
244
245 [
246 Chapter,
247 Directory(Anthology),
248 Directory(Doujin),
249 Directory(Issue),
250 Directory(Series),
251 Directory(Author),
252 Directory(Scanlator),
253 Directory(Tag),
254 Directory(Pairing),
255 ]
256 .map(|category| {
257 (
258 SearchConfig {
259 page_number: 1,
260 categories: vec![category],
261 ..Default::default()
262 },
263 move |item: SearchItem| {
264 assert_eq!(
265 item.kind, category,
266 "category: {category} result should not contains other category"
267 )
268 },
269 )
270 })
271 };
272 tryhard_configs(categories, check_response).await?;
273
274 let with_tags = [
275 (5175, DirectoryKind::Tag, "aaaaaangst"),
276 (18109, DirectoryKind::Author, "manio"),
277 (16084, DirectoryKind::Doujin, "bloom_into_you"),
278 ]
279 .map(|(t, kind, permalink)| {
280 (
281 SearchConfig {
282 page_number: 1,
283 with_tags: vec![SearchTag(t)],
284 ..Default::default()
285 },
286 move |item: SearchItem| {
287 assert!(
288 item.tags
289 .iter()
290 .any(|tag| kind == tag.kind && permalink == tag.permalink),
291 "with_tags: {t} should contains {kind} {permalink}"
292 )
293 },
294 )
295 });
296 tryhard_configs(with_tags, check_response).await?;
297
298 let without_tags = [
299 (5182, DirectoryKind::Tag, "love_triangle"),
300 (9811, DirectoryKind::Tag, "guro"),
301 (5367, DirectoryKind::Tag, "vampire"),
302 ]
303 .map(|(t, kind, permalink)| {
304 (
305 SearchConfig {
306 page_number: 1,
307 without_tags: vec![SearchTag(t)],
308 ..Default::default()
309 },
310 move |item: SearchItem| {
311 assert!(
312 !item
313 .tags
314 .iter()
315 .any(|tag| kind == tag.kind && permalink == tag.permalink),
316 "without_tags: {t} should not contains {kind} {permalink}"
317 )
318 },
319 )
320 });
321 tryhard_configs(without_tags, check_response).await?;
322
323 Ok(())
324 }
325}