Skip to main content

phemios_core/encyclopedia/
biography_cache.rs

1//! Disposable public biography copies, separate from explicitly saved account-private articles.
2use 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/// One native-owned, bounded file; edition and exact attributed title are checked inside every entry.
13/// The native bridge serializes access. No path or owner identifier comes from the WebView.
14#[derive(Clone)]
15pub struct Store {
16    path: PathBuf,
17}
18impl Store {
19    /// Construct without reading or creating files.
20    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    /// Read a retained copy without network traffic, including after application restart.
52    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    /// Revalidate public identity/revision on explicit reopen; fetch full text only when absent/changed.
145    /// A failed revalidation leaves the retained copy intact, so the reader can report it while offline.
146    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, &params)
165            } else {
166                self.request(lang, &params)
167            }
168        })
169    }
170}
171#[cfg(test)]
172#[path = "biography_cache_tests.rs"]
173mod tests;