1use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::PathBuf;
16
17pub type Words = BTreeMap<String, String>;
19
20pub 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 #[serde(default)]
31 pub checked: String,
32 #[serde(default)]
34 pub tags: BTreeMap<String, Tag>,
35 #[serde(default)]
37 pub international: Vec<Preset>,
38 #[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 #[serde(default)]
55 pub regions: BTreeMap<String, Region>,
56 #[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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
70pub struct Preset {
71 pub name: String,
72 pub url: String,
74 #[serde(rename = "type")]
76 pub kind: String,
77 #[serde(default)]
79 pub tags: Vec<String>,
80 #[serde(default)]
82 pub area: String,
83 #[serde(default)]
85 pub announced_by: Vec<String>,
86 #[serde(default)]
87 pub about: Words,
88 #[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
95pub fn built_in() -> Presets {
97 serde_json::from_str(BUILT_IN).unwrap_or_default()
98}
99
100fn places() -> Vec<PathBuf> {
102 let mut places = vec![crate::config::data_dir().join("presets").join("sites.json")];
103 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
109pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
124pub struct Scope {
125 pub country: String,
126 pub region: String,
127}
128
129impl Presets {
130 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 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 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 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}