Skip to main content

sioul_core/
presets.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sites people commonly keep, offered when adding one (docs/sites.md,
5//! "Presets"): per country, per state or province where it matters, and for
6//! everyone (chats, video calls, social networks, dating). Each says its type
7//! (how it behaves) and the categories it comes with (what it is about). A plain JSON file anyone can update
8//! without building Sioul (`presets/sites.json`; `tools/check-presets.py`
9//! checks every address): the newest, by its `checked` date, of the copy
10//! built in, one in `$XDG_DATA_DIRS/sioul/presets/`, and yours in
11//! `$XDG_DATA_HOME/sioul/presets/`.
12
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::PathBuf;
16
17/// Words in several languages: "en" → "Bank", "fr" → "Banque".
18pub type Words = BTreeMap<String, String>;
19
20/// The words in `language`, else in English, else any.
21pub fn words(words: &Words, language: &str) -> String {
22    words.get(language).or_else(|| words.get("en")).or_else(|| words.values().next()).cloned().unwrap_or_default()
23}
24
25#[derive(Debug, Clone, Default, Deserialize)]
26pub struct Presets {
27    #[serde(default)]
28    pub version: u32,
29    /// When the addresses were last checked: "2026-10-04". The newest file wins.
30    #[serde(default)]
31    pub checked: String,
32    /// The categories presets come with, by id: their names in each language.
33    #[serde(default)]
34    pub tags: BTreeMap<String, Tag>,
35    /// For everyone: chats, video calls, social networks, dating.
36    #[serde(default)]
37    pub international: Vec<Preset>,
38    /// By ISO 3166-1 code: "FR", "CA", "US".
39    #[serde(default)]
40    pub countries: BTreeMap<String, Country>,
41}
42
43#[derive(Debug, Clone, Default, Deserialize)]
44pub struct Tag {
45    pub name: Words,
46}
47
48#[derive(Debug, Clone, Default, Deserialize)]
49pub struct Country {
50    pub name: Words,
51    #[serde(default)]
52    pub sites: Vec<Preset>,
53    /// States and provinces, by their ISO 3166-2 code without the country: "QC".
54    #[serde(default)]
55    pub regions: BTreeMap<String, Region>,
56    /// What the regions are called, as a menu says it: "By state", "By province".
57    #[serde(default)]
58    pub regions_name: Words,
59}
60
61#[derive(Debug, Clone, Default, Deserialize)]
62pub struct Region {
63    pub name: Words,
64    #[serde(default)]
65    pub sites: Vec<Preset>,
66}
67
68/// One site offered.
69#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
70pub struct Preset {
71    pub name: String,
72    /// Where you sign in to your own space, not the home page.
73    pub url: String,
74    /// One of `sites::TYPES`.
75    #[serde(rename = "type")]
76    pub kind: String,
77    /// The categories it comes with, by id (`Presets::tags`).
78    #[serde(default)]
79    pub tags: Vec<String>,
80    /// What it is for, when not its type's: "work" for a work chat.
81    #[serde(default)]
82    pub area: String,
83    /// The domains its mail comes from, when it announces something waiting there.
84    #[serde(default)]
85    pub announced_by: Vec<String>,
86    #[serde(default)]
87    pub about: Words,
88    /// Voice and video calls in the browser.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub calls: Option<bool>,
91}
92
93const BUILT_IN: &str = include_str!("../../../presets/sites.json");
94
95/// The presets built into this Sioul.
96pub fn built_in() -> Presets {
97    serde_json::from_str(BUILT_IN).unwrap_or_default()
98}
99
100/// Where newer presets may be: yours first, then the system's.
101fn places() -> Vec<PathBuf> {
102    let mut places = vec![crate::config::data_dir().join("presets").join("sites.json")];
103    // The system's folders: XDG's; on Windows, none unless said (its lists split at ";").
104    let system = std::env::var_os("XDG_DATA_DIRS").unwrap_or_else(|| if cfg!(windows) { Default::default() } else { "/usr/local/share:/usr/share".into() });
105    places.extend(std::env::split_paths(&system).filter(|d| !d.as_os_str().is_empty()).map(|d| d.join("sioul").join("presets").join("sites.json")));
106    places
107}
108
109/// The newest presets: of the copy built in and those on disk, the one checked last.
110pub fn load() -> Presets {
111    let mut best = built_in();
112    for path in places() {
113        if let Some(found) = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str::<Presets>(&t).ok())
114            && found.checked > best.checked
115        {
116            best = found;
117        }
118    }
119    best
120}
121
122/// Where an offered site comes from: everyone's, a country's, a region's.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
124pub struct Scope {
125    pub country: String,
126    pub region: String,
127}
128
129impl Presets {
130    /// The sites offered for a country and a region (codes; "" for none): the
131    /// region's, the country's, then everyone's, each with where it comes from.
132    pub fn offered(&self, country: &str, region: &str) -> Vec<(&Preset, Scope)> {
133        let mut out = Vec::new();
134        if let Some(c) = self.countries.get(country) {
135            if let Some(r) = c.regions.get(region) {
136                out.extend(r.sites.iter().map(|p| (p, Scope { country: country.into(), region: region.into() })));
137            }
138            out.extend(c.sites.iter().map(|p| (p, Scope { country: country.into(), region: String::new() })));
139        }
140        out.extend(self.international.iter().map(|p| (p, Scope { country: String::new(), region: String::new() })));
141        out
142    }
143
144    /// What a preset is for: its own area, else as its type goes.
145    pub fn area_of(&self, preset: &Preset) -> crate::areas::Area {
146        crate::areas::Area::parse(&preset.area).unwrap_or_else(|| crate::sites::usual_area(&preset.kind))
147    }
148
149    /// A preset's categories, in your words: "Banque", "Santé".
150    pub fn tag_words(&self, preset: &Preset, language: &str) -> Vec<String> {
151        preset.tags.iter().map(|id| self.tags.get(id).map_or_else(|| id.clone(), |t| words(&t.name, language))).collect()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn the_built_in_presets_read() {
161        // Read strictly: a mistake in the file says where.
162        let presets: Presets = serde_json::from_str(BUILT_IN).unwrap();
163        assert_eq!(presets.version, 1);
164        assert!(!presets.checked.is_empty());
165        for tag in presets.tags.values() {
166            assert!(!words(&tag.name, "fr").is_empty() && !words(&tag.name, "en").is_empty());
167        }
168        let all = presets.countries.values().flat_map(|c| c.sites.iter().chain(c.regions.values().flat_map(|r| r.sites.iter()))).chain(presets.international.iter());
169        for preset in all {
170            assert!(preset.url.starts_with("https://"), "{}: {}", preset.name, preset.url);
171            assert!(crate::sites::TYPES.contains(&preset.kind.as_str()), "{}: {}", preset.name, preset.kind);
172            assert!(preset.tags.iter().all(|t| presets.tags.contains_key(t)), "{}: {:?}", preset.name, preset.tags);
173            assert!(preset.area.is_empty() || crate::areas::Area::parse(&preset.area).is_some(), "{}: {}", preset.name, preset.area);
174        }
175    }
176
177    #[test]
178    fn regions_then_the_country_then_everyone() {
179        let presets: Presets = serde_json::from_str(r#"{"version":1,"checked":"2026-10-04","tags":{"health":{"name":{"en":"Health","fr":"Santé"}}},"international":[{"name":"Chat","url":"https://chat.example.org/","type":"chat"}],"countries":{"CA":{"name":{"en":"Canada"},"sites":[{"name":"Tax","url":"https://tax.example.org/","type":"mailbox"}],"regions":{"QC":{"name":{"fr":"Québec"},"sites":[{"name":"Health","url":"https://health.example.org/","type":"mailbox","tags":["health"]}]}}}}}"#).unwrap();
180        let names: Vec<&str> = presets.offered("CA", "QC").iter().map(|(p, _)| p.name.as_str()).collect();
181        assert_eq!(names, vec!["Health", "Tax", "Chat"]);
182        assert_eq!(presets.offered("CA", "").len(), 2);
183        assert_eq!(presets.offered("", "").len(), 1);
184        assert_eq!(words(&presets.countries["CA"].regions["QC"].name, "en"), "Québec", "any language when the one asked is missing");
185        assert_eq!(presets.area_of(&presets.international[0]), crate::areas::Area::LEISURE);
186        let health = &presets.countries["CA"].regions["QC"].sites[0];
187        assert_eq!(presets.tag_words(health, "fr"), vec!["Santé"]);
188        assert_eq!(presets.area_of(health), crate::areas::Area::ADMIN, "a mailbox is your admin");
189    }
190}