1use crate::areas::{Area, in_view};
43use crate::attention::{self, Attention, Event, Level, Source};
44use crate::i18n::Translator;
45use crate::quiet::Mode;
46use crate::reach::{Channel, Who};
47use jiff::Zoned;
48use serde::{Deserialize, Serialize};
49use std::collections::BTreeMap;
50use std::path::{Path, PathBuf};
51
52pub const OWN: &str = "com.aurelienpierre.sioul";
54pub const GRACE: i64 = 60;
57pub const HORIZON_DAYS: i64 = 8;
59pub const RECHECK: i64 = 6 * 3600;
63pub const GATE_RECHECK: i64 = 30 * 60;
65pub const FOR_GOOD: i64 = 7 * 86_400;
67pub const AGAIN_SHORT: i64 = 5 * 60;
69pub const AGAIN_MAX: u32 = 3;
73const SEEN_KEPT: i64 = 30 * 86_400;
75const TALK_KEPT: i64 = 7 * 86_400;
77const TALKS_MAX: usize = 60;
79const BACK_SAID: i64 = 2 * 3600;
81
82#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
87#[serde(default)]
88pub struct Person {
89 pub name: String,
90 pub key: String,
92 pub uri: String,
94 pub numbers: Vec<String>,
97 pub emails: Vec<String>,
98 pub by_name: Vec<String>,
102 pub emails_by_name: Vec<String>,
103 pub bot: bool,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
108#[serde(default)]
109pub struct Message {
110 pub sender: Option<Person>,
111 pub text: String,
112}
113
114#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
117#[serde(default)]
118pub struct Posted {
119 pub key: String,
121 pub package: String,
122 pub app: String,
124 pub channel: String,
125 pub channel_name: String,
126 pub alerted: bool,
128 #[serde(default = "unknown_importance")]
131 pub importance: i32,
132 pub intercepted: bool,
134 pub category: String,
136 pub tag: String,
137 pub group: String,
138 pub summary: bool,
140 pub ongoing: bool,
142 pub full_screen: bool,
144 pub template: String,
146 pub title: String,
147 pub text: String,
148 pub sub: String,
149 pub big: String,
150 pub lines: Vec<String>,
151 pub conversation: String,
153 pub group_conversation: bool,
154 pub is_conversation: bool,
156 pub shortcut: String,
158 pub messages: Vec<Message>,
159 pub user: Option<Person>,
161 pub people: Vec<Person>,
163 pub redacted: bool,
166 pub sms_app: bool,
168}
169
170fn unknown_importance() -> i32 {
171 -1
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum Sender {
179 Known { numbers: Vec<String>, emails: Vec<String>, contact: bool },
183 Named { name: String, book: bool },
187 Unknown,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Conversation {
194 pub key: String,
196 pub title: String,
198 pub group: bool,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct Talk {
204 pub via: Channel,
205 pub sender: Sender,
206 pub conversation: Option<Conversation>,
207 pub to: String,
209 pub sms: bool,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
216pub enum Kind {
217 Untouched,
219 Code,
221 AtOnce,
223 People(Talk),
224 Automaton { site: String },
226}
227
228const UNTOUCHED: &[&str] = &[
230 "call", "alarm", "navigation", "transport", "stopwatch", "location_sharing", "sys", "service", "progress", "status", "err", "car_emergency",
231 "car_warning", "car_information", "workout", "reminder", "event",
232];
233
234const BOOK_APPS: &[&str] = &["com.whatsapp", "com.whatsapp.w4b"];
236
237const SMS_APPS: &[&str] = &[
239 "com.google.android.apps.messaging", "com.android.messaging", "com.android.mms", "com.samsung.android.messaging", "org.fossify.messages",
240 "com.simplemobiletools.smsmessenger", "com.moez.QKSMS", "dev.octoshrimpy.quik", "foundation.e.message",
241];
242
243const MAIL_APPS: &[&str] = &[
245 "ch.protonmail.android", "com.google.android.gm", "com.fsck.k9", "net.thunderbird.android", "net.thunderbird.android.beta", "eu.faircode.email",
246 "com.microsoft.office.outlook", "foundation.e.mail", "de.tutao.tutanota", "org.kman.AquaMail", "com.samsung.android.email.provider",
247 "com.yahoo.mobile.client.android.mail", "me.proton.android.mail",
248];
249
250const FIREFOX_SITES: &str = "mozac.feature.webnotifications.generic.channel";
252
253pub fn is_code(p: &Posted, words: &crate::words::Words) -> bool {
260 if p.redacted {
261 return true;
262 }
263 let mut body = vec![p.text.as_str(), p.big.as_str(), p.sub.as_str()];
264 body.extend(p.lines.iter().map(String::as_str));
265 body.extend(p.messages.iter().map(|m| m.text.as_str()));
266 let body = body.into_iter().filter(|t| !t.trim().is_empty()).collect::<Vec<_>>().join("\n");
267 if crate::codes::detect(&words.codes, &p.title, &body).is_some() {
268 return true;
269 }
270 let folded: Vec<char> = crate::text::fold(&format!("{}\n{body}", p.title)).into_iter().map(|c| if c == '\n' { ' ' } else { c }).collect();
271 let said = |phrases: &[String]| phrases.iter().any(|phrase| crate::text::find_word(&folded, phrase, 0).is_some());
272 let approvals = &words.approvals;
273 if said(&approvals.phrases) || (said(&approvals.asks) && said(&approvals.asked_about)) {
274 return true;
275 }
276 let channel: Vec<char> = crate::text::fold(&format!("{} {}", p.channel.replace(['_', '.'], " "), p.channel_name));
277 approvals.channels.iter().any(|word| crate::text::find_word(&channel, word, 0).is_some())
278}
279
280pub fn site_of(p: &Posted) -> Option<String> {
285 let tagged = p.tag.strip_prefix("p#").or_else(|| p.tag.strip_prefix("n#")).and_then(|rest| rest.split('#').next());
286 let channel = p.channel.strip_prefix("web:").and_then(|rest| rest.split(';').next());
287 let grouped = p.group.strip_prefix("Web:");
288 let firefox = (p.channel == FIREFOX_SITES).then_some(p.sub.as_str());
289 [tagged, channel, grouped, firefox].into_iter().flatten().map(host_of).find(|h| !h.is_empty())
290}
291
292fn host_of(origin: &str) -> String {
294 let rest = origin.trim().split_once("://").map_or(origin.trim(), |(_, rest)| rest);
295 let host = rest.split(['/', '?', '#']).next().unwrap_or("");
296 let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
297 let host = host.split(':').next().unwrap_or("").trim_end_matches('.').to_lowercase();
298 let fine = host.contains('.') && host.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-');
299 if fine { host } else { String::new() }
300}
301
302pub fn number_in(text: &str) -> Option<String> {
306 let text = text.trim().trim_start_matches('\u{202a}').trim_end_matches('\u{202c}').trim();
307 let fine = !text.is_empty() && text.chars().enumerate().all(|(i, c)| c.is_ascii_digit() || " .-()/\u{a0}\u{202f}".contains(c) || (c == '+' && i == 0));
308 let digits = text.chars().filter(char::is_ascii_digit).count();
309 (fine && digits >= 7).then(|| text.to_string())
310}
311
312fn short_code(text: &str) -> bool {
314 let text = text.trim();
315 !text.is_empty() && text.len() <= 6 && text.chars().all(|c| c.is_ascii_digit())
316}
317
318fn address_in(text: &str) -> Option<String> {
320 let text = text.trim();
321 let inner = text.rsplit_once('<').and_then(|(_, rest)| rest.split_once('>')).map_or(text, |(a, _)| a).trim();
322 let (local, domain) = inner.split_once('@')?;
323 let fine = !local.is_empty() && domain.contains('.') && !inner.contains(char::is_whitespace);
324 fine.then(|| inner.to_lowercase())
325}
326
327fn sender_of(person: &Person, book: bool) -> Sender {
330 let mut numbers = person.numbers.clone();
331 let mut emails = person.emails.clone();
332 if book && person.uri.trim().is_empty() {
333 numbers.extend(person.by_name.iter().cloned());
334 emails.extend(person.emails_by_name.iter().cloned());
335 }
336 let contact = !numbers.is_empty() || !emails.is_empty();
337 let uri = person.uri.trim();
338 if let Some(number) = uri.strip_prefix("tel:") {
339 numbers.insert(0, percent_decoded(number));
340 } else if let Some(address) = uri.strip_prefix("mailto:") {
341 emails.insert(0, percent_decoded(address).to_lowercase());
342 }
343 numbers.retain(|n| !n.trim().is_empty());
344 emails.retain(|e| !e.trim().is_empty());
345 if !numbers.is_empty() || !emails.is_empty() {
346 return Sender::Known { numbers, emails, contact };
347 }
348 if let Some(number) = number_in(&person.name) {
350 return Sender::Known { numbers: vec![number], emails: Vec::new(), contact: false };
351 }
352 if let Some(address) = address_in(&person.name) {
353 return Sender::Known { numbers: Vec::new(), emails: vec![address], contact: false };
354 }
355 let name = one_line(&person.name);
356 if name.is_empty() { Sender::Unknown } else { Sender::Named { name, book } }
357}
358
359fn percent_decoded(text: &str) -> String {
361 let bytes = text.as_bytes();
362 let mut out = Vec::with_capacity(bytes.len());
363 let mut i = 0;
364 while i < bytes.len() {
365 let hex = (bytes[i] == b'%').then(|| bytes.get(i + 1..i + 3)).flatten().and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
366 match hex {
367 Some(byte) => {
368 out.push(byte);
369 i += 3;
370 }
371 None => {
372 out.push(bytes[i]);
373 i += 1;
374 }
375 }
376 }
377 String::from_utf8_lossy(&out).to_string()
378}
379
380fn one_line(text: &str) -> String {
382 text.split_whitespace().collect::<Vec<_>>().join(" ")
383}
384
385fn fnv(text: &str) -> u64 {
386 text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
387}
388
389pub fn key_hash(key: &str) -> String {
392 format!("{:016x}", fnv(key))
393}
394
395pub fn talk_key(package: &str, id: &str) -> String {
397 format!("{:016x}", fnv(&format!("{package}\u{1f}{id}")))
398}
399
400fn conversation_of(p: &Posted, sender: Option<&Person>, sms: bool) -> Option<Conversation> {
405 let mut writers: Vec<String> = p.messages.iter().filter_map(|m| m.sender.as_ref()).filter(|s| !is_user(p, s)).map(|s| if s.key.trim().is_empty() { one_line(&s.name) } else { s.key.trim().to_string() }).filter(|w| !w.is_empty()).collect();
407 writers.sort();
408 writers.dedup();
409 let group = p.group_conversation || writers.len() >= 2;
410 let title = [p.conversation.as_str(), sender.map_or("", |s| s.name.as_str()), p.title.as_str()].into_iter().map(one_line).find(|t| !t.is_empty()).unwrap_or_default();
411 let from = sender.map_or("", |s| if s.key.trim().is_empty() { s.name.trim() } else { s.key.trim() });
412 let plain = if sms { p.title.trim() } else { "" };
413 let id = [p.shortcut.trim(), p.conversation.trim(), from, plain].into_iter().find(|i| !i.is_empty())?;
414 Some(Conversation { key: talk_key(&p.package, id), title, group })
415}
416
417fn is_user(p: &Posted, sender: &Person) -> bool {
420 p.user.as_ref().is_some_and(|user| {
421 let (key, name) = (user.key.trim(), one_line(&user.name));
422 (!key.is_empty() && key == sender.key.trim()) || (!name.is_empty() && name == one_line(&sender.name))
423 })
424}
425
426pub fn classify(p: &Posted, choices: &Choices, words: &crate::words::Words) -> Kind {
429 if p.package == OWN || p.ongoing || p.full_screen || p.summary || UNTOUCHED.contains(&p.category.as_str()) {
430 return Kind::Untouched;
431 }
432 if is_code(p, words) {
433 return Kind::Code;
434 }
435 let app = choices.app(&p.package);
436 if app.kind == AppKind::AtOnce {
437 return Kind::AtOnce;
438 }
439 let site = site_of(p);
440 if let Some(site) = &site
441 && choices.site.get(site).is_some_and(|s| s.at_once)
442 {
443 return Kind::AtOnce;
444 }
445 let automaton = Kind::Automaton { site: site.clone().unwrap_or_default() };
446 if app.kind == AppKind::Automaton || (app.kind == AppKind::Usual && site.is_some()) {
447 return automaton;
448 }
449 let mail = MAIL_APPS.contains(&p.package.as_str()) || p.category == "email";
450 let sms = p.sms_app || SMS_APPS.contains(&p.package.as_str());
451 let via = if matches!(p.category.as_str(), "missed_call" | "voicemail") {
452 Channel::Calls
453 } else if mail {
454 Channel::Mail
455 } else {
456 Channel::Messages
457 };
458 let styled = p.template.ends_with("$MessagingStyle") || !p.messages.is_empty() || p.is_conversation || !p.shortcut.trim().is_empty();
459 let identified = p.people.iter().any(|person| person.uri.starts_with("tel:") || person.uri.starts_with("mailto:"));
460 let between = app.kind == AppKind::People || via != Channel::Messages || styled || identified || sms || p.category == "msg";
461 let advert = matches!(p.category.as_str(), "promo" | "social" | "recommendation");
462 if !between || (advert && app.kind != AppKind::People) {
463 return automaton;
464 }
465 let book = sms || via == Channel::Calls || BOOK_APPS.contains(&p.package.as_str());
467 let last = p.messages.iter().rev().find_map(|m| m.sender.as_ref().filter(|s| !is_user(p, s)));
469 let person = last.or_else(|| p.people.first());
470 if person.is_some_and(|s| s.bot) && app.kind != AppKind::People {
472 return automaton;
473 }
474 let sender = match person {
475 Some(person) => sender_of(person, book),
476 None => {
477 let title = one_line(&p.title);
478 if sms && short_code(&title) {
479 return automaton;
480 }
481 sender_of(&Person { name: title, ..Person::default() }, book)
482 }
483 };
484 if sms && let Sender::Named { name, .. } = &sender
485 && short_code(name)
486 {
487 return automaton;
488 }
489 let to = if via == Channel::Mail { address_in(&p.sub).unwrap_or_default() } else { String::new() };
490 let conversation = if via == Channel::Messages { conversation_of(p, person, sms) } else { None };
491 Kind::People(Talk { via, sender, conversation, to, sms })
492}
493
494pub fn named(found: Option<Who>, book: bool) -> Who {
500 match found {
501 Some(who) if book => who,
502 Some(who @ (Who::Restricted | Who::Blocked)) => who,
503 _ => Who::Stranger,
504 }
505}
506
507#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(rename_all = "kebab-case")]
512pub enum AppKind {
513 #[default]
515 Usual,
516 People,
518 Automaton,
520 AtOnce,
522}
523
524impl AppKind {
525 pub const ALL: [AppKind; 4] = [AppKind::Usual, AppKind::People, AppKind::Automaton, AppKind::AtOnce];
526
527 pub fn id(self) -> &'static str {
528 match self {
529 AppKind::Usual => "usual",
530 AppKind::People => "people",
531 AppKind::Automaton => "automaton",
532 AppKind::AtOnce => "at-once",
533 }
534 }
535
536 pub fn read(id: &str) -> Option<AppKind> {
537 AppKind::ALL.into_iter().find(|k| k.id() == id)
538 }
539}
540
541#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
543#[serde(rename_all = "kebab-case")]
544pub enum Through {
545 #[default]
547 Usual,
548 Always,
550 Gathered,
552 Never,
554}
555
556impl Through {
557 pub const ALL: [Through; 4] = [Through::Usual, Through::Always, Through::Gathered, Through::Never];
558
559 pub fn id(self) -> &'static str {
560 match self {
561 Through::Usual => "usual",
562 Through::Always => "always",
563 Through::Gathered => "gathered",
564 Through::Never => "never",
565 }
566 }
567
568 pub fn read(id: &str) -> Option<Through> {
569 Through::ALL.into_iter().find(|t| t.id() == id)
570 }
571}
572
573#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
574pub struct AppChoice {
575 #[serde(default)]
576 pub kind: AppKind,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub area: Option<Area>,
581 #[serde(default, skip_serializing_if = "String::is_empty")]
583 pub label: String,
584}
585
586#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
587pub struct TalkChoice {
588 #[serde(default)]
589 pub through: Through,
590 #[serde(default, skip_serializing_if = "String::is_empty")]
592 pub title: String,
593 #[serde(default, skip_serializing_if = "String::is_empty")]
594 pub app: String,
595}
596
597#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
598pub struct SiteChoice {
599 #[serde(default)]
600 pub at_once: bool,
601}
602
603#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
606pub struct Choices {
607 #[serde(default = "yes")]
609 pub hold: bool,
610 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
612 pub app: BTreeMap<String, AppChoice>,
613 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
615 pub conversation: BTreeMap<String, TalkChoice>,
616 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
618 pub site: BTreeMap<String, SiteChoice>,
619}
620
621fn yes() -> bool {
622 true
623}
624
625impl Default for Choices {
626 fn default() -> Choices {
627 Choices { hold: true, app: BTreeMap::new(), conversation: BTreeMap::new(), site: BTreeMap::new() }
628 }
629}
630
631impl Choices {
632 pub fn default_path() -> PathBuf {
633 crate::config::config_dir().join("app-notes.toml")
634 }
635
636 pub fn load(path: &Path) -> Choices {
638 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
639 }
640
641 pub fn save(&self, path: &Path) -> Result<(), String> {
642 write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
643 }
644
645 pub fn app(&self, package: &str) -> AppChoice {
646 self.app.get(package).cloned().unwrap_or_default()
647 }
648
649 pub fn through(&self, conversation: &str) -> Through {
650 self.conversation.get(conversation).map_or(Through::Usual, |c| c.through)
651 }
652}
653
654fn write_whole(path: &Path, text: &str) -> Result<(), String> {
656 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
657 if let Some(parent) = path.parent() {
658 std::fs::create_dir_all(parent).map_err(fail)?;
659 }
660 let temporary = path.with_extension(format!("toml.{}", std::process::id()));
661 std::fs::write(&temporary, text).map_err(fail)?;
662 std::fs::rename(&temporary, path).map_err(fail)
663}
664
665pub trait TimeSource {
670 fn mode(&self, at: &Zoned) -> Mode;
671 fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned>;
674}
675
676#[derive(Debug, Clone)]
678pub struct Live {
679 pub clock: crate::reach::Clock,
680}
681
682impl TimeSource for Live {
683 fn mode(&self, at: &Zoned) -> Mode {
684 self.clock.mode(at)
685 }
686
687 fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned> {
688 let stamp = from.timestamp().as_second();
689 self.clock.frames(from, until).into_iter().filter(|f| f.start > stamp).filter_map(|f| jiff::Timestamp::from_second(f.start).ok()).map(|t| t.to_zoned(from.time_zone().clone())).collect()
690 }
691}
692
693pub struct Gate {
696 pub until: Option<i64>,
698}
699
700pub struct Ask<'a> {
702 pub now: &'a Zoned,
703 pub clock: &'a dyn TimeSource,
704 pub attention: &'a Attention,
706 pub gathered: &'a [String],
708 pub area: Option<Area>,
710 pub gate: Option<Gate>,
711 pub slot_at: &'a dyn Fn(&Zoned) -> bool,
713 pub nothing: bool,
715 pub also: &'a [i64],
718 pub sources: &'a [String],
721}
722
723impl Ask<'_> {
724 fn stamp(&self) -> i64 {
725 self.now.timestamp().as_second()
726 }
727
728 fn gated(&self, at: &Zoned) -> bool {
730 self.gate.as_ref().is_some_and(|g| at.timestamp().as_second() < g.until.unwrap_or(self.stamp() + GATE_RECHECK))
731 }
732
733 fn moments(&self) -> Vec<Zoned> {
737 let stamp = self.stamp();
738 let Ok(until) = self.now.checked_add(jiff::Span::new().hours(HORIZON_DAYS * 24)) else { return Vec::new() };
739 let mut moments = self.clock.starts(self.now, &until);
740 let gate_end = self.gate.as_ref().map(|g| g.until.unwrap_or(stamp + GATE_RECHECK));
741 for second in self.also.iter().copied().chain(gate_end).filter(|s| *s > stamp && *s < until.timestamp().as_second()) {
742 if let Ok(t) = jiff::Timestamp::from_second(second) {
743 moments.push(t.to_zoned(self.now.time_zone().clone()));
744 }
745 }
746 moments.sort_by_key(Zoned::timestamp);
747 moments.dedup_by_key(|z| z.timestamp());
748 moments
749 }
750
751 fn moment(&self, at: &Zoned, mode: &Mode) -> attention::Now {
754 let mut now = attention::Now::of(mode).layers((self.slot_at)(at), self.gated(at));
755 now.nothing = self.nothing && mode.free();
756 now
757 }
758
759 fn level(&self, kind: attention::Kind, at: &Zoned, mode: &Mode) -> Level {
761 self.attention.level(attention::Row::Own(kind), &self.moment(at, mode))
762 }
763
764 fn area_fits(&self, mode: &Mode) -> bool {
766 self.area.is_none_or(|a| in_view(a, mode.time, mode.week))
767 }
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
772#[serde(rename_all = "kebab-case")]
773pub enum Why {
774 Untouched,
776 Code,
778 Times,
780 Off,
782 AtOnce,
784 Allowed,
786 Always,
788 Waiting,
790 Gathered,
792 Never,
794 Again,
796}
797
798impl Why {
799 pub fn id(self) -> &'static str {
800 match self {
801 Why::Untouched => "untouched",
802 Why::Code => "code",
803 Why::Times => "times",
804 Why::Off => "off",
805 Why::AtOnce => "at-once",
806 Why::Allowed => "allowed",
807 Why::Always => "always",
808 Why::Waiting => "waiting",
809 Why::Gathered => "gathered",
810 Why::Never => "never",
811 Why::Again => "again",
812 }
813 }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
818pub struct Decision {
819 pub until: Option<i64>,
821 pub why: Why,
822}
823
824impl Decision {
825 pub fn through(why: Why) -> Decision {
826 Decision { until: None, why }
827 }
828
829 pub fn held(until: i64, why: Why) -> Decision {
830 Decision { until: Some(until), why }
831 }
832}
833
834pub fn first_moment(ask: &Ask, fits: &dyn Fn(&Zoned, &Mode) -> bool) -> Option<Zoned> {
837 if fits(ask.now, &ask.clock.mode(ask.now)) {
838 return Some(ask.now.clone());
839 }
840 ask.moments().into_iter().find(|at| fits(at, &ask.clock.mode(at)))
841}
842
843pub fn times_of(gathered: &[String]) -> Vec<jiff::civil::Time> {
846 let read = |text: &str| {
847 let (h, m) = text.trim().split_once([':', 'h', 'H'])?;
848 let (h, m) = (h.trim().parse::<i8>().ok()?, if m.trim().is_empty() { 0 } else { m.trim().parse::<i8>().ok()? });
849 jiff::civil::Time::new(h, m, 0, 0).ok()
850 };
851 let mut times: Vec<jiff::civil::Time> = gathered.iter().filter_map(|t| read(t)).collect();
852 if times.is_empty() {
853 times = [9, 13, 18].iter().filter_map(|h| jiff::civil::Time::new(*h, 0, 0, 0).ok()).collect();
854 }
855 times.sort();
856 times.dedup();
857 times
858}
859
860pub fn next_gathering(ask: &Ask) -> Option<Zoned> {
866 let times = times_of(ask.gathered);
867 let zone = ask.now.time_zone().clone();
868 let stamp = ask.stamp();
869 for day in 0..=HORIZON_DAYS {
870 let Ok(date) = ask.now.date().checked_add(jiff::Span::new().days(day)) else { continue };
871 for time in × {
872 let Ok(at) = date.to_datetime(*time).to_zoned(zone.clone()) else { continue };
873 if at.timestamp().as_second() + GRACE <= stamp {
874 continue;
875 }
876 let mode = ask.clock.mode(&at);
877 if ask.level(attention::Kind::AppAutomatons, &at, &mode) == Level::Gathered && ask.area_fits(&mode) {
878 return Some(at);
879 }
880 }
881 }
882 None
883}
884
885fn gatherings(ask: &Ask) -> Vec<Zoned> {
888 let zone = ask.now.time_zone().clone();
889 let stamp = ask.stamp();
890 let times = times_of(ask.gathered);
891 let mut out = Vec::new();
892 for day in 0..=HORIZON_DAYS {
893 let Ok(date) = ask.now.date().checked_add(jiff::Span::new().days(day)) else { continue };
894 for time in × {
895 if let Ok(at) = date.to_datetime(*time).to_zoned(zone.clone())
896 && at.timestamp().as_second() + GRACE > stamp
897 {
898 out.push(at);
899 }
900 }
901 }
902 out
903}
904
905pub fn first_time(ask: &Ask, level_at: &dyn Fn(&Zoned, &Mode) -> Level) -> Option<Zoned> {
909 let mut candidates: Vec<(Zoned, bool)> = vec![(ask.now.clone(), false)];
910 candidates.extend(ask.moments().into_iter().map(|at| (at, false)));
911 candidates.extend(gatherings(ask).into_iter().map(|at| (at, true)));
912 candidates.sort_by_key(|(at, gathering)| (at.timestamp(), !*gathering));
913 candidates
914 .into_iter()
915 .find(|(at, gathering)| match level_at(at, &ask.clock.mode(at)) {
916 Level::Now => true,
917 Level::Gathered => *gathering,
918 _ => false,
919 })
920 .map(|(at, _)| at)
921}
922
923fn by_matrix(ask: &Ask, events: &[Event], now: Why, later: Why) -> Decision {
930 let stamp = ask.stamp();
931 let Some(first) = events.first() else { return Decision::through(now) };
932 let chosen = ask.attention.decide(first, &ask.moment(ask.now, &ask.clock.mode(ask.now))).step == attention::Step::Chosen;
933 let why = |usual: Why| if chosen { Why::Times } else { usual };
934 let next = events.iter().find_map(|event| first_time(ask, &|at, mode| ask.attention.decide(event, &ask.moment(at, mode)).level));
935 match next {
936 Some(at) if at.timestamp().as_second() <= stamp => Decision::through(why(now)),
937 Some(at) => Decision::held(at.timestamp().as_second(), why(later)),
938 None => Decision::held(stamp + RECHECK, why(later)),
939 }
940}
941
942fn gathered(ask: &Ask) -> Decision {
948 let event = Event::own(attention::Kind::AppAutomatons).for_area(ask.area).from_sources(ask.sources.to_vec());
949 by_matrix(ask, &[event], Why::Gathered, Why::Gathered)
950}
951
952fn at_once(ask: &Ask) -> Decision {
956 if ask.attention.has_sources(ask.sources) {
957 let event = Event::own(attention::Kind::AppAtOnce).for_area(ask.area).from_sources(ask.sources.to_vec());
958 return by_matrix(ask, &[event], Why::AtOnce, Why::AtOnce);
959 }
960 match first_moment(ask, &|at, mode| ask.level(attention::Kind::AppAtOnce, at, mode) == Level::Now && ask.area_fits(mode)) {
961 Some(at) if at.timestamp().as_second() <= ask.stamp() => Decision::through(Why::AtOnce),
962 Some(at) => Decision::held(at.timestamp().as_second(), Why::AtOnce),
963 None => Decision::held(ask.stamp() + RECHECK, Why::AtOnce),
964 }
965}
966
967fn person(talk: &Talk, who: Who, group: bool, always: bool, ask: &Ask) -> Decision {
973 if who == Who::Blocked {
974 return Decision::held(ask.stamp() + FOR_GOOD, Why::Never);
975 }
976 if ask.attention.has_sources(ask.sources) {
978 let event = Event::of(Source::Message { via: talk.via, who, group, always }).from_sources(ask.sources.to_vec());
979 return by_matrix(ask, &[event.clone().for_area(ask.area), event], Why::Allowed, Why::Waiting);
980 }
981 let open = |at: &Zoned, mode: &Mode, area: Option<Area>| ask.attention.decide(&Event::of(Source::Message { via: talk.via, who, group, always }).for_area(area), &ask.moment(at, mode)).told;
982 let next = first_moment(ask, &|at, mode| open(at, mode, ask.area)).or_else(|| first_moment(ask, &|at, mode| open(at, mode, None)));
984 match next {
985 Some(at) if at.timestamp().as_second() <= ask.stamp() => Decision::through(Why::Allowed),
986 Some(at) => Decision::held(at.timestamp().as_second(), Why::Waiting),
987 None => Decision::held(ask.stamp() + RECHECK, Why::Waiting),
988 }
989}
990
991fn always(talk: &Talk, who: Who, group: bool, ask: &Ask) -> Decision {
994 match person(talk, who, group, true, ask) {
995 Decision { until: None, why: Why::Allowed } => Decision::through(Why::Always),
996 other => other,
997 }
998}
999
1000pub fn decide(kind: &Kind, who: Option<Who>, listed: bool, choices: &Choices, ask: &Ask) -> Decision {
1006 match kind {
1007 Kind::Untouched => Decision::through(Why::Untouched),
1008 Kind::Code => Decision::through(Why::Code),
1009 _ if !choices.hold => Decision::through(Why::Off),
1010 Kind::AtOnce => at_once(ask),
1011 Kind::Automaton { .. } => gathered(ask),
1012 Kind::People(talk) => {
1013 if who == Some(Who::Blocked) {
1014 return Decision::held(ask.stamp() + FOR_GOOD, Why::Never);
1015 }
1016 let group = talk.conversation.as_ref().is_some_and(|c| c.group);
1018 let through = talk.conversation.as_ref().map_or(Through::Usual, |c| choices.through(&c.key));
1019 match through {
1020 Through::Always => always(talk, who.unwrap_or(Who::Stranger), group, ask),
1021 Through::Gathered => gathered(ask),
1022 Through::Never => Decision::held(ask.stamp() + FOR_GOOD, Why::Never),
1023 Through::Usual => {
1024 if talk.sms && who.is_none() && matches!(talk.sender, Sender::Named { .. }) {
1026 return gathered(ask);
1027 }
1028 person(talk, who.unwrap_or(Who::Stranger), group, listed && !group, ask)
1029 }
1030 }
1031 }
1032 }
1033}
1034
1035#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1040pub struct Held {
1041 pub app: String,
1043 pub label: String,
1044 pub kind: String,
1046 #[serde(default, skip_serializing_if = "String::is_empty")]
1048 pub via: String,
1049 #[serde(default, skip_serializing_if = "String::is_empty")]
1050 pub who: String,
1051 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1053 pub admitted: bool,
1054 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1056 pub group: bool,
1057 #[serde(default, skip_serializing_if = "String::is_empty")]
1058 pub conversation: String,
1059 #[serde(default, skip_serializing_if = "String::is_empty")]
1060 pub site: String,
1061 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub area: Option<Area>,
1063 pub since: i64,
1064 pub until: i64,
1065 #[serde(default, skip_serializing_if = "is_zero")]
1067 pub again: u32,
1068}
1069
1070fn is_zero(n: &u32) -> bool {
1071 *n == 0
1072}
1073
1074#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1076pub struct SeenApp {
1077 pub label: String,
1078 pub seen: i64,
1079 pub why: String,
1081 #[serde(default, skip_serializing_if = "is_zero_i64")]
1082 pub until: i64,
1083 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1086 pub rang: BTreeMap<String, Rang>,
1087}
1088
1089fn is_zero_i64(n: &i64) -> bool {
1090 *n == 0
1091}
1092
1093#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1094pub struct Rang {
1095 pub name: String,
1096 pub at: i64,
1097}
1098
1099#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1101pub struct SeenTalk {
1102 pub app: String,
1103 pub label: String,
1104 pub title: String,
1105 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1106 pub group: bool,
1107 pub seen: i64,
1108}
1109
1110#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1112pub struct SeenSite {
1113 pub app: String,
1114 pub seen: i64,
1115}
1116
1117#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1119pub struct Back {
1120 pub at: i64,
1121 pub apps: Vec<String>,
1122}
1123
1124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1126pub struct Ledger {
1127 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1129 pub held: BTreeMap<String, Held>,
1130 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1131 pub apps: BTreeMap<String, SeenApp>,
1132 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1133 pub conversations: BTreeMap<String, SeenTalk>,
1134 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1135 pub sites: BTreeMap<String, SeenSite>,
1136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1137 pub back: Vec<Back>,
1138 #[serde(default, skip_serializing_if = "is_zero_i64")]
1140 pub heard: i64,
1141}
1142
1143impl Ledger {
1144 pub fn default_path() -> PathBuf {
1145 crate::config::state_dir().join("app-notes-seen.toml")
1146 }
1147
1148 pub fn load(path: &Path) -> Ledger {
1150 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
1151 }
1152
1153 pub fn save(&self, path: &Path) -> Result<(), String> {
1154 write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
1155 }
1156
1157 pub fn note(&mut self, p: &Posted, kind: &Kind, who: Option<Who>, admitted: bool, area: Option<Area>, decision: &Decision, now: i64) {
1159 self.heard = now;
1160 let label = if p.app.trim().is_empty() { p.package.clone() } else { one_line(&p.app) };
1161 let app = self.apps.entry(p.package.clone()).or_default();
1162 app.label = label.clone();
1163 app.seen = now;
1164 app.why = decision.why.id().to_string();
1165 app.until = decision.until.unwrap_or(0);
1166 if !p.channel.is_empty() {
1168 if decision.until.is_some() && p.alerted && !p.intercepted {
1169 app.rang.insert(p.channel.clone(), Rang { name: one_line(&p.channel_name), at: now });
1170 } else if (0..3).contains(&p.importance) {
1171 app.rang.remove(&p.channel);
1172 }
1173 }
1174 match kind {
1175 Kind::People(talk) => {
1176 if let Some(c) = &talk.conversation {
1177 self.conversations.insert(c.key.clone(), SeenTalk { app: p.package.clone(), label: label.clone(), title: c.title.clone(), group: c.group, seen: now });
1178 }
1179 }
1180 Kind::Automaton { site } if !site.is_empty() => {
1181 self.sites.insert(site.clone(), SeenSite { app: p.package.clone(), seen: now });
1182 }
1183 _ => {}
1184 }
1185 let hash = key_hash(&p.key);
1186 let Some(until) = decision.until else {
1187 self.held.remove(&hash);
1188 return;
1189 };
1190 let (kind_id, via, conversation, site) = match kind {
1191 Kind::People(talk) => ("people", talk.via.id(), talk.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(), String::new()),
1192 Kind::Automaton { site } => ("gathered", "", String::new(), site.clone()),
1193 Kind::AtOnce => ("at-once", "", String::new(), String::new()),
1194 _ => ("gathered", "", String::new(), String::new()),
1195 };
1196 let kind_id = match decision.why {
1197 Why::Never => "never",
1198 Why::Gathered => "gathered",
1199 _ => kind_id,
1200 };
1201 let group = matches!(kind, Kind::People(t) if t.conversation.as_ref().is_some_and(|c| c.group));
1204 let admitted = admitted && !group;
1205 let again = self.held.get(&hash).map_or(0, |h| h.again);
1206 self.held.insert(hash, Held { app: p.package.clone(), label, kind: kind_id.into(), via: via.to_string(), who: who.map(|w| w.id().to_string()).unwrap_or_default(), admitted, group, conversation, site, area, since: now, until, again });
1207 }
1208
1209 pub fn came_back(&mut self, hash: &str, now: i64) {
1212 let Some(held) = self.held.remove(hash) else { return };
1213 match self.back.last_mut() {
1214 Some(back) if now - back.at < 120 => {
1215 if !back.apps.contains(&held.label) {
1216 back.apps.push(held.label);
1217 }
1218 }
1219 _ => self.back.push(Back { at: now, apps: vec![held.label] }),
1220 }
1221 }
1222
1223 pub fn returning(&self, hash: &str, now: i64) -> bool {
1225 self.held.get(hash).is_some_and(|h| now >= h.until - 120)
1226 }
1227
1228 pub fn forget_old(&mut self, now: i64, choices: &Choices) {
1232 self.held.retain(|_, h| h.until + 86_400 > now);
1233 self.apps.retain(|package, a| now - a.seen < SEEN_KEPT || choices.app.contains_key(package));
1234 self.sites.retain(|host, s| now - s.seen < SEEN_KEPT || choices.site.contains_key(host));
1235 self.conversations.retain(|key, c| now - c.seen < TALK_KEPT || choices.conversation.contains_key(key));
1236 if self.conversations.len() > TALKS_MAX {
1237 let mut by_age: Vec<(i64, String)> = self.conversations.iter().filter(|(k, _)| !choices.conversation.contains_key(*k)).map(|(k, c)| (c.seen, k.clone())).collect();
1238 by_age.sort();
1239 for (_, key) in by_age.into_iter().take(self.conversations.len() - TALKS_MAX) {
1240 self.conversations.remove(&key);
1241 }
1242 }
1243 for app in self.apps.values_mut() {
1244 app.rang.retain(|_, r| now - r.at < TALK_KEPT);
1245 }
1246 self.back.retain(|b| now - b.at < 86_400);
1247 }
1248}
1249
1250pub fn again(held: &Held, choices: &Choices, ask: &Ask) -> Decision {
1254 if !choices.hold {
1255 return Decision::through(Why::Off);
1256 }
1257 let app = choices.app(&held.app);
1258 if app.kind == AppKind::AtOnce || (!held.site.is_empty() && choices.site.get(&held.site).is_some_and(|s| s.at_once)) {
1259 return at_once(ask);
1260 }
1261 let who = Who::read(&held.who);
1262 if who == Some(Who::Blocked) {
1264 return Decision::held(held.until.max(ask.stamp() + 60), Why::Never);
1265 }
1266 let talk = Talk { via: Channel::read(&held.via).unwrap_or(Channel::Messages), sender: Sender::Unknown, conversation: None, to: String::new(), sms: false };
1267 if !held.conversation.is_empty() {
1268 match choices.through(&held.conversation) {
1269 Through::Always => return always(&talk, who.unwrap_or(Who::Stranger), held.group, ask),
1270 Through::Gathered => return gathered(ask),
1271 Through::Never => return Decision::held(held.until.max(ask.stamp() + 60), Why::Never),
1272 Through::Usual => {}
1273 }
1274 }
1275 match held.kind.as_str() {
1276 _ if app.kind == AppKind::Automaton => gathered(ask),
1277 "people" | "never" => person(&talk, who.unwrap_or(Who::Stranger), held.group, held.admitted, ask),
1278 _ => gathered(ask),
1279 }
1280}
1281
1282pub fn apps_in_words(tr: &Translator, labels: &[String]) -> String {
1288 let and = tr.text("word-and", None);
1289 match labels {
1290 [] => String::new(),
1291 [one] => one.clone(),
1292 [a, b] => format!("{a} {and} {b}"),
1293 [a, b, c] => format!("{a}, {b} {and} {c}"),
1294 [a, b, c, rest @ ..] => {
1295 let others = if rest.len() > 12 { tr.text("appnotes-others-many", None) } else { tr.text("appnotes-others", Some(&tr.counted(rest.len()))) };
1296 format!("{a}, {b}, {c} {and} {others}")
1297 }
1298 }
1299}
1300
1301pub fn porch_line(tr: &Translator, ledger: &Ledger, now: &Zoned) -> String {
1304 let stamp = now.timestamp().as_second();
1305 let mut sentences = Vec::new();
1306 let mut waiting: Vec<&Held> = ledger.held.values().filter(|h| h.kind != "never" && h.until > stamp).collect();
1307 waiting.sort_by_key(|h| (h.until, h.label.clone()));
1308 if let Some(first) = waiting.first() {
1309 let mut labels: Vec<String> = Vec::new();
1310 for h in &waiting {
1311 if !labels.contains(&h.label) {
1312 labels.push(h.label.clone());
1313 }
1314 }
1315 let mut args = crate::i18n::args();
1316 args.set("apps", apps_in_words(tr, &labels));
1317 args.set("when", when_text(tr, first.until, now));
1318 let one_time = waiting.iter().all(|h| h.until == first.until);
1319 sentences.push(tr.text(if one_time { "appnotes-porch-held" } else { "appnotes-porch-held-first" }, Some(&args)));
1320 }
1321 if let Some(back) = ledger.back.iter().rev().find(|b| stamp - b.at < BACK_SAID && !b.apps.is_empty()) {
1322 let mut args = crate::i18n::args();
1323 args.set("apps", apps_in_words(tr, &back.apps));
1324 args.set("when", when_text(tr, back.at, now));
1325 sentences.push(tr.text("appnotes-porch-back", Some(&args)));
1326 }
1327 sentences.join(" ")
1328}
1329
1330fn when_text(tr: &Translator, at: i64, now: &Zoned) -> String {
1332 match jiff::Timestamp::from_second(at) {
1333 Ok(t) => crate::quiet::until_text(tr, &t.to_zoned(now.time_zone().clone()), now),
1334 Err(_) => String::new(),
1335 }
1336}
1337
1338pub fn why_text(tr: &Translator, why: &str, until: i64, now: &Zoned) -> String {
1340 let mut args = crate::i18n::args();
1341 args.set("when", if until > 0 { when_text(tr, until, now) } else { String::new() });
1342 let held = until > now.timestamp().as_second();
1343 let id = match (why, held) {
1344 ("waiting", true) | ("gathered", true) | ("at-once", true) | ("never", true) | ("times", true) => format!("appnotes-why-{why}-held"),
1345 _ => format!("appnotes-why-{why}"),
1346 };
1347 tr.text(&id, Some(&args))
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353
1354 fn classify(p: &Posted, choices: &Choices) -> Kind {
1356 super::classify(p, choices, crate::words::Words::builtin_ref())
1357 }
1358
1359 fn is_code(p: &Posted) -> bool {
1360 super::is_code(p, crate::words::Words::builtin_ref())
1361 }
1362 use crate::attention::{Column, Person, Row};
1363 use crate::needs::{Days, Needs};
1364 use crate::quiet::{Blocks, Overrides};
1365 use crate::window::AdminWindow;
1366
1367 fn at(text: &str) -> Zoned {
1368 text.parse().unwrap()
1369 }
1370
1371 struct TestClock {
1375 overrides: Overrides,
1376 }
1377
1378 impl TestClock {
1379 fn new() -> TestClock {
1380 TestClock { overrides: Overrides::default() }
1381 }
1382 }
1383
1384 fn week() -> Vec<AdminWindow> {
1385 ["monday", "tuesday", "wednesday", "thursday", "friday"].iter().map(|d| AdminWindow { day: d.to_string(), start: "09:00".into(), end: Some("17:00".into()), minutes: 0, kind: None }).collect()
1386 }
1387
1388 impl TimeSource for TestClock {
1389 fn mode(&self, at: &Zoned) -> Mode {
1390 let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1391 crate::quiet::mode(&week(), &[], &self.overrides, &Blocks::of(&needs, &Days::default(), &[], at), at)
1392 }
1393
1394 fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned> {
1395 let mut out = Vec::new();
1397 let mut last = self.mode(from);
1398 let mut at = from.clone();
1399 while at.timestamp() < until.timestamp() {
1400 at = at.checked_add(jiff::Span::new().minutes(5)).unwrap();
1401 let mode = self.mode(&at);
1402 if (mode.time, mode.reason.clone()) != (last.time, last.reason.clone()) {
1403 out.push(at.clone());
1404 last = mode;
1405 }
1406 }
1407 out
1408 }
1409 }
1410
1411 fn never_slot(_: &Zoned) -> bool {
1412 false
1413 }
1414
1415 fn usual() -> &'static Attention {
1418 static USUAL: std::sync::OnceLock<Attention> = std::sync::OnceLock::new();
1419 USUAL.get_or_init(|| {
1420 let mut matrix = Attention::usual();
1421 for channel in [Channel::Mail, Channel::Messages] {
1422 matrix.set(Row::People(channel, Person::Neutral), Column::Leisure, Level::Now).unwrap();
1423 }
1424 matrix
1425 })
1426 }
1427
1428 fn ask<'a>(now: &'a Zoned, clock: &'a TestClock, area: Option<Area>, gate: Option<Gate>) -> Ask<'a> {
1429 static GATHERED: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1430 let gathered = GATHERED.get_or_init(|| vec!["09:00".into(), "13:00".into(), "18:00".into()]);
1431 Ask { now, clock, attention: usual(), gathered, area, gate, slot_at: &never_slot, nothing: false, also: &[], sources: &[] }
1432 }
1433
1434 fn posted(json: &str) -> Posted {
1435 serde_json::from_str(json).unwrap()
1436 }
1437
1438 fn until_of(d: Decision) -> String {
1439 d.until.map(|u| jiff::Timestamp::from_second(u).unwrap().to_zoned(jiff::tz::TimeZone::get("Europe/Paris").unwrap()).datetime().to_string()).unwrap_or_default()
1440 }
1441
1442 const SMS_NUMBER: &str = r#"{"key":"0|com.google.android.apps.messaging|1|sms|10001","package":"com.google.android.apps.messaging","app":"Messages","category":"msg","template":"android.app.Notification$MessagingStyle","title":"+262 6 39 98 01 01","text":"Tu es où ?","messages":[{"sender":{"name":"+262 6 39 98 01 01","key":"+262639980101"},"text":"Tu es où ?"}],"sms_app":true,"shortcut":"conv-41"}"#;
1444 const SMS_CONTACT: &str = r#"{"key":"0|com.google.android.apps.messaging|2|sms|10001","package":"com.google.android.apps.messaging","app":"Messages","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Maman","messages":[{"sender":{"name":"Maman","uri":"tel:%2B262639980102"},"text":"Rappelle-moi"}],"sms_app":true}"#;
1445 const SMS_SENDER_ID: &str = r#"{"key":"0|foundation.e.message|3|null|10002","package":"foundation.e.message","app":"Message","title":"AMELI","text":"Votre attestation est disponible dans votre compte.","messages":[{"sender":{"name":"AMELI"},"text":"Votre attestation est disponible dans votre compte."}],"sms_app":true}"#;
1446 const SMS_SHORT: &str = r#"{"key":"0|foundation.e.message|4|null|10002","package":"foundation.e.message","app":"Message","title":"38015","text":"Votre colis arrive demain entre 9h et 13h.","sms_app":true}"#;
1447 const SMS_CODE: &str = r#"{"key":"0|foundation.e.message|5|null|10002","package":"foundation.e.message","app":"Message","title":"BANQUE","text":"Votre code de vérification : 482913. Ne le communiquez à personne.","sms_app":true}"#;
1448 const WHATSAPP_PERSON: &str = r#"{"key":"0|com.whatsapp|1|262639980103@s.whatsapp.net|10100","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Jeanne Martin","shortcut":"262639980103@s.whatsapp.net","is_conversation":true,"messages":[{"sender":{"name":"Jeanne Martin","key":"1","uri":"content://com.android.contacts/contacts/lookup/0r1-ABC/1","numbers":["+262 6 39 98 01 03"]},"text":"On se voit demain ?"}]}"#;
1449 const WHATSAPP_GROUP: &str = r#"{"key":"0|com.whatsapp|1|120363000000000001@g.us|10100","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"École Jules Ferry","conversation":"École Jules Ferry","group_conversation":true,"shortcut":"120363000000000001@g.us","is_conversation":true,"messages":[{"sender":{"name":"Mme Durand"},"text":"Sortie annulée demain, les enfants restent à l'école."}]}"#;
1450 const DISCORD_SERVER: &str = r##"{"key":"0|com.discord|7|null|10200","package":"com.discord","app":"Discord","channel":"messages","channel_name":"Messages","category":"msg","group":"GROUP_MESSAGE_CREATE","template":"android.app.Notification$MessagingStyle","title":"Rust FR #général","conversation":"Rust FR #général","group_conversation":true,"shortcut":"110022003344556677","is_conversation":true,"messages":[{"sender":{"name":"ferris","key":"ferris"},"text":"Nouvelle version ce soir"}]}"##;
1451 const DISCORD_DM: &str = r#"{"key":"0|com.discord|8|null|10200","package":"com.discord","app":"Discord","channel":"dm","category":"msg","template":"android.app.Notification$MessagingStyle","title":"alice","shortcut":"110022003344556688","is_conversation":true,"messages":[{"sender":{"name":"alice","key":"alice"},"text":"tu as vu ?"}]}"#;
1452 const CHROME_SITE: &str = r#"{"key":"0|com.android.chrome|-1|p#https://forum.example.net#2|10300","package":"com.android.chrome","app":"Chrome","tag":"p#https://forum.example.net#2","channel":"web:https://forum.example.net;1759000000000","group":"Web:forum.example.net","title":"Nouvelle réponse","text":"Quelqu'un a répondu à votre sujet","sub":"forum.example.net"}"#;
1453 const FIREFOX_SITE: &str = r#"{"key":"0|org.mozilla.fennec_fdroid|5|a1b2|10301","package":"org.mozilla.fennec_fdroid","app":"Fennec","channel":"mozac.feature.webnotifications.generic.channel","title":"New post","text":"Your feed has news","sub":"news.example.com"}"#;
1454 const PROTON: &str = r#"{"key":"0|ch.protonmail.android|42|null|10400","package":"ch.protonmail.android","app":"Proton Mail","channel":"v7_email_channel_id","channel_name":"Emails","title":"Alice Martin","text":"Dîner vendredi","sub":"me@proton.example"}"#;
1455 const PROTON_LOGIN: &str = r#"{"key":"0|ch.protonmail.android|43|null|10400","package":"ch.protonmail.android","app":"Proton Mail","channel":"v7_login_channel_id","channel_name":"Login","title":"New sign-in to your account","text":"A new device signed in."}"#;
1456 const SHOP: &str = r#"{"key":"0|com.shop.example|1|null|10500","package":"com.shop.example","app":"Shop","category":"promo","channel":"offers","title":"-30 % sur les chaussures","text":"Jusqu'à dimanche seulement","alerted":true}"#;
1457 const BANK_CODE: &str = r#"{"key":"0|com.bank.example|1|null|10600","package":"com.bank.example","app":"Bank","title":"Your security code","text":"Your security code is 482913. It expires in 5 minutes."}"#;
1458 const BANK_APPROVAL: &str = r#"{"key":"0|com.bank.example|2|null|10600","package":"com.bank.example","app":"Banque","title":"Paiement en ligne","text":"Validez votre paiement de 42,00 € dans l'application."}"#;
1459 const REDACTED: &str = r#"{"key":"0|com.otp.example|1|null|10700","package":"com.otp.example","app":"Authenticator","title":"Authenticator","text":"Sensitive notification content hidden","redacted":true}"#;
1460
1461 #[test]
1462 fn what_a_notification_is() {
1463 let choices = Choices::default();
1464 let talk = |json: &str| match classify(&posted(json), &choices) {
1465 Kind::People(t) => t,
1466 other => panic!("{other:?}"),
1467 };
1468 let sms = talk(SMS_NUMBER);
1470 assert_eq!((sms.via, sms.sms), (Channel::Messages, true));
1471 assert_eq!(sms.sender, Sender::Known { numbers: vec!["+262 6 39 98 01 01".into()], emails: vec![], contact: false });
1472 assert_eq!(talk(SMS_CONTACT).sender, Sender::Known { numbers: vec!["+262639980102".into()], emails: vec![], contact: false });
1473 assert_eq!(talk(SMS_SENDER_ID).sender, Sender::Named { name: "AMELI".into(), book: true });
1474 assert_eq!(classify(&posted(SMS_SHORT), &choices), Kind::Automaton { site: String::new() });
1475 assert_eq!(classify(&posted(SMS_CODE), &choices), Kind::Code);
1476 let jeanne = talk(WHATSAPP_PERSON);
1478 assert_eq!(jeanne.sender, Sender::Known { numbers: vec!["+262 6 39 98 01 03".into()], emails: vec![], contact: true }, "the phone's contact");
1479 let school = talk(WHATSAPP_GROUP);
1480 let conversation = school.conversation.clone().unwrap();
1481 assert_eq!((conversation.title.as_str(), conversation.group), ("École Jules Ferry", true));
1482 assert_eq!(conversation.key, talk_key("com.whatsapp", "120363000000000001@g.us"));
1483 assert_eq!(school.sender, Sender::Named { name: "Mme Durand".into(), book: true });
1484 let by_name = r#"{"key":"k","package":"com.whatsapp","app":"WhatsApp","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Paul","messages":[{"sender":{"name":"Paul","by_name":["+262 6 39 98 01 04"]},"text":"Salut"}]}"#;
1486 assert_eq!(talk(by_name).sender, Sender::Known { numbers: vec!["+262 6 39 98 01 04".into()], emails: vec![], contact: true });
1487 let discord_by_name = by_name.replace("com.whatsapp", "com.discord");
1488 assert_eq!(talk(discord_by_name.as_str()).sender, Sender::Named { name: "Paul".into(), book: false });
1489 let bot = r#"{"key":"b","package":"com.bank.example","app":"Bank","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Assistant","messages":[{"sender":{"name":"Assistant","bot":true},"text":"Votre relevé est prêt."}]}"#;
1491 assert_eq!(classify(&posted(bot), &choices), Kind::Automaton { site: String::new() });
1492 assert_eq!(classify(&posted(r#"{"key":"s6","package":"foundation.e.message","title":"123456","text":"Votre colis est arrivé.","sms_app":true}"#), &choices), Kind::Automaton { site: String::new() });
1493 assert_eq!(number_in("06 39 98 01"), Some("06 39 98 01".into()), "eight digits");
1494 let replied = talk(r#"{"key":"r","package":"com.discord","category":"msg","user":{"name":"Me","key":"me"},"messages":[{"sender":{"name":"alice","key":"alice"},"text":"tu viens ?"},{"sender":{"name":"Me","key":"me"},"text":"oui"}]}"#);
1496 assert_eq!(replied.sender, Sender::Named { name: "alice".into(), book: false });
1497 assert!(!replied.conversation.unwrap().group);
1498 let two = talk(r#"{"key":"t","package":"org.example.chat","category":"msg","conversation":"Voisins","messages":[{"sender":{"name":"Ana"},"text":"Bonjour"},{"sender":{"name":"Bob"},"text":"Salut"}]}"#);
1500 assert!(two.conversation.unwrap().group);
1501 let missed = talk(r#"{"key":"m","package":"com.android.dialer","category":"missed_call","title":"Maman"}"#);
1503 assert_eq!((missed.via, missed.sender), (Channel::Calls, Sender::Named { name: "Maman".into(), book: true }));
1504 let plain = talk(r#"{"key":"k2","package":"com.android.mms","app":"SMS","title":"Paul","text":"Salut"}"#);
1506 assert_eq!(plain.conversation.map(|c| c.key), Some(talk_key("com.android.mms", "Paul")));
1507 let server = talk(DISCORD_SERVER);
1509 assert!(server.conversation.as_ref().unwrap().group);
1510 assert_eq!(server.sender, Sender::Named { name: "ferris".into(), book: false });
1511 let dm = talk(DISCORD_DM);
1512 assert_eq!((dm.conversation.as_ref().unwrap().group, dm.conversation.as_ref().unwrap().title.as_str()), (false, "alice"));
1513 assert_eq!(classify(&posted(CHROME_SITE), &choices), Kind::Automaton { site: "forum.example.net".into() });
1515 assert_eq!(classify(&posted(FIREFOX_SITE), &choices), Kind::Automaton { site: "news.example.com".into() });
1516 let proton = talk(PROTON);
1518 assert_eq!((proton.via, proton.to.as_str()), (Channel::Mail, "me@proton.example"));
1519 assert_eq!(proton.sender, Sender::Named { name: "Alice Martin".into(), book: false });
1520 assert_eq!(classify(&posted(PROTON_LOGIN), &choices), Kind::Code);
1521 assert_eq!(classify(&posted(SHOP), &choices), Kind::Automaton { site: String::new() });
1523 for json in [BANK_CODE, BANK_APPROVAL, REDACTED] {
1524 assert_eq!(classify(&posted(json), &choices), Kind::Code, "{json}");
1525 }
1526 let asked = |text: &str| is_code(&Posted { title: "Alice".into(), text: text.into(), ..Posted::default() });
1527 assert!(asked("Is this you signing in from Lyon?") && asked("Est-ce bien vous qui tentez une connexion ?"));
1528 assert!(!asked("Is this you on the photo? Haha") && !asked("C'est bien vous sur la photo ?"));
1529 let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.approvals.phrases]\nadd = [\"Zahlung freigeben\"]\n").unwrap();
1531 let german = Posted { title: "Bank".into(), text: "Bitte Zahlung freigeben in der App.".into(), ..Posted::default() };
1532 assert!(!is_code(&german) && super::is_code(&german, &crate::words::Words::of(&config)));
1533 for json in [
1535 r#"{"package":"com.whatsapp","category":"call","title":"Jeanne"}"#,
1536 r#"{"package":"org.fossify.clock","category":"alarm","full_screen":true}"#,
1537 r#"{"package":"org.example.music","ongoing":true,"title":"A song"}"#,
1538 r#"{"package":"com.whatsapp","summary":true,"title":"3 messages"}"#,
1539 r#"{"package":"ws.xsoh.etar","category":"event","title":"Dentist at 14:00"}"#,
1540 r#"{"package":"com.aurelienpierre.sioul","title":"Taken"}"#,
1541 ] {
1542 assert_eq!(classify(&posted(json), &choices), Kind::Untouched, "{json}");
1543 }
1544 let mut chosen = Choices::default();
1546 chosen.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, ..AppChoice::default() });
1547 chosen.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, ..AppChoice::default() });
1548 chosen.site.insert("forum.example.net".into(), SiteChoice { at_once: true });
1549 assert_eq!(classify(&posted(DISCORD_DM), &chosen), Kind::Automaton { site: String::new() });
1550 assert_eq!(classify(&posted(SHOP), &chosen), Kind::AtOnce);
1551 assert_eq!(classify(&posted(CHROME_SITE), &chosen), Kind::AtOnce);
1552 chosen.app.insert("org.example.old.sms".into(), AppChoice { kind: AppKind::People, ..AppChoice::default() });
1554 let old = classify(&posted(r#"{"package":"org.example.old.sms","title":"+262 6 39 98 01 05","text":"Coucou"}"#), &chosen);
1555 assert!(matches!(old, Kind::People(Talk { sender: Sender::Known { .. }, .. })), "{old:?}");
1556 }
1557
1558 #[test]
1559 fn names_anyone_can_take_only_hold_more() {
1560 assert_eq!(named(Some(Who::Safe), true), Who::Safe, "WhatsApp names people as the address book does");
1561 assert_eq!(named(Some(Who::Safe), false), Who::Stranger, "a Discord name is anyone's");
1562 assert_eq!(named(Some(Who::Neutral), false), Who::Stranger);
1563 assert_eq!(named(Some(Who::Restricted), false), Who::Restricted);
1564 assert_eq!(named(Some(Who::Blocked), false), Who::Blocked);
1565 assert_eq!(named(None, true), Who::Stranger);
1566 }
1567
1568 #[test]
1569 fn who_and_when_decide() {
1570 let clock = TestClock::new();
1571 let choices = Choices::default();
1572 let kind = |json: &str| classify(&posted(json), &choices);
1573 let morning = at("2026-10-06T10:00[Europe/Paris]");
1575 let evening = at("2026-10-06T20:30[Europe/Paris]");
1576 let night = at("2026-10-06T23:30[Europe/Paris]");
1577 let decide_at = |json: &str, who: Option<Who>, now: &Zoned| decide(&kind(json), who, false, &choices, &ask(now, &clock, None, None));
1578 assert_eq!(decide_at(SMS_NUMBER, Some(Who::Stranger), &morning), Decision::through(Why::Allowed));
1580 let held = decide_at(SMS_NUMBER, Some(Who::Stranger), &evening);
1581 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1582 assert_eq!(decide_at(WHATSAPP_PERSON, Some(Who::Neutral), &evening), Decision::through(Why::Allowed));
1584 let held = decide_at(WHATSAPP_PERSON, Some(Who::Neutral), &night);
1585 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1586 assert_eq!(decide_at(SMS_CONTACT, Some(Who::Safe), &evening), Decision::through(Why::Allowed));
1589 let held = decide_at(SMS_CONTACT, Some(Who::Safe), &night);
1590 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1591 assert_eq!(decide(&kind(SMS_CONTACT), Some(Who::Safe), true, &choices, &ask(&night, &clock, None, None)), Decision::through(Why::Allowed));
1592 assert_eq!(decide_at(SMS_CONTACT, Some(Who::Blocked), &morning).why, Why::Never);
1593 assert_eq!(decide(&kind(SMS_CONTACT), Some(Who::Blocked), true, &choices, &ask(&morning, &clock, None, None)).why, Why::Never);
1594 let ameli = decide_at(SMS_SENDER_ID, None, &morning);
1596 assert_eq!((ameli.why, until_of(ameli).as_str()), (Why::Gathered, "2026-10-06T13:00:00"));
1597 assert_eq!(decide_at(SMS_SENDER_ID, Some(Who::Neutral), &morning), Decision::through(Why::Allowed));
1598 let group = decide_at(WHATSAPP_GROUP, Some(Who::Safe), &evening);
1600 assert_eq!((group.why, until_of(group).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1601 let mut chosen = Choices::default();
1603 let school = talk_key("com.whatsapp", "120363000000000001@g.us");
1604 chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Always, title: "École Jules Ferry".into(), app: "WhatsApp".into() });
1605 for now in [&evening, &night] {
1606 assert_eq!(decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(now, &clock, None, None)), Decision::through(Why::Always));
1607 }
1608 assert_eq!(decide(&kind(WHATSAPP_GROUP), Some(Who::Blocked), false, &chosen, &ask(&evening, &clock, None, None)).why, Why::Never);
1610 chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Gathered, ..TalkChoice::default() });
1612 let gathered = decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(&morning, &clock, None, None));
1613 assert_eq!((gathered.why, until_of(gathered).as_str()), (Why::Gathered, "2026-10-06T13:00:00"));
1614 chosen.conversation.insert(school, TalkChoice { through: Through::Never, ..TalkChoice::default() });
1615 assert_eq!(decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(&morning, &clock, None, None)).why, Why::Never);
1616 assert_eq!(decide_at(BANK_CODE, None, &night), Decision::through(Why::Code));
1618 let off = Choices { hold: false, ..Choices::default() };
1619 assert_eq!(decide(&kind(SHOP), None, false, &off, &ask(&night, &clock, None, None)), Decision::through(Why::Off));
1620 let mut at_once = Choices::default();
1622 at_once.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, ..AppChoice::default() });
1623 let shop = classify(&posted(SHOP), &at_once);
1624 assert_eq!(decide(&shop, None, false, &at_once, &ask(&evening, &clock, None, None)), Decision::through(Why::AtOnce));
1625 let waking = decide(&shop, None, false, &at_once, &ask(&night, &clock, None, None));
1626 assert_eq!((waking.why, until_of(waking).as_str()), (Why::AtOnce, "2026-10-07T07:00:00"));
1627 }
1628
1629 #[test]
1630 fn mail_and_areas() {
1631 let clock = TestClock::new();
1632 let choices = Choices::default();
1633 let proton = classify(&posted(PROTON), &choices);
1634 let evening = at("2026-10-06T20:30[Europe/Paris]");
1635 let morning = at("2026-10-06T10:00[Europe/Paris]");
1636 let who = named(Some(Who::Safe), false);
1638 let held = decide(&proton, Some(who), false, &choices, &ask(&evening, &clock, Some(Area::WORK), None));
1639 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1640 let held = decide(&proton, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None));
1642 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-06T17:00:00"));
1643 assert_eq!(decide(&proton, Some(Who::Safe), false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None)).why, Why::Allowed);
1645 let shop = classify(&posted(SHOP), &choices);
1647 let held = decide(&shop, None, false, &choices, &ask(&evening, &clock, Some(Area::WORK), None));
1648 assert_eq!(until_of(held), "2026-10-07T09:00:00");
1649 let held = decide(&shop, None, false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None));
1651 assert_eq!(until_of(held), "2026-10-06T18:00:00");
1652 }
1653
1654 #[test]
1655 fn gathered_times_across_midnight_sleep_and_pauses() {
1656 let clock = TestClock::new();
1657 let choices = Choices::default();
1658 let shop = classify(&posted(SHOP), &choices);
1659 let when = |now: &str, clock: &TestClock| until_of(decide(&shop, None, false, &choices, &ask(&at(now), clock, None, None)));
1660 assert_eq!(when("2026-10-06T08:00[Europe/Paris]", &clock), "2026-10-06T09:00:00");
1662 assert_eq!(when("2026-10-06T09:30[Europe/Paris]", &clock), "2026-10-06T13:00:00");
1663 assert_eq!(when("2026-10-06T13:20[Europe/Paris]", &clock), "2026-10-06T18:00:00");
1664 assert_eq!(when("2026-10-06T18:30[Europe/Paris]", &clock), "2026-10-07T09:00:00");
1666 assert_eq!(decide(&shop, None, false, &choices, &ask(&at("2026-10-06T13:00:30[Europe/Paris]"), &clock, None, None)), Decision::through(Why::Gathered));
1668 let late = vec!["06:30".to_string(), "13:00".into(), "22:30".into()];
1670 let ask_late = |now: &'static str| {
1671 let now = Box::leak(Box::new(at(now)));
1672 Ask { now, clock: &clock, attention: usual(), gathered: &late, area: None, gate: None, slot_at: &never_slot, nothing: false, also: &[], sources: &[] }
1673 };
1674 assert_eq!(until_of(decide(&shop, None, false, &choices, &ask_late("2026-10-06T20:00[Europe/Paris]"))), "2026-10-07T13:00:00", "22:30 and 06:30 are asleep");
1675 let paused = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1677 let now = at("2026-10-06T08:30[Europe/Paris]");
1678 let held = decide(&shop, None, false, &choices, &ask(&now, &paused, None, None));
1679 assert_eq!((held.why, until_of(held).as_str()), (Why::Gathered, "2026-10-06T14:30:00"));
1680 let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1682 let held = decide(&shop, None, false, &choices, &ask(&at("2026-10-06T16:00[Europe/Paris]"), &free, None, None));
1683 assert_eq!(until_of(held), "2026-10-07T09:00:00", "18:00 falls in free time, which lasts until the night");
1684 let gate = Gate { until: Some(at("2026-10-06T18:30[Europe/Paris]").timestamp().as_second()) };
1686 let held = decide(&shop, None, false, &choices, &ask(&at("2026-10-06T14:00[Europe/Paris]"), &clock, None, Some(gate)));
1687 assert_eq!(until_of(held), "2026-10-07T09:00:00");
1688 let slot = |z: &Zoned| z.date() == at("2026-10-06T00:00[Europe/Paris]").date() && z.hour() == 13;
1690 let now = at("2026-10-06T10:00[Europe/Paris]");
1691 let usual = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1692 let with_slot = Ask { now: &now, clock: &clock, attention: self::usual(), gathered: &usual, area: None, gate: None, slot_at: &slot, nothing: false, also: &[], sources: &[] };
1693 assert_eq!(until_of(decide(&shop, None, false, &choices, &with_slot)), "2026-10-06T18:00:00");
1694 assert_eq!(times_of(&["later".into()]).len(), 3);
1696 assert_eq!(times_of(&["8h30".into(), "08:30".into(), " 17:00 ".into()]).iter().map(|t| t.to_string()).collect::<Vec<_>>(), ["08:30:00", "17:00:00"]);
1697 }
1698
1699 #[test]
1700 fn do_not_disturb_lets_its_list_through() {
1701 let clock = TestClock::new();
1702 let choices = Choices::default();
1703 let sms = classify(&posted(SMS_CONTACT), &choices);
1704 let morning = at("2026-10-06T10:00[Europe/Paris]");
1705 let gate = || Some(Gate { until: Some(at("2026-10-06T11:00[Europe/Paris]").timestamp().as_second()) });
1706 assert_eq!(decide(&sms, Some(Who::Neutral), true, &choices, &ask(&morning, &clock, None, gate())).why, Why::Allowed);
1708 let held = decide(&sms, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, None, gate()));
1709 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-06T11:00:00"));
1710 let open = Some(Gate { until: None });
1712 let held = decide(&sms, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, None, open));
1713 assert_eq!(until_of(held), "2026-10-06T10:30:00");
1714 }
1715
1716 #[test]
1717 fn the_matrix_changes_when() {
1718 let clock = TestClock::new();
1719 let choices = Choices::default();
1720 let sms = classify(&posted(SMS_CONTACT), &choices);
1721 let shop = classify(&posted(SHOP), &choices);
1722 let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1723 let decided = |kind: &Kind, who: Option<Who>, listed: bool, matrix: &Attention, now: &Zoned, clock: &TestClock, gate: Option<Gate>| {
1724 decide(kind, who, listed, &choices, &Ask { now, clock, attention: matrix, gathered: &gathered, area: None, gate, slot_at: &never_slot, nothing: false, also: &[], sources: &[] })
1725 };
1726 let mut matrix = usual().clone();
1727 let evening = at("2026-10-06T20:30[Europe/Paris]");
1729 let gate = || Some(Gate { until: Some(at("2026-10-06T21:00[Europe/Paris]").timestamp().as_second()) });
1730 assert_eq!(decided(&sms, Some(Who::Stranger), true, &matrix, &evening, &clock, gate()).why, Why::Allowed);
1731 matrix.set(Row::People(Channel::Messages, Person::Always), Column::Leisure, Level::As).unwrap();
1732 matrix.set(Row::People(Channel::Messages, Person::Always), Column::Dnd, Level::Through).unwrap();
1733 assert_eq!(decided(&sms, Some(Who::Stranger), true, &matrix, &evening, &clock, gate()).why, Why::Waiting, "a stranger's row in the evening");
1734 assert_eq!(decided(&sms, Some(Who::Neutral), true, &matrix, &evening, &clock, gate()).why, Why::Allowed, "the neutral come in leisure");
1735 assert_eq!(decided(&sms, Some(Who::Safe), false, &matrix, &evening, &clock, gate()).why, Why::Waiting, "not on the list: do-not-disturb holds them");
1736 let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1739 let afternoon = at("2026-10-06T16:00[Europe/Paris]");
1740 assert_eq!(decided(&sms, Some(Who::Safe), false, &matrix, &afternoon, &free, None).why, Why::Allowed, "as usual");
1741 matrix.set(Row::People(Channel::Messages, Person::Safe), Column::Free, Level::Later).unwrap();
1742 let held = decided(&sms, Some(Who::Safe), false, &matrix, &afternoon, &free, None);
1743 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1744 let morning = at("2026-10-06T08:00[Europe/Paris]");
1746 assert_eq!(until_of(decided(&shop, None, false, &matrix, &morning, &clock, None)), "2026-10-06T09:00:00", "as usual");
1747 matrix.set(Row::Own(crate::attention::Kind::AppAutomatons), Column::Work, Level::Later).unwrap();
1748 assert_eq!(until_of(decided(&shop, None, false, &matrix, &morning, &clock, None)), "2026-10-06T18:00:00");
1749 let in_slot = |z: &Zoned| z.hour() == 9;
1751 let slotted = Ask { now: &morning, clock: &clock, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &in_slot, nothing: false, also: &[], sources: &[] };
1752 assert_eq!(until_of(decide(&shop, None, false, &choices, &slotted)), "2026-10-06T13:00:00");
1753 let nothing = Ask { now: &afternoon, clock: &free, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &never_slot, nothing: true, also: &[], sources: &[] };
1755 assert_eq!(decide(&sms, Some(Who::Safe), false, &choices, ¬hing).why, Why::Waiting);
1756 assert_eq!(decide(&sms, Some(Who::Safe), true, &choices, ¬hing).why, Why::Allowed);
1757 let proton = classify(&posted(PROTON), &choices);
1759 let night = at("2026-10-06T23:30[Europe/Paris]");
1760 let held = decide(&proton, Some(Who::Safe), false, &choices, &ask(&night, &clock, None, None));
1761 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1762 let missed = classify(&posted(r#"{"key":"m","package":"com.android.dialer","category":"missed_call","title":"+262 6 39 98 01 07"}"#), &choices);
1764 assert_eq!(decide(&missed, Some(Who::Stranger), false, &choices, &ask(&at("2026-10-06T10:00[Europe/Paris]"), &clock, None, None)).why, Why::Allowed);
1765 assert_eq!(decide(&missed, Some(Who::Stranger), false, &choices, &ask(&night, &clock, None, None)).why, Why::Waiting);
1766 }
1767
1768 #[test]
1769 fn what_was_held_and_came_back() {
1770 let clock = TestClock::new();
1771 let choices = Choices::default();
1772 let now = at("2026-10-06T10:00[Europe/Paris]");
1773 let stamp = now.timestamp().as_second();
1774 let mut ledger = Ledger::default();
1775 for json in [SHOP, DISCORD_SERVER, CHROME_SITE, WHATSAPP_GROUP] {
1776 let p = posted(json);
1777 let kind = classify(&p, &choices);
1778 let decision = decide(&kind, Some(Who::Stranger), false, &choices, &ask(&now, &clock, None, None));
1779 ledger.note(&p, &kind, Some(Who::Stranger), false, None, &decision, stamp);
1780 }
1781 assert_eq!(ledger.apps["com.shop.example"].rang.keys().collect::<Vec<_>>(), ["offers"]);
1783 assert_eq!(ledger.conversations.len(), 2, "Discord's channel and the school's group");
1784 assert!(ledger.sites.contains_key("forum.example.net"));
1785 assert_eq!(ledger.held.len(), 2, "the shop and the site wait for 13:00; strangers write in work time");
1786 let tr = Translator::new("en");
1787 assert_eq!(porch_line(&tr, &ledger, &now), "Notifications held until 13:00: Chrome and Shop.");
1788 let fr = Translator::new("fr");
1789 assert_eq!(porch_line(&fr, &ledger, &now), "Notifications retenues jusqu’à 13:00\u{202f}: Chrome et Shop.");
1790 let one = at("2026-10-06T13:00[Europe/Paris]");
1792 let hash = key_hash(&posted(SHOP).key);
1793 assert!(ledger.returning(&hash, one.timestamp().as_second()) && !ledger.returning(&hash, stamp));
1794 ledger.came_back(&hash, one.timestamp().as_second());
1795 ledger.came_back(&key_hash(&posted(CHROME_SITE).key), one.timestamp().as_second() + 5);
1796 assert!(ledger.held.is_empty());
1797 assert_eq!(porch_line(&tr, &ledger, &one), "Back in your notifications at 13:00: Shop and Chrome.");
1798 assert_eq!(porch_line(&tr, &ledger, &at("2026-10-06T15:30[Europe/Paris]")), "");
1799 let text = toml::to_string(&ledger).unwrap();
1801 for said in ["chaussures", "Sortie", "Nouvelle version", "répondu", "+33", "Mme Durand", "ferris", "120363"] {
1802 assert!(!text.contains(said), "{said} kept: {text}");
1803 }
1804 assert!(text.contains("École Jules Ferry") && text.contains("Rust FR #général"), "{text}");
1805 let mut chosen = Choices::default();
1807 let school = talk_key("com.whatsapp", "120363000000000001@g.us");
1808 chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
1809 ledger.forget_old(stamp + 8 * 86_400, &chosen);
1810 assert!(ledger.back.is_empty());
1811 assert_eq!(ledger.conversations.keys().collect::<Vec<_>>(), [&school]);
1812 }
1813
1814 #[test]
1815 fn held_ones_worked_out_again() {
1816 let choices = Choices::default();
1817 let paused = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1818 let back = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), paused_ended: Some(at("2026-10-06T10:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1819 let held = Held { app: "com.shop.example".into(), label: "Shop".into(), kind: "gathered".into(), since: 0, until: at("2026-10-06T11:30[Europe/Paris]").timestamp().as_second(), ..Held::default() };
1820 let now = at("2026-10-06T10:00:30[Europe/Paris]");
1821 assert_eq!(again(&held, &choices, &ask(&now, &paused, None, None)).why, Why::Gathered);
1823 assert_eq!(until_of(again(&held, &choices, &ask(&now, &back, None, None))), "2026-10-06T13:00:00");
1825 let talk = Held { kind: "people".into(), via: "messages".into(), who: "stranger".into(), conversation: "c1".into(), ..held.clone() };
1827 let mut chosen = Choices::default();
1828 chosen.conversation.insert("c1".into(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
1829 let evening = at("2026-10-06T20:30[Europe/Paris]");
1830 assert_eq!(again(&talk, &chosen, &ask(&evening, &TestClock::new(), None, None)), Decision::through(Why::Always));
1831 assert_eq!(until_of(again(&talk, &choices, &ask(&evening, &TestClock::new(), None, None))), "2026-10-07T09:00:00");
1832 let clock = TestClock::new();
1834 let mut ledger = Ledger::default();
1835 let p = posted(WHATSAPP_GROUP);
1836 let kind = classify(&p, &choices);
1837 let decision = decide(&kind, Some(Who::Safe), true, &choices, &ask(&evening, &clock, None, None));
1838 ledger.note(&p, &kind, Some(Who::Safe), true, None, &decision, evening.timestamp().as_second());
1839 let group = ledger.held.values().next().unwrap().clone();
1840 assert_eq!((group.who.as_str(), group.admitted, group.group), ("safe", false, true));
1841 assert_eq!(until_of(again(&group, &choices, &ask(&evening, &clock, None, None))), "2026-10-07T09:00:00");
1842 let mut gathered_then_always = Choices::default();
1844 gathered_then_always.conversation.insert(group.conversation.clone(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
1845 let gathered_held = Held { kind: "gathered".into(), ..group.clone() };
1846 assert_eq!(again(&gathered_held, &gathered_then_always, &ask(&evening, &clock, None, None)), Decision::through(Why::Always));
1847 let off = Choices { hold: false, ..Choices::default() };
1849 assert_eq!(again(&held, &off, &ask(&now, &paused, None, None)), Decision::through(Why::Off));
1850 }
1851
1852 #[test]
1858 fn an_app_s_own_row_decides_when() {
1859 fn with<'a>(now: &'a Zoned, clock: &'a TestClock, attention: &'a Attention, gathered: &'a [String], sources: &'a [String], gate: Option<Gate>) -> Ask<'a> {
1860 Ask { now, clock, attention, gathered, area: None, gate, slot_at: &never_slot, nothing: false, also: &[], sources }
1861 }
1862 let clock = TestClock::new();
1863 let choices = Choices::default();
1864 let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1865 let (morning, evening) = (at("2026-10-06T10:00[Europe/Paris]"), at("2026-10-06T20:00[Europe/Paris]"));
1866 let dm = classify(&posted(DISCORD_DM), &choices);
1867 let server = classify(&posted(DISCORD_SERVER), &choices);
1868 let key = |k: &Kind| match k {
1869 Kind::People(t) => t.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
1870 _ => String::new(),
1871 };
1872 let dm_rows = attention::source_rows("com.discord", &key(&dm));
1873 let server_rows = attention::source_rows("com.discord", &key(&server));
1874 assert_eq!(server_rows, vec![format!("conversation.{}", key(&server)), "app.com.discord".to_string()]);
1875 for fixture in [SMS_NUMBER, SMS_SENDER_ID, WHATSAPP_PERSON, WHATSAPP_GROUP, DISCORD_SERVER, DISCORD_DM, CHROME_SITE, PROTON, SHOP, BANK_CODE] {
1877 let p = posted(fixture);
1878 let kind = classify(&p, &choices);
1879 let rows = attention::source_rows(&p.package, &key(&kind));
1880 for hour in ["03:00", "08:30", "10:00", "12:30", "17:30", "20:00", "22:30"] {
1881 let now = at(&format!("2026-10-06T{hour}[Europe/Paris]"));
1882 for who in [None, Some(Who::Safe), Some(Who::Stranger)] {
1883 assert_eq!(decide(&kind, who, false, &choices, &with(&now, &clock, usual(), &gathered, &rows, None)), decide(&kind, who, false, &choices, &with(&now, &clock, usual(), &gathered, &[], None)), "{fixture} {hour} {who:?}");
1884 }
1885 }
1886 }
1887 let mut work_only = usual().clone();
1889 for column in Column::ALL {
1890 work_only.set_source("app.com.discord", column, if column == Column::Work { Level::Now } else { Level::Later }).unwrap();
1891 }
1892 assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)), Decision::through(Why::Times));
1893 let held = decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None));
1894 assert_eq!((held.why, until_of(held).as_str()), (Why::Times, "2026-10-07T09:00:00"));
1895 assert_eq!(decide(&dm, Some(Who::Safe), false, &choices, &with(&evening, &clock, usual(), &gathered, &dm_rows, None)), Decision::through(Why::Allowed));
1897 assert_eq!(until_of(decide(&dm, Some(Who::Safe), false, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None))), "2026-10-07T09:00:00");
1898 assert_eq!(decide(&dm, Some(Who::Blocked), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)).why, Why::Never);
1900 let code = classify(&posted(BANK_CODE), &choices);
1901 let mut code_held = usual().clone();
1902 code_held.set_source("app.com.bank.example", Column::Work, Level::Later).unwrap();
1903 let code_rows = attention::source_rows("com.bank.example", "");
1904 assert_eq!(decide(&code, None, false, &choices, &with(&morning, &clock, &code_held, &gathered, &code_rows, None)), Decision::through(Why::Code));
1905 let mut server_evening = work_only.clone();
1907 server_evening.set_source(&server_rows[0], Column::Leisure, Level::Now).unwrap();
1908 assert_eq!(decide(&server, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &server_rows, None)), Decision::through(Why::Times));
1909 assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &dm_rows, None)).why, Why::Times);
1910 assert!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &dm_rows, None)).until.is_some());
1911 let mut by_day = usual().clone();
1913 for column in [Column::Work, Column::Admin, Column::Meals] {
1914 by_day.set_source("app.com.discord", column, Level::Gathered).unwrap();
1915 }
1916 let held = decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &by_day, &gathered, &dm_rows, None));
1917 assert_eq!((held.why, until_of(held).as_str()), (Why::Times, "2026-10-06T13:00:00"));
1918 let gate = || Some(Gate { until: Some(at("2026-10-06T11:00[Europe/Paris]").timestamp().as_second()) });
1920 assert_eq!(until_of(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, gate()))), "2026-10-06T11:00:00");
1921 let mut through_dnd = work_only.clone();
1922 through_dnd.set_source("app.com.discord", Column::Dnd, Level::As).unwrap();
1923 assert_eq!(until_of(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &through_dnd, &gathered, &dm_rows, gate()))), "2026-10-06T11:00:00");
1924 through_dnd.set_source("app.com.discord", Column::Dnd, Level::Now).unwrap();
1925 assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &through_dnd, &gathered, &dm_rows, gate())), Decision::through(Why::Times));
1926 let shop = classify(&posted(SHOP), &choices);
1928 let shop_rows = attention::source_rows("com.shop.example", "");
1929 let mut shop_evening = usual().clone();
1930 shop_evening.set_source("app.com.shop.example", Column::Leisure, Level::Now).unwrap();
1931 assert_eq!(decide(&shop, None, false, &choices, &with(&evening, &clock, &shop_evening, &gathered, &shop_rows, None)), Decision::through(Why::Times));
1932 assert_eq!(until_of(decide(&shop, None, false, &choices, &with(&evening, &clock, usual(), &gathered, &shop_rows, None))), "2026-10-07T09:00:00");
1933 let reachable = crate::attention::Preset::Reachable.matrix();
1935 assert_eq!(decide(&shop, None, false, &choices, &with(&morning, &clock, &reachable, &gathered, &[], None)), Decision::through(Why::Gathered));
1936 let kept = Held { app: "com.discord".into(), label: "Discord".into(), kind: "people".into(), via: "messages".into(), who: "stranger".into(), conversation: key(&dm), since: 0, until: 0, ..Held::default() };
1938 assert_eq!(again(&kept, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)), Decision::through(Why::Times));
1939 assert_eq!(until_of(again(&kept, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None))), "2026-10-07T09:00:00");
1940 let mut dm_evening = work_only.clone();
1941 dm_evening.set_source(&dm_rows[0], Column::Leisure, Level::Now).unwrap();
1942 assert_eq!(again(&kept, &choices, &with(&evening, &clock, &dm_evening, &gathered, &dm_rows, None)), Decision::through(Why::Times));
1943 }
1944
1945 #[test]
1946 fn choices_and_ledger_read_and_written() {
1947 let dir = std::env::temp_dir().join(format!("sioul-appnotes-{}", std::process::id()));
1948 let _ = std::fs::remove_dir_all(&dir);
1949 let mut choices = Choices::default();
1950 assert!(choices.hold, "holding is on until switched off");
1951 choices.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, area: Some(Area::LEISURE), label: "Discord".into() });
1952 choices.conversation.insert("c1".into(), TalkChoice { through: Through::Always, title: "École".into(), app: "WhatsApp".into() });
1953 let path = dir.join("app-notes.toml");
1954 choices.save(&path).unwrap();
1955 assert_eq!(Choices::load(&path), choices);
1956 let text = std::fs::read_to_string(&path).unwrap();
1957 assert!(text.contains("kind = \"automaton\"") && text.contains("area = \"leisure\"") && text.contains("through = \"always\""), "{text}");
1958 assert_eq!(Choices::load(&dir.join("none.toml")), Choices::default());
1959 std::fs::write(&path, "hold = false\n").unwrap();
1960 assert!(!Choices::load(&path).hold);
1961 std::fs::write(&path, "hold = [broken").unwrap();
1962 assert_eq!(Choices::load(&path), Choices::default(), "a file that does not read is no choice at all");
1963 let _ = std::fs::remove_dir_all(&dir);
1964 assert_eq!(AppKind::read("at-once"), Some(AppKind::AtOnce));
1965 assert_eq!(Through::read("never"), Some(Through::Never));
1966 }
1967
1968 #[test]
1969 fn words_in_both_languages() {
1970 let now = at("2026-10-06T10:00[Europe/Paris]");
1971 let names = |n: usize| (1..=n).map(|i| format!("App{i}")).collect::<Vec<_>>();
1972 for (language, n, said) in [
1973 ("en", 1, "App1"),
1974 ("en", 2, "App1 and App2"),
1975 ("en", 3, "App1, App2 and App3"),
1976 ("en", 4, "App1, App2, App3 and one other app"),
1977 ("en", 6, "App1, App2, App3 and three other apps"),
1978 ("en", 15, "App1, App2, App3 and twelve other apps"),
1979 ("en", 16, "App1, App2, App3 and many other apps"),
1980 ("fr", 2, "App1 et App2"),
1981 ("fr", 4, "App1, App2, App3 et une autre application"),
1982 ("fr", 5, "App1, App2, App3 et deux autres applications"),
1983 ("fr", 20, "App1, App2, App3 et beaucoup d’autres applications"),
1984 ] {
1985 assert_eq!(apps_in_words(&Translator::new(language), &names(n)), said, "{language} {n}");
1986 }
1987 let tr = Translator::new("en");
1988 let at13 = at("2026-10-06T13:00[Europe/Paris]").timestamp().as_second();
1989 assert_eq!(why_text(&tr, "gathered", at13, &now), "From an automaton: held until 13:00, a gathered time.");
1990 assert_eq!(why_text(&tr, "allowed", 0, &now), "From someone who may reach you now: let through.");
1991 let fr = Translator::new("fr");
1992 assert_eq!(why_text(&fr, "gathered", at13, &now), "D’un automate\u{202f}: retenue jusqu’à 13:00, une heure de regroupement.");
1993 assert_eq!(why_text(&tr, "times", at13, &now), "As you chose for this time: held until 13:00.");
1994 for why in ["untouched", "code", "off", "at-once", "allowed", "always", "waiting", "gathered", "never", "again", "times"] {
1995 for tr in [&tr, &fr] {
1996 let said = why_text(tr, why, 0, &now);
1997 assert!(!said.starts_with("appnotes-"), "{why}: {said}");
1998 let held = why_text(tr, why, at13, &now);
1999 assert!(!held.starts_with("appnotes-"), "{why}: {held}");
2000 }
2001 }
2002 }
2003}