1use anyhow::{anyhow, Context, Result};
2
3use bitflags::bitflags;
4use core::fmt;
5use reqwest::{Client, Response};
6use scraper::Html;
7use serde::Deserialize;
8use serde_repr::{Deserialize_repr, Serialize_repr};
9use std::fmt::Debug;
10use std::fmt::Display;
11use std::fmt::Write;
12
13use crate::Endpoint;
14
15use crate::languages::Language;
16
17#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20pub struct Search {
21 pub results: Vec<SearchResult>,
23 pub endpoint: Endpoint,
25 pub continue_offset: Option<usize>,
27 pub info: SearchInfo,
29}
30
31impl Search {
32 pub fn builder() -> SearchBuilder<NoQuery, NoEndpoint, NoLanguage> {
36 SearchBuilder::default()
37 }
38
39 pub fn continue_data(&self) -> Option<SearchContinue> {
48 if let Some(ref offset) = self.continue_offset {
49 let info: &SearchInfo = &self.info;
50 return Some(SearchContinue {
51 query: info.query.clone(),
52 endpoint: self.endpoint.clone(),
53 language: info.language,
54 offset: *offset,
55 });
56 }
57 None
58 }
59}
60
61impl Debug for Search {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_struct("Search")
64 .field("continue_offset", &self.continue_offset)
65 .field("results", &self.results.len())
66 .field("info", &self.info)
67 .finish()
68 }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
73pub struct SearchInfo {
74 pub complete: bool,
76 pub total_hits: Option<usize>,
78 pub suggestion: Option<String>,
80 pub rewritten_query: Option<String>,
85 pub query: String,
87 pub language: Language,
89}
90
91#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
109pub struct SearchContinue {
110 pub query: String,
114 pub endpoint: Endpoint,
116 pub language: Language,
118 pub offset: usize,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
125pub struct SearchResult {
126 pub namespace: Namespace,
128 pub title: String,
130 pub pageid: usize,
132
133 pub language: Language,
135 pub endpoint: Endpoint,
137
138 pub size: Option<usize>,
140 pub wordcount: Option<usize>,
142 pub snippet: Option<String>,
144 pub timestamp: Option<String>,
146}
147
148impl SearchResult {
149 pub fn cleaned_snippet(&self) -> String {
150 self.snippet
151 .as_ref()
152 .map(|snip| {
153 let fragment = Html::parse_document(snip);
154 fragment.root_element().text().collect()
155 })
156 .unwrap_or_default()
157 }
158}
159
160#[derive(Serialize_repr, Deserialize_repr, Debug, Clone, PartialEq, Eq)]
172#[repr(usize)]
173pub enum Namespace {
174 Main = 0,
175 MainTalk = 1,
176 User = 2,
177 UserTalk = 3,
178 Project = 4,
179 ProjectTalk = 5,
180 File = 6,
181 FileTalk = 7,
182 MediaWiki = 8,
183 MediaWikiTalk = 9,
184 Template = 10,
185 TemplateTalk = 11,
186 Help = 12,
187 HelpTalk = 13,
188 Category = 14,
189 CategoryTalk = 15,
190}
191
192impl Display for Namespace {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 match self {
195 Namespace::Main => write!(f, "Main"),
196 Namespace::MainTalk => write!(f, "Main_talk"),
197 Namespace::User => write!(f, "User"),
198 Namespace::UserTalk => write!(f, "User_talk"),
199 Namespace::Project => write!(f, "Project"),
200 Namespace::ProjectTalk => write!(f, "Project_talk"),
201 Namespace::File => write!(f, "File"),
202 Namespace::FileTalk => write!(f, "File_talk"),
203 Namespace::MediaWiki => write!(f, "Mediawiki"),
204 Namespace::MediaWikiTalk => write!(f, "Mediawiki_talk"),
205 Namespace::Template => write!(f, "Template"),
206 Namespace::TemplateTalk => write!(f, "Template_talk"),
207 Namespace::Help => write!(f, "Help"),
208 Namespace::HelpTalk => write!(f, "Help_talk"),
209 Namespace::Category => write!(f, "Category"),
210 Namespace::CategoryTalk => write!(f, "Category_talk"),
211 }
212 }
213}
214
215impl Namespace {
216 pub fn from_string(namespace: &str) -> Option<Namespace> {
217 match namespace.to_lowercase().as_str() {
218 "main" => Some(Namespace::Main),
219 "main_talk" => Some(Namespace::MainTalk),
220 "user" => Some(Namespace::User),
221 "user_talk" => Some(Namespace::UserTalk),
222 "project" => Some(Namespace::Project),
223 "project_talk" => Some(Namespace::ProjectTalk),
224 "file" => Some(Namespace::File),
225 "file_talk" => Some(Namespace::FileTalk),
226 "mediawiki" => Some(Namespace::MediaWiki),
227 "mediawiki_talk" => Some(Namespace::MediaWikiTalk),
228 "template" => Some(Namespace::Template),
229 "template_talk" => Some(Namespace::TemplateTalk),
230 "help" => Some(Namespace::Help),
231 "help_talk" => Some(Namespace::HelpTalk),
232 "category" => Some(Namespace::Category),
233 "category_talk" => Some(Namespace::CategoryTalk),
234 _ => None,
235 }
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::Namespace;
242 #[test]
243 fn test_namespace_display_and_str() {
244 macro_rules! test_namespace {
245 ($namespace: ident, $namespace_talk: ident) => {
246 let namespace_str = format!("{}", Namespace::$namespace);
247 assert_eq!(
248 Namespace::from_string(&namespace_str),
249 Some(Namespace::$namespace)
250 );
251
252 let namespace_str = format!("{}", Namespace::$namespace_talk);
253 assert_eq!(
254 Namespace::from_string(&namespace_str),
255 Some(Namespace::$namespace_talk)
256 );
257 };
258 }
259
260 test_namespace!(Main, MainTalk);
261 test_namespace!(User, UserTalk);
262 test_namespace!(Project, ProjectTalk);
263 test_namespace!(File, FileTalk);
264 test_namespace!(MediaWiki, MediaWikiTalk);
265 test_namespace!(Template, TemplateTalk);
266 test_namespace!(Help, HelpTalk);
267 test_namespace!(Category, CategoryTalk);
268 }
269}
270
271#[derive(Default, Clone, Deserialize, serde::Serialize)]
273#[serde(rename_all = "lowercase")]
274pub enum QiProfile {
275 Classic,
279 ClassicNoBoostLinks,
282 WSumIncLinks,
284 WSumIncLinksPV,
286 PopularIncLinksPV,
288 PopularIncLinks,
290 #[default]
292 EngineAutoselect,
293}
294
295impl Display for QiProfile {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 match self {
298 QiProfile::Classic => write!(f, "classic"),
299 QiProfile::ClassicNoBoostLinks => write!(f, "classic_noboostlinks"),
300 QiProfile::WSumIncLinks => write!(f, "wsum_inclinks"),
301 QiProfile::WSumIncLinksPV => write!(f, "wsum_inclinks_pv"),
302 QiProfile::PopularIncLinksPV => write!(f, "popular_inclinks_pv"),
303 QiProfile::PopularIncLinks => write!(f, "popular_inclinks"),
304 QiProfile::EngineAutoselect => write!(f, "engine_autoselect"),
305 }
306 }
307}
308
309#[derive(Default, Clone, Deserialize, serde::Serialize)]
311#[serde(rename_all = "lowercase")]
312pub enum SearchType {
313 NearMatch,
315 #[default]
317 Text,
318 Title,
320}
321
322impl Display for SearchType {
323 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324 match self {
325 SearchType::NearMatch => write!(f, "nearmatch"),
326 SearchType::Text => write!(f, "text"),
327 SearchType::Title => write!(f, "title"),
328 }
329 }
330}
331
332bitflags! {
333 #[derive(Clone, Deserialize, serde::Serialize)]
335 pub struct Info: u8 {
336 const REWRITTEN_QUERY = 0b00000001;
341 const SUGGESTION = 0b00000010;
343 const TOTAL_HITS = 0b00000100;
345 }
346}
347
348impl Default for Info {
349 fn default() -> Self {
350 Self::REWRITTEN_QUERY | Self::SUGGESTION | Self::TOTAL_HITS
351 }
352}
353
354impl fmt::Display for Info {
355 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 let mut first = true;
357
358 if self.contains(Info::REWRITTEN_QUERY) {
359 if !first {
360 f.write_char('|')?;
361 }
362 first = false;
363
364 f.write_str("rewrittenquery")?;
365 }
366
367 if self.contains(Info::SUGGESTION) {
368 if !first {
369 f.write_char('|')?;
370 }
371 first = false;
372
373 f.write_str("suggestion")?;
374 }
375
376 if self.contains(Info::TOTAL_HITS) {
377 if !first {
378 f.write_char('|')?;
379 }
380
381 f.write_str("totalhits")?;
382 }
383
384 Ok(())
385 }
386}
387
388#[derive(serde::Serialize, serde::Deserialize)]
390pub enum Property {
391 Size,
393 WordCount,
395 Timestamp,
397 Snippet,
399 TitleSnippet,
401 RedirectTitle,
403 RedirectSnippet,
405 SectionTitle,
407 SectionSnippet,
409 IsFileMatch,
411 CategorySnippet,
413}
414
415impl Display for Property {
416 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417 match self {
418 Property::Size => write!(f, "size"),
419 Property::WordCount => write!(f, "wordcount"),
420 Property::Timestamp => write!(f, "timestamp"),
421 Property::Snippet => write!(f, "snippet"),
422 Property::TitleSnippet => write!(f, "titlesnippet"),
423 Property::RedirectTitle => write!(f, "redirecttitle"),
424 Property::RedirectSnippet => write!(f, "redirectsnippet"),
425 Property::SectionTitle => write!(f, "sectiontitle"),
426 Property::SectionSnippet => write!(f, "sectionsnippet"),
427 Property::IsFileMatch => write!(f, "isfilematch"),
428 Property::CategorySnippet => write!(f, "categorysnippet"),
429 }
430 }
431}
432
433#[derive(Deserialize, Clone, serde::Serialize)]
435#[serde(rename_all = "lowercase")]
436pub enum SortOrder {
437 CreateTimestampAscending,
439 CreateTimestampDescending,
441 IncomingLinksAscending,
443 IncomingLinksDescending,
445 JustMatch,
447 LastEditAscending,
449 LastEditDescending,
451 NoSort,
453 Random,
455 Relevance,
457 UserRandom,
459}
460
461impl Display for SortOrder {
462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463 match self {
464 SortOrder::CreateTimestampAscending => write!(f, "create_timestamp_asc"),
465 SortOrder::CreateTimestampDescending => write!(f, "create_timestamp_desc"),
466 SortOrder::IncomingLinksAscending => write!(f, "incoming_links_asc"),
467 SortOrder::IncomingLinksDescending => write!(f, "incoming_links_desc"),
468 SortOrder::JustMatch => write!(f, "just_match"),
469 SortOrder::LastEditAscending => write!(f, "last_edit_asc"),
470 SortOrder::LastEditDescending => write!(f, "last_edit_desc"),
471 SortOrder::NoSort => write!(f, "none"),
472 SortOrder::Random => write!(f, "random"),
473 SortOrder::Relevance => write!(f, "relevance"),
474 SortOrder::UserRandom => write!(f, "user_random"),
475 }
476 }
477}
478
479#[doc(hidden)]
480pub struct WithQuery(String);
481
482#[doc(hidden)]
483#[derive(Default)]
484pub struct NoQuery;
485
486#[doc(hidden)]
487pub struct WithEndpoint(Endpoint);
488
489#[doc(hidden)]
490#[derive(Default)]
491pub struct NoEndpoint;
492
493#[doc(hidden)]
494pub struct WithLanguage(Language);
495
496#[doc(hidden)]
497#[derive(Default)]
498pub struct NoLanguage;
499
500pub type SearchRequest = SearchBuilder<WithQuery, WithEndpoint, WithLanguage>;
502
503#[derive(Default)]
505pub struct SearchBuilder<Q, E, L> {
506 query: Q,
507 endpoint: E,
508 language: L,
509 namespace: Option<Namespace>,
510 limit: Option<usize>,
511 offset: Option<usize>,
512 qiprofile: Option<QiProfile>,
513 search_type: Option<SearchType>,
514 info: Option<Info>,
515 properties: Option<Vec<Property>>,
516 interwiki: Option<bool>,
517 rewrites: Option<bool>,
518 sort_order: Option<SortOrder>,
519}
520
521impl<E, L> SearchBuilder<NoQuery, E, L> {
522 pub fn query(self, query: impl Into<String>) -> SearchBuilder<WithQuery, E, L> {
524 SearchBuilder {
525 query: WithQuery(query.into()),
526 endpoint: self.endpoint,
527 language: self.language,
528 namespace: self.namespace,
529 limit: self.limit,
530 offset: self.offset,
531 qiprofile: self.qiprofile,
532 search_type: self.search_type,
533 info: self.info,
534 properties: self.properties,
535 interwiki: self.interwiki,
536 rewrites: self.rewrites,
537 sort_order: self.sort_order,
538 }
539 }
540}
541
542impl<Q, L> SearchBuilder<Q, NoEndpoint, L> {
543 pub fn endpoint(self, endpoint: Endpoint) -> SearchBuilder<Q, WithEndpoint, L> {
545 SearchBuilder {
546 query: self.query,
547 endpoint: WithEndpoint(endpoint),
548 language: self.language,
549 namespace: self.namespace,
550 limit: self.limit,
551 offset: self.offset,
552 qiprofile: self.qiprofile,
553 search_type: self.search_type,
554 info: self.info,
555 properties: self.properties,
556 interwiki: self.interwiki,
557 rewrites: self.rewrites,
558 sort_order: self.sort_order,
559 }
560 }
561}
562
563impl<Q, E> SearchBuilder<Q, E, NoLanguage> {
564 pub fn language(self, language: Language) -> SearchBuilder<Q, E, WithLanguage> {
566 SearchBuilder {
567 query: self.query,
568 endpoint: self.endpoint,
569 language: WithLanguage(language),
570 namespace: self.namespace,
571 limit: self.limit,
572 offset: self.offset,
573 qiprofile: self.qiprofile,
574 search_type: self.search_type,
575 info: self.info,
576 properties: self.properties,
577 interwiki: self.interwiki,
578 rewrites: self.rewrites,
579 sort_order: self.sort_order,
580 }
581 }
582}
583
584impl<Q, E, L> SearchBuilder<Q, E, L> {
585 pub fn namespace(mut self, namespace: Namespace) -> Self {
587 self.namespace = Some(namespace);
588 self
589 }
590
591 pub fn limit(mut self, limit: usize) -> Self {
595 self.limit = Some(limit);
596 self
597 }
598
599 pub fn offset(mut self, offset: usize) -> Self {
602 self.offset = Some(offset);
603 self
604 }
605
606 pub fn qiprofile(mut self, qiprofile: QiProfile) -> Self {
612 self.qiprofile = Some(qiprofile);
613 self
614 }
615
616 pub fn search_type(mut self, search_type: SearchType) -> Self {
622 self.search_type = Some(search_type);
623 self
624 }
625
626 pub fn info(mut self, info: Info) -> Self {
634 self.info = Some(info);
635 self
636 }
637
638 pub fn properties(mut self, properties: Vec<Property>) -> Self {
648 self.properties = Some(properties);
649 self
650 }
651
652 pub fn interwiki(mut self, interwiki: bool) -> Self {
656 self.interwiki = Some(interwiki);
657 self
658 }
659
660 pub fn rewrites(mut self, rewrites: bool) -> Self {
665 self.rewrites = Some(rewrites);
666 self
667 }
668
669 pub fn sort_order(mut self, sort_order: SortOrder) -> Self {
675 self.sort_order = Some(sort_order);
676 self
677 }
678}
679
680impl SearchBuilder<WithQuery, WithEndpoint, WithLanguage> {
681 pub async fn search(self) -> Result<Search> {
702 async fn action_query(params: Vec<(&str, String)>, endpoint: Endpoint) -> Result<Response> {
703 Client::new()
704 .get(endpoint)
705 .header(
706 "User-Agent",
707 format!(
708 "wiki-tui/{} (https://github.com/Builditluc/wiki-tui)",
709 env!("CARGO_PKG_VERSION")
710 ),
711 )
712 .query(&[
713 ("action", "query"),
714 ("format", "json"),
715 ("formatversion", "2"),
716 ])
717 .query(¶ms)
718 .send()
719 .await
720 .context("failed sending the request")
721 }
722
723 let mut params = vec![
724 ("list", "search".to_string()),
725 ("srsearch", self.query.0.clone()),
726 ];
727
728 if let Some(namespace) = self.namespace {
729 params.push(("srnamespace", namespace.to_string()));
730 }
731
732 if let Some(limit) = self.limit {
733 params.push(("srlimit", limit.to_string()));
734 }
735
736 if let Some(offset) = self.offset {
737 params.push(("sroffset", offset.to_string()));
738 }
739
740 if let Some(qiprofile) = self.qiprofile {
741 params.push(("srqiprofile", qiprofile.to_string()));
742 }
743
744 if let Some(search_type) = self.search_type {
745 params.push(("srwhat", search_type.to_string()));
746 }
747
748 if let Some(info) = self.info {
749 params.push(("srinfo", info.to_string()));
750 }
751
752 if let Some(prop) = self.properties {
753 let mut prop_str = String::new();
754 for prop in prop {
755 prop_str.push('|');
756 prop_str.push_str(&prop.to_string());
757 }
758 params.push(("srprop", prop_str));
759 }
760
761 if let Some(interwiki) = self.interwiki {
762 params.push(("srinterwiki", interwiki.to_string()));
763 }
764
765 if let Some(rewrites) = self.rewrites {
766 params.push(("srenablerewrites", rewrites.to_string()));
767 }
768
769 if let Some(sort_order) = self.sort_order {
770 params.push(("srsort", sort_order.to_string()));
771 }
772
773 let response = action_query(params, self.endpoint.0.clone())
774 .await?
775 .error_for_status()
776 .context("the server returned an error")?;
777
778 let res_json: serde_json::Value = serde_json::from_str(
779 &response
780 .text()
781 .await
782 .context("failed reading the response")?,
783 )
784 .context("failed interpreting the response as json")?;
785
786 let continue_offset = res_json
787 .get("continue")
788 .and_then(|x| x.get("sroffset"))
789 .and_then(|x| x.as_u64().map(|x| x as usize));
790
791 let total_hits = res_json
792 .get("query")
793 .and_then(|x| x.get("searchinfo"))
794 .and_then(|x| x.get("totalhits"))
795 .and_then(|x| x.as_u64().map(|x| x as usize));
796
797 let suggestion = res_json
798 .get("query")
799 .and_then(|x| x.get("searchinfo"))
800 .and_then(|x| x.get("suggestion"))
801 .and_then(|x| x.as_str().map(|x| x.to_string()));
802
803 let rewritten_query = res_json
804 .get("query")
805 .and_then(|x| x.get("searchinfo"))
806 .and_then(|x| x.get("rewrittenquery"))
807 .and_then(|x| x.as_str().map(|x| x.to_string()));
808
809 let results: Vec<SearchResult> = {
810 let mut results: Vec<SearchResult> = Vec::new();
811 let results_json = res_json
812 .get("query")
813 .and_then(|x| x.get("search"))
814 .and_then(|x| x.as_array())
815 .ok_or_else(|| anyhow!("missing the search results"))?
816 .to_owned();
817
818 macro_rules! value_from_json {
819 ($result: ident, $val: expr) => {
820 serde_json::from_value($result.get($val).map(|x| x.to_owned()).ok_or_else(
821 || {
822 anyhow!(
823 "couldn't find '{}'
824 in the result",
825 stringify!($val)
826 )
827 },
828 )?)?
829 };
830 }
831
832 for result in results_json.into_iter() {
833 results.push(SearchResult {
834 namespace: value_from_json!(result, "ns"),
835 title: value_from_json!(result, "title"),
836 pageid: value_from_json!(result, "pageid"),
837 language: self.language.0,
838 endpoint: self.endpoint.0.clone(),
839 size: value_from_json!(result, "size"),
840 wordcount: value_from_json!(result, "wordcount"),
841 snippet: value_from_json!(result, "snippet"),
842 timestamp: value_from_json!(result, "timestamp"),
843 })
844 }
845
846 results
847 };
848
849 let info = SearchInfo {
850 complete: continue_offset.is_none(),
851 total_hits,
852 suggestion,
853 rewritten_query,
854 query: self.query.0,
855 language: self.language.0,
856 };
857
858 Ok(Search {
859 continue_offset,
860 results,
861 endpoint: self.endpoint.0,
862 info,
863 })
864 }
865}