Skip to main content

sioul_core/
sites.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sites kept open in Sioul: secure mailboxes (a bank's, a hospital's, the
5//! tax office's), chats, a dating site. Each is a portal account; this module
6//! says what each is, which mail announces it, and keeps what they notified,
7//! to be shown at your pace (`$XDG_STATE_HOME/sioul/site-notices.toml`): a
8//! site's notification is always accepted, then waits, unless the site is in
9//! real time.
10
11use crate::areas::Area;
12use crate::config::{Account, AccountKind, Config};
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16/// What a site is, as it behaves: a secure mailbox or a client area (a
17/// bank's, the tax office's), a chat, video calls, a social network, a dating
18/// site, another site. Your own categories ("Banque", "Santé") come on top.
19pub const TYPES: [&str; 6] = ["mailbox", "chat", "video", "social", "dating", "other"];
20
21/// A type as written, as one of `TYPES`; an office, a bank, a supplier is a mailbox.
22pub fn type_of(text: &str) -> &'static str {
23    match text.trim().to_lowercase().as_str() {
24        "" | "mailbox" | "admin" | "bank" | "energy" | "telecom" => "mailbox",
25        other => TYPES.iter().find(|t| **t == other).copied().unwrap_or("other"),
26    }
27}
28
29/// What a site of a type is for, unless you said: secure mailboxes are your
30/// admin; chats, social networks and dating, leisure; calls and the rest, yours either way.
31pub fn usual_area(kind: &str) -> Area {
32    match type_of(kind) {
33        "mailbox" => Area::ADMIN,
34        "chat" | "social" | "dating" => Area::LEISURE,
35        _ => Area::PERSONAL,
36    }
37}
38
39/// One site, as the Sites page shows it.
40#[derive(Debug, Clone, PartialEq, Serialize)]
41pub struct Site {
42    pub id: String,
43    pub name: String,
44    pub url: String,
45    /// One of `TYPES`.
46    pub kind: String,
47    /// Your own categories: any words, the list filters by them.
48    pub categories: Vec<String>,
49    pub realtime: bool,
50    pub muted: bool,
51    /// Kept open while the window is: chats by default.
52    pub background: bool,
53    /// For calls: the microphone, the camera, sharing the screen; given to
54    /// chats, video calls and dating sites unless you said otherwise, to no other.
55    pub microphone: bool,
56    pub camera: bool,
57    pub screen: bool,
58    /// The domains whose mail announces it.
59    pub announced_by: Vec<String>,
60    /// What it is for: as you said, else as its category goes (`usual_area`).
61    pub area: Area,
62    /// Yours outside work too: admin or leisure.
63    pub personal: bool,
64}
65
66/// The host of an address: `https://mail.proton.me/u/0` → `mail.proton.me`.
67pub fn host_of(url: &str) -> String {
68    let rest = url.split("://").nth(1).unwrap_or(url);
69    rest.split(['/', '?', '#']).next().unwrap_or("").split('@').next_back().unwrap_or("").split(':').next().unwrap_or("").to_lowercase()
70}
71
72/// A site's address as compared with another's: its host, path and query,
73/// without the scheme, a final "/" or a fragment. Two services on one host
74/// (canada.ca's accounts, a network and its messages) stay two.
75pub fn place_of(url: &str) -> String {
76    let rest = url.split("://").nth(1).unwrap_or(url).split('#').next().unwrap_or("");
77    let (host, path) = rest.split_at(rest.find(['/', '?']).unwrap_or(rest.len()));
78    format!("{}{}", host_of(&format!("https://{host}")), path.trim_end_matches('/'))
79}
80
81/// The domain under which a host lives, roughly: its last two labels, three
82/// for the few countries that register under a second level ("co.uk").
83pub fn domain_of(host: &str) -> String {
84    let labels: Vec<&str> = host.split('.').filter(|l| !l.is_empty()).collect();
85    let second_level = labels.len() >= 3 && matches!(labels[labels.len() - 2], "co" | "com" | "gov" | "org" | "ac" | "net" | "gouv");
86    let keep = if second_level { 3 } else { 2 };
87    labels[labels.len().saturating_sub(keep)..].join(".")
88}
89
90impl Site {
91    pub fn of(account: &Account) -> Option<Site> {
92        if account.kind != AccountKind::Portal {
93            return None;
94        }
95        let url = account.url.clone().unwrap_or_default();
96        let kind = type_of(account.site.as_deref().unwrap_or("")).to_string();
97        let area = account.area.as_deref().and_then(Area::parse).unwrap_or_else(|| usual_area(&kind));
98        let calls = matches!(kind.as_str(), "chat" | "video" | "dating");
99        let announced_by = if account.announced_by.is_empty() { vec![domain_of(&host_of(&url))] } else { account.announced_by.iter().map(|d| d.trim().trim_start_matches('@').to_lowercase()).collect() };
100        Some(Site {
101            id: account.id.clone(),
102            name: account.name.clone().unwrap_or_else(|| account.id.clone()),
103            background: account.background.unwrap_or(kind == "chat"),
104            microphone: account.microphone.unwrap_or(calls),
105            camera: account.camera.unwrap_or(calls),
106            screen: account.screen.unwrap_or(calls),
107            realtime: account.realtime,
108            muted: account.muted,
109            personal: area.admin || area.leisure,
110            area,
111            categories: account.categories.iter().map(|c| c.trim().to_string()).filter(|c| !c.is_empty()).collect(),
112            url,
113            kind,
114            announced_by,
115        })
116    }
117
118    /// Whether mail from this address announces something waiting on the site.
119    pub fn announced(&self, address: &str) -> bool {
120        let address = address.trim().to_lowercase();
121        let domain = address.rsplit_once('@').map_or(address.as_str(), |(_, d)| d);
122        self.announced_by.iter().any(|d| !d.is_empty() && (domain == d || domain.ends_with(&format!(".{d}")) || address == *d))
123    }
124}
125
126/// Whether a site's notification rings: someone calling now, in the words
127/// chats use (`words::current`: the configuration's). A call waits for
128/// nobody; a missed one can.
129pub fn is_call(title: &str, text: &str) -> bool {
130    is_call_with(&crate::words::current().calls, title, text)
131}
132
133/// `is_call` with these words: a missed call can wait, video or voice
134/// ("Missed video call", "Appel vidéo manqué"); one ringing cannot. Each
135/// word is matched whole (`words::holds_word`), as docs/words.md says: "vous
136/// appelle" rings, never inside "vous appellerez".
137pub fn is_call_with(words: &crate::words::CallWords, title: &str, text: &str) -> bool {
138    let said: String = format!("{title} {text}").chars().map(crate::text::fold_char).collect();
139    !crate::words::holds_word(&words.missed, &said) && crate::words::holds_word(&words.ringing, &said)
140}
141
142/// Every site, in the order of the configuration: `[[site]]`, and those an
143/// older file kept among the accounts when it was not read by `Config::load`.
144pub fn sites(config: &Config) -> Vec<Site> {
145    config.sites.iter().chain(config.accounts.iter()).filter_map(Site::of).collect()
146}
147
148/// The site a message comes from, when its sender announces one.
149pub fn announcing<'a>(sites: &'a [Site], sender: &str) -> Option<&'a Site> {
150    sites.iter().find(|s| s.announced(sender))
151}
152
153/// One notification of a site, kept until you have seen it.
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
155pub struct Notice {
156    pub site: String,
157    pub title: String,
158    #[serde(default)]
159    pub text: String,
160    /// When it came, Unix seconds.
161    pub at: i64,
162}
163
164#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
165pub struct Notices {
166    #[serde(default, rename = "notice")]
167    pub notices: Vec<Notice>,
168}
169
170impl Notices {
171    pub fn default_path() -> PathBuf {
172        crate::config::state_dir().join("site-notices.toml")
173    }
174
175    pub fn load(path: &Path) -> Notices {
176        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
177    }
178
179    pub fn save(&self, path: &Path) -> Result<(), String> {
180        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
181        if let Some(parent) = path.parent() {
182            std::fs::create_dir_all(parent).map_err(fail)?;
183        }
184        let temporary = path.with_extension("toml.new");
185        std::fs::write(&temporary, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
186        std::fs::rename(&temporary, path).map_err(fail)
187    }
188
189    /// Keeps a notification; the same one told twice (a chat repeats itself) is kept once.
190    pub fn add(&mut self, notice: Notice) {
191        if !self.notices.iter().any(|n| n.site == notice.site && n.title == notice.title && n.text == notice.text) {
192            self.notices.push(notice);
193        }
194        // A long absence does not pile up: the newest two hundred.
195        let excess = self.notices.len().saturating_sub(200);
196        self.notices.drain(..excess);
197    }
198
199    /// A site visited: what it notified is seen.
200    pub fn seen(&mut self, site: &str) {
201        self.notices.retain(|n| n.site != site);
202    }
203
204    pub fn of(&self, site: &str) -> Vec<&Notice> {
205        self.notices.iter().filter(|n| n.site == site).collect()
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn calls_ring_through() {
215        assert!(is_call("Appel vidéo entrant", "Camille"));
216        assert!(is_call("Camille", "is calling you…"));
217        assert!(!is_call("Appel manqué", "Camille"), "a missed call can wait");
218        assert!(!is_call("Missed video call", "Camille") && !is_call("Appel vidéo manqué", "Camille"), "a missed video call too");
219        assert!(!is_call("Camille", "Did you call the bank?"));
220        // Whole words, as docs/words.md says: never inside a longer word.
221        assert!(!is_call("Rappel", "Vous appellerez la banque demain"), "« vous appelle » inside « vous appellerez »");
222        assert!(!is_call("Settings", "Incoming calls are now silenced") && !is_call("Demo", "Video calling is back"));
223        assert!(is_call("Camille vous appelle…", "") && is_call("Incoming call", "Camille"));
224        // Your words: a Dutch chat's ring, once added.
225        let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.calls.ringing]\nadd = [\"Inkomende oproep\"]\n").unwrap();
226        let yours = crate::words::Words::of(&config);
227        assert!(is_call_with(&yours.calls, "Inkomende oproep", "Camille"));
228        assert!(!is_call_with(&crate::words::Words::builtin().calls, "Inkomende oproep", "Camille"));
229    }
230
231    #[test]
232    fn places_compared() {
233        assert_eq!(place_of("https://WWW.Instagram.com/"), "www.instagram.com");
234        assert_eq!(place_of("https://www.instagram.com/direct/inbox/#x"), "www.instagram.com/direct/inbox");
235        assert_ne!(place_of("https://www.canada.ca/en/revenue-agency/a.html"), place_of("https://www.canada.ca/en/immigration/b.html"), "two services on one host");
236        assert_ne!(place_of("https://power.example.com/Login?op=AL"), place_of("https://power.example.com/Login?op=GA"));
237    }
238
239    #[test]
240    fn types_and_areas() {
241        assert_eq!(type_of(""), "mailbox", "the default");
242        assert_eq!(type_of(" Bank "), "mailbox", "a bank's space is a mailbox");
243        assert_eq!(type_of("Social"), "social");
244        assert_eq!(type_of("forum"), "other");
245        assert_eq!(usual_area("chat"), Area::LEISURE);
246        assert_eq!(usual_area("social"), Area::LEISURE);
247        assert_eq!(usual_area("mailbox"), Area::ADMIN);
248        assert_eq!(usual_area("video"), Area::PERSONAL);
249        let config: Config = toml::from_str("[[account]]\nid = \"chat\"\nkind = \"portal\"\nurl = \"https://chat.example.org\"\nsite = \"chat\"\ncamera = false\n\n[[account]]\nid = \"bank\"\nkind = \"portal\"\nurl = \"https://bank.example.org\"\nsite = \"bank\"\n").unwrap();
250        let all = sites(&config);
251        assert_eq!((all[0].microphone, all[0].camera, all[0].screen), (true, false, true), "a chat has them, the camera said no");
252        assert_eq!((all[1].microphone, all[1].camera, all[1].screen), (false, false, false), "a bank has none");
253    }
254
255    #[test]
256    fn sites_and_what_announces_them() {
257        let config: Config = toml::from_str(
258            "[[account]]\nid = \"proton\"\nkind = \"portal\"\nurl = \"https://mail.proton.me/u/0\"\n\n[[account]]\nid = \"element\"\nkind = \"portal\"\nname = \"Element\"\nurl = \"https://app.element.io\"\nsite = \"chat\"\nrealtime = true\n\n[[account]]\nid = \"bank\"\nkind = \"portal\"\nurl = \"https://secure.bank.example.co.uk/login\"\nannounced_by = [\"@alerts.example.net\"]\n",
259        )
260        .unwrap();
261        let all = sites(&config);
262        assert_eq!(all.iter().map(|s| (s.id.as_str(), s.kind.as_str(), s.background)).collect::<Vec<_>>(), vec![("proton", "mailbox", false), ("element", "chat", true), ("bank", "mailbox", false)]);
263        assert_eq!(all[0].announced_by, vec!["proton.me"]);
264        assert_eq!(announcing(&all, "notify@proton.me").map(|s| s.id.as_str()), Some("proton"));
265        assert_eq!(announcing(&all, "no-reply@mail.alerts.example.net").map(|s| s.id.as_str()), Some("bank"));
266        assert!(announcing(&all, "jane@example.org").is_none());
267        assert_eq!(domain_of("secure.bank.example.co.uk"), "example.co.uk");
268        assert_eq!(host_of("https://user@app.element.io:443/#/room"), "app.element.io");
269        let mut notices = Notices::default();
270        notices.add(Notice { site: "element".into(), title: "Jane".into(), text: "Lunch?".into(), at: 1 });
271        notices.add(Notice { site: "element".into(), title: "Jane".into(), text: "Lunch?".into(), at: 2 });
272        notices.add(Notice { site: "proton".into(), title: "New message".into(), text: String::new(), at: 3 });
273        assert_eq!(notices.of("element").len(), 1, "told twice, kept once");
274        notices.seen("element");
275        assert_eq!(notices.notices.len(), 1);
276    }
277}