tools_interface/
missing_topics.rs

1//! # Missing Topics
2//! Module for interacting with the [Missing Topics tool](https://missingtopics.toolforge.org/).
3//! You can retrieve a list of missing topics for a page or category.
4//! There are blocking and async methods available.
5//!
6//! ## Example
7//! ```rust
8//! let mut mt = MissingTopics::new(Site::from_wiki("dewiki").unwrap())
9//!     .with_article("Biologie")
10//!     .no_template_links(true);
11//! mt.run().await.unwrap();
12//! mt.results()
13//!     .iter()
14//!     .for_each(|(title, count)| {
15//!        println!("{title} wanted {count} times");
16//!     });
17//! ```
18
19use crate::{Site, Tool, ToolsError};
20use async_trait::async_trait;
21use serde_json::Value;
22
23#[derive(Debug, Default, PartialEq)]
24pub struct MissingTopics {
25    site: Site,
26    category_depth: Option<u32>,
27    category: Option<String>,
28    article: Option<String>,
29    occurs_more_often_than: Option<u32>,
30    no_template_links: Option<bool>,
31    no_singles: bool,
32
33    url_used: String,
34    results: Vec<(String, u64)>,
35    tool_url: String,
36}
37
38impl MissingTopics {
39    /// Create a new MissingTopics object with the given site.
40    pub fn new(site: Site) -> Self {
41        Self {
42            site,
43            tool_url: "https://missingtopics.toolforge.org/".to_string(),
44            ..Default::default()
45        }
46    }
47
48    /// Set the category and category depth for the query.
49    /// The category depth is the number of subcategories to include.
50    /// Only one of category or article can be set.
51    pub fn with_category(mut self, category: &str, category_depth: u32) -> Self {
52        self.category = Some(category.into());
53        self.category_depth = Some(category_depth);
54        self
55    }
56
57    /// Set the article for the query.
58    /// Only one of category or article can be set.
59    pub fn with_article(mut self, article: &str) -> Self {
60        self.article = Some(article.into());
61        self
62    }
63
64    /// Any result must have more than the given number of occurrences.
65    pub fn limit(mut self, occurs_more_often_than: u32) -> Self {
66        self.no_singles = true;
67        self.occurs_more_often_than = Some(occurs_more_often_than);
68        self
69    }
70
71    /// Filter out links from templates used in category pages.
72    pub fn no_template_links(mut self, no_template_links: bool) -> Self {
73        self.no_template_links = Some(no_template_links);
74        self
75    }
76
77    /// Get the URL used for the last query.
78    pub fn url_used(&self) -> &str {
79        &self.url_used
80    }
81
82    /// Get the results of the last query.
83    /// The results are a list of tuples with the missing article and the number of occurrences.
84    pub fn results(&self) -> &[(String, u64)] {
85        &self.results
86    }
87
88    /// Get the site used for the query.
89    pub fn site(&self) -> &Site {
90        &self.site
91    }
92}
93
94#[async_trait]
95impl Tool for MissingTopics {
96    fn generate_paramters(&self) -> Result<Vec<(String, String)>, ToolsError> {
97        let mut parameters: Vec<(String, String)> = [
98            ("language".to_string(), self.site.language().to_string()),
99            ("project".to_string(), self.site.project().to_string()),
100            ("doit".to_string(), "Run".to_string()),
101            ("wikimode".to_string(), "json".to_string()),
102        ]
103        .to_vec();
104
105        if self.category.is_some() && self.category_depth.is_some() && self.article.is_some() {
106            return Err(ToolsError::Tool(
107                "Only one of category or article can be set".to_string(),
108            ));
109        }
110        if let (Some(category), Some(category_depth)) = (&self.category, &self.category_depth) {
111            parameters.push(("category".to_string(), category.to_string()));
112            parameters.push(("depth".to_string(), category_depth.to_string()));
113        } else if let Some(article) = &self.article {
114            parameters.push(("article".to_string(), article.to_string()));
115        } else {
116            return Err(ToolsError::Tool(
117                "Either category or article must be set".to_string(),
118            ));
119        }
120        match self.no_singles {
121            true => parameters.push(("nosingles".to_string(), "1".to_string())),
122            false => parameters.push(("nosingles".to_string(), "0".to_string())),
123        }
124        match self.no_template_links {
125            Some(true) => parameters.push(("no_template_links".to_string(), "1".to_string())),
126            Some(false) => parameters.push(("no_template_links".to_string(), "0".to_string())),
127            _ => {}
128        }
129        if let Some(occurs_more_often_than) = self.occurs_more_often_than {
130            parameters.push(("limitnum".to_string(), occurs_more_often_than.to_string()));
131        }
132        Ok(parameters)
133    }
134
135    #[cfg(feature = "tokio")]
136    /// Run the query asynchronously.
137    async fn run(&mut self) -> Result<(), ToolsError> {
138        let url = &self.tool_url;
139        let parameters = self.generate_paramters()?;
140        let client = crate::ToolsInterface::tokio_client()?;
141        let response = client.get(url).query(&parameters).send().await?;
142        let j: Value = response.json().await?;
143        self.from_json(j)
144    }
145
146    #[cfg(feature = "blocking")]
147    /// Run the query in a blocking manner.
148    fn run_blocking(&mut self) -> Result<(), ToolsError> {
149        let url = &self.tool_url;
150        let parameters = self.generate_paramters()?;
151        let client = crate::ToolsInterface::blocking_client()?;
152        let j: Value = client.get(url).query(&parameters).send()?.json()?;
153        self.from_json(j)
154    }
155
156    fn from_json(&mut self, j: Value) -> Result<(), ToolsError> {
157        if j["status"].as_str() != Some("OK") {
158            return Err(ToolsError::Tool(format!(
159                "MissingTopics status is not OK: {:?}",
160                j["status"]
161            )));
162        }
163        self.results = j["results"]
164            .as_object()
165            .ok_or(ToolsError::Json("['results'] has no object".into()))?
166            .iter()
167            .filter_map(|(k, v)| Some((k.to_string(), v.as_u64()?)))
168            .collect();
169        self.url_used = j["url"]
170            .as_str()
171            .ok_or(ToolsError::Json("['url'] is missing".into()))?
172            .to_string();
173        Ok(())
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::Site;
181    use serde_json::json;
182    use wiremock::matchers::{method, path, query_param_contains};
183    use wiremock::{Mock, MockServer, ResponseTemplate};
184
185    async fn get_mock_server() -> MockServer {
186        let mock_path = format!("/");
187        let mock_server = MockServer::start().await;
188        Mock::given(method("GET"))
189            .and(query_param_contains("language","de"))
190            .and(query_param_contains("project","wikipedia"))
191            .and(query_param_contains("article","Biologie"))
192            .and(query_param_contains("language","de"))
193            .and(query_param_contains("doit","Run"))
194            .and(query_param_contains("wikimode","json"))
195            .and(query_param_contains("no_template_links","1"))
196            .and(path(&mock_path))
197            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"results":{"Ethnobiologie":4,"Landschaftsdiversität":3,"Micrographia":4,"Spezielle_Botanik":6,"Wbetavirus":4,"Zellphysiologie":4},"status":"OK","url":"https://missingtopics.toolforge.org/?language=de&project=wikipedia&depth=1&category=&article=Biologie&wikimode=json&limitnum=1&notemplatelinks=0"})))
198            .mount(&mock_server)
199            .await;
200        mock_server
201    }
202
203    #[cfg(feature = "tokio")]
204    #[tokio::test]
205    async fn test_missing_topics_run_async() {
206        let mock_server = get_mock_server().await;
207        let mut mt = MissingTopics::new(Site::from_wiki("dewiki").unwrap())
208            .with_article("Biologie")
209            .no_template_links(true);
210        mt.tool_url = format!("{}/", mock_server.uri());
211        mt.run().await.unwrap();
212        assert_eq!(mt.results.len(), 6);
213        assert_eq!(mt.results[5].0, "Zellphysiologie");
214        assert_eq!(mt.results[5].1, 4);
215        assert_eq!(mt.url_used, "https://missingtopics.toolforge.org/?language=de&project=wikipedia&depth=1&category=&article=Biologie&wikimode=json&limitnum=1&notemplatelinks=0")
216    }
217}