1use crate::areas::Area;
12use crate::config::{Account, AccountKind, Config};
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16pub const TYPES: [&str; 6] = ["mailbox", "chat", "video", "social", "dating", "other"];
20
21pub 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
29pub 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#[derive(Debug, Clone, PartialEq, Serialize)]
41pub struct Site {
42 pub id: String,
43 pub name: String,
44 pub url: String,
45 pub kind: String,
47 pub categories: Vec<String>,
49 pub realtime: bool,
50 pub muted: bool,
51 pub background: bool,
53 pub microphone: bool,
56 pub camera: bool,
57 pub screen: bool,
58 pub announced_by: Vec<String>,
60 pub area: Area,
62 pub personal: bool,
64}
65
66pub 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
72pub 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
81pub 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 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
126pub fn is_call(title: &str, text: &str) -> bool {
130 is_call_with(&crate::words::current().calls, title, text)
131}
132
133pub 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
142pub fn sites(config: &Config) -> Vec<Site> {
145 config.sites.iter().chain(config.accounts.iter()).filter_map(Site::of).collect()
146}
147
148pub fn announcing<'a>(sites: &'a [Site], sender: &str) -> Option<&'a Site> {
150 sites.iter().find(|s| s.announced(sender))
151}
152
153#[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 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 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 let excess = self.notices.len().saturating_sub(200);
196 self.notices.drain(..excess);
197 }
198
199 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 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 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}