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