phemios_core/encyclopedia/
biography_cache.rs1use super::{
3 Client, article_result, checked_response, language, page_id, title,
4 translation::checked_article_title,
5};
6use crate::cache::{read, write_atomic};
7use serde_json::{Value, json};
8use std::path::PathBuf;
9const LIMIT: u64 = 12 * 1024 * 1024;
10const COUNT: usize = 40;
11const INVALID: &str = "Cache des biographies illisible.";
12#[derive(Clone)]
15pub struct Store {
16 path: PathBuf,
17}
18impl Store {
19 pub fn new(root: PathBuf) -> Self {
21 Self {
22 path: root.join("biographies.json"),
23 }
24 }
25 fn rows(&self) -> Result<Vec<Value>, String> {
26 let Some(bytes) = read(&self.path, LIMIT)? else {
27 return Ok(Vec::new());
28 };
29 let mut value: Value = serde_json::from_slice(&bytes).map_err(|_| INVALID)?;
30 if value["version"] != 1 {
31 return Err(INVALID.into());
32 }
33 let rows = value
34 .get_mut("rows")
35 .and_then(Value::as_array_mut)
36 .filter(|rows| rows.len() <= COUNT)
37 .ok_or(INVALID)?;
38 let mut keys = std::collections::HashSet::new();
39 for row in rows.iter() {
40 let lang = row["language"].as_str().ok_or(INVALID)?;
41 let name = row["name"].as_str().ok_or(INVALID)?;
42 language(lang)?;
43 checked_article_title(name)?;
44 validate(&row["article"], lang)?;
45 if !keys.insert((lang, name)) {
46 return Err(INVALID.into());
47 }
48 }
49 Ok(std::mem::take(rows))
50 }
51 pub fn get(&self, lang: &str, name: &str) -> Result<Option<Value>, String> {
53 language(lang)?;
54 checked_article_title(name)?;
55 Ok(self
56 .rows()?
57 .into_iter()
58 .find(|row| row["language"] == lang && row["name"] == name)
59 .map(|mut row| row["article"].take()))
60 }
61 fn save(&self, lang: &str, name: &str, article: &Value) -> Result<(), String> {
62 validate(article, lang)?;
63 let mut rows = self.rows()?;
64 rows.retain(|row| row["language"] != lang || row["name"] != name);
65 rows.push(json!({"language":lang,"name":name,"article":article}));
66 loop {
67 let bytes =
68 serde_json::to_vec(&json!({"version":1,"rows":rows})).map_err(|_| INVALID)?;
69 if rows.len() <= COUNT && bytes.len() as u64 <= LIMIT {
70 return write_atomic(&self.path, &bytes);
71 }
72 rows.remove(0);
73 }
74 }
75 fn refresh(
76 &self,
77 lang: &str,
78 name: &str,
79 mut request: impl FnMut(bool) -> Result<Value, String>,
80 ) -> Result<Value, String> {
81 let retained = self.get(lang, name)?;
82 if let Some(article) = retained {
83 let metadata = request(false)?;
84 let page = single_page(&metadata)?;
85 let id = page_id(page["pageid"].as_u64().ok_or(INVALID)?)?;
86 let revision = page_id(page["lastrevid"].as_u64().ok_or(INVALID)?)?;
87 if article["pageId"] == id
88 && article["revision"] == revision
89 && article["title"] == title(page)?
90 && article.get("externalLinks").is_some()
91 {
92 return Ok(article);
93 }
94 }
95 let response = request(true)?;
96 let page = single_page(&response)?;
97 let at = std::time::SystemTime::now()
98 .duration_since(std::time::UNIX_EPOCH)
99 .map_err(|_| "Horloge système invalide.")?
100 .as_millis();
101 let article = article_result(
102 &response,
103 lang,
104 page_id(page["pageid"].as_u64().ok_or(INVALID)?)?,
105 u64::try_from(at).map_err(|_| INVALID)?,
106 )?;
107 self.save(lang, name, &article)?;
108 Ok(article)
109 }
110}
111fn single_page(value: &Value) -> Result<&Value, String> {
112 let pages = checked_response(value)?["pages"]
113 .as_array()
114 .filter(|pages| pages.len() == 1)
115 .ok_or(INVALID)?;
116 let row = &pages[0];
117 if row["ns"] != 0 || row.get("missing").is_some() || row.get("invalid").is_some() {
118 return Err("Article Wikipédia indisponible.".into());
119 }
120 Ok(row)
121}
122fn validate(article: &Value, lang: &str) -> Result<(), String> {
123 let id = page_id(article["pageId"].as_u64().ok_or(INVALID)?)?;
124 let at = article["retrievedAt"]
125 .as_u64()
126 .filter(|at| *at > 0 && *at <= 8_640_000_000_000_000)
127 .ok_or(INVALID)?;
128 let mut response = json!({"query":{"pages":[{"ns":0,"pageid":id,"title":article["title"],"lastrevid":article["revision"],"extract":article["text"]}]}});
129 if let Some(links) = article.get("externalLinks") {
130 response["query"]["pages"][0]["externalLinks"] = links.clone();
131 }
132 let checked = article_result(&response, lang, id, at)?;
133 if checked != *article
134 || article["text"].as_str().is_none_or(|text| {
135 text.chars()
136 .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
137 })
138 {
139 return Err(INVALID.into());
140 }
141 Ok(())
142}
143impl Client {
144 pub fn retained_biography(
147 &self,
148 store: &Store,
149 lang: &str,
150 name: &str,
151 ) -> Result<Value, String> {
152 language(lang)?;
153 checked_article_title(name)?;
154 store.refresh(lang, name, |text| {
155 let mut params = vec![
156 ("titles", name),
157 ("redirects", "1"),
158 ("prop", if text { "extracts|info" } else { "info" }),
159 ];
160 if text {
161 params.extend([("explaintext", "1"), ("exsectionformat", "wiki")]);
162 }
163 if text {
164 self.request_article(lang, ¶ms)
165 } else {
166 self.request(lang, ¶ms)
167 }
168 })
169 }
170}
171#[cfg(test)]
172#[path = "biography_cache_tests.rs"]
173mod tests;