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 pub by_writer: bool,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct Talk {
209 pub via: Channel,
210 pub sender: Sender,
211 pub conversation: Option<Conversation>,
212 pub to: String,
214 pub sms: bool,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum Kind {
222 Untouched,
224 Code,
226 AtOnce,
228 People(Talk),
229 Automaton { site: String },
231}
232
233const UNTOUCHED: &[&str] = &[
235 "call", "alarm", "navigation", "transport", "stopwatch", "location_sharing", "sys", "service", "progress", "status", "err", "car_emergency",
236 "car_warning", "car_information", "workout", "reminder", "event",
237];
238
239const BOOK_APPS: &[&str] = &["com.whatsapp", "com.whatsapp.w4b"];
241
242const SMS_APPS: &[&str] = &[
244 "com.google.android.apps.messaging", "com.android.messaging", "com.android.mms", "com.samsung.android.messaging", "org.fossify.messages",
245 "com.simplemobiletools.smsmessenger", "com.moez.QKSMS", "dev.octoshrimpy.quik", "foundation.e.message",
246];
247
248const MAIL_APPS: &[&str] = &[
250 "ch.protonmail.android", "com.google.android.gm", "com.fsck.k9", "net.thunderbird.android", "net.thunderbird.android.beta", "eu.faircode.email",
251 "com.microsoft.office.outlook", "foundation.e.mail", "de.tutao.tutanota", "org.kman.AquaMail", "com.samsung.android.email.provider",
252 "com.yahoo.mobile.client.android.mail", "me.proton.android.mail",
253];
254
255const FIREFOX_SITES: &str = "mozac.feature.webnotifications.generic.channel";
257
258pub fn is_code(p: &Posted, words: &crate::words::Words) -> bool {
265 if p.redacted {
266 return true;
267 }
268 let mut body = vec![p.text.as_str(), p.big.as_str(), p.sub.as_str()];
269 body.extend(p.lines.iter().map(String::as_str));
270 body.extend(p.messages.iter().map(|m| m.text.as_str()));
271 let body = body.into_iter().filter(|t| !t.trim().is_empty()).collect::<Vec<_>>().join("\n");
272 if crate::codes::detect(&words.codes, &p.title, &body).is_some() {
273 return true;
274 }
275 let folded: Vec<char> = crate::text::fold(&format!("{}\n{body}", p.title)).into_iter().map(|c| if c == '\n' { ' ' } else { c }).collect();
276 let said = |phrases: &[String]| phrases.iter().any(|phrase| crate::text::find_word(&folded, phrase, 0).is_some());
277 let approvals = &words.approvals;
278 if said(&approvals.phrases) || (said(&approvals.asks) && said(&approvals.asked_about)) {
279 return true;
280 }
281 let channel: Vec<char> = crate::text::fold(&format!("{} {}", p.channel.replace(['_', '.'], " "), p.channel_name));
282 approvals.channels.iter().any(|word| crate::text::find_word(&channel, word, 0).is_some())
283}
284
285pub fn site_of(p: &Posted) -> Option<String> {
290 let tagged = p.tag.strip_prefix("p#").or_else(|| p.tag.strip_prefix("n#")).and_then(|rest| rest.split('#').next());
291 let channel = p.channel.strip_prefix("web:").and_then(|rest| rest.split(';').next());
292 let grouped = p.group.strip_prefix("Web:");
293 let firefox = (p.channel == FIREFOX_SITES).then_some(p.sub.as_str());
294 [tagged, channel, grouped, firefox].into_iter().flatten().map(host_of).find(|h| !h.is_empty())
295}
296
297fn host_of(origin: &str) -> String {
299 let rest = origin.trim().split_once("://").map_or(origin.trim(), |(_, rest)| rest);
300 let host = rest.split(['/', '?', '#']).next().unwrap_or("");
301 let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
302 let host = host.split(':').next().unwrap_or("").trim_end_matches('.').to_lowercase();
303 let fine = host.contains('.') && host.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-');
304 if fine { host } else { String::new() }
305}
306
307pub fn number_in(text: &str) -> Option<String> {
311 let text = text.trim().trim_start_matches('\u{202a}').trim_end_matches('\u{202c}').trim();
312 let fine = !text.is_empty() && text.chars().enumerate().all(|(i, c)| c.is_ascii_digit() || " .-()/\u{a0}\u{202f}".contains(c) || (c == '+' && i == 0));
313 let digits = text.chars().filter(char::is_ascii_digit).count();
314 (fine && digits >= 7).then(|| text.to_string())
315}
316
317fn short_code(text: &str) -> bool {
319 let text = text.trim();
320 !text.is_empty() && text.len() <= 6 && text.chars().all(|c| c.is_ascii_digit())
321}
322
323fn address_in(text: &str) -> Option<String> {
325 let text = text.trim();
326 let inner = text.rsplit_once('<').and_then(|(_, rest)| rest.split_once('>')).map_or(text, |(a, _)| a).trim();
327 let (local, domain) = inner.split_once('@')?;
328 let fine = !local.is_empty() && domain.contains('.') && !inner.contains(char::is_whitespace);
329 fine.then(|| inner.to_lowercase())
330}
331
332fn sender_of(person: &Person, book: bool) -> Sender {
335 let mut numbers = person.numbers.clone();
336 let mut emails = person.emails.clone();
337 if book && person.uri.trim().is_empty() {
338 numbers.extend(person.by_name.iter().cloned());
339 emails.extend(person.emails_by_name.iter().cloned());
340 }
341 let contact = !numbers.is_empty() || !emails.is_empty();
342 let uri = person.uri.trim();
343 if let Some(number) = uri.strip_prefix("tel:") {
344 numbers.insert(0, percent_decoded(number));
345 } else if let Some(address) = uri.strip_prefix("mailto:") {
346 emails.insert(0, percent_decoded(address).to_lowercase());
347 }
348 numbers.retain(|n| !n.trim().is_empty());
349 emails.retain(|e| !e.trim().is_empty());
350 if !numbers.is_empty() || !emails.is_empty() {
351 return Sender::Known { numbers, emails, contact };
352 }
353 if let Some(number) = number_in(&person.name) {
355 return Sender::Known { numbers: vec![number], emails: Vec::new(), contact: false };
356 }
357 if let Some(address) = address_in(&person.name) {
358 return Sender::Known { numbers: Vec::new(), emails: vec![address], contact: false };
359 }
360 let name = one_line(&person.name);
361 if name.is_empty() { Sender::Unknown } else { Sender::Named { name, book } }
362}
363
364fn percent_decoded(text: &str) -> String {
366 let bytes = text.as_bytes();
367 let mut out = Vec::with_capacity(bytes.len());
368 let mut i = 0;
369 while i < bytes.len() {
370 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());
371 match hex {
372 Some(byte) => {
373 out.push(byte);
374 i += 3;
375 }
376 None => {
377 out.push(bytes[i]);
378 i += 1;
379 }
380 }
381 }
382 String::from_utf8_lossy(&out).to_string()
383}
384
385fn one_line(text: &str) -> String {
387 text.split_whitespace().collect::<Vec<_>>().join(" ")
388}
389
390fn fnv(text: &str) -> u64 {
391 text.bytes().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3))
392}
393
394pub fn key_hash(key: &str) -> String {
397 format!("{:016x}", fnv(key))
398}
399
400pub fn talk_key(package: &str, id: &str) -> String {
402 format!("{:016x}", fnv(&format!("{package}\u{1f}{id}")))
403}
404
405fn conversation_of(p: &Posted, sender: Option<&Person>, sms: bool) -> Option<Conversation> {
410 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();
412 writers.sort();
413 writers.dedup();
414 let group = p.group_conversation || writers.len() >= 2;
415 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();
416 let from = sender.map_or("", |s| if s.key.trim().is_empty() { s.name.trim() } else { s.key.trim() });
417 let plain = if sms { p.title.trim() } else { "" };
418 let id = [p.shortcut.trim(), p.conversation.trim(), from, plain].into_iter().find(|i| !i.is_empty())?;
419 let by_writer = p.shortcut.trim().is_empty() && p.conversation.trim().is_empty() && !from.is_empty();
420 Some(Conversation { key: talk_key(&p.package, id), title, group, by_writer })
421}
422
423fn is_user(p: &Posted, sender: &Person) -> bool {
426 p.user.as_ref().is_some_and(|user| {
427 let (key, name) = (user.key.trim(), one_line(&user.name));
428 (!key.is_empty() && key == sender.key.trim()) || (!name.is_empty() && name == one_line(&sender.name))
429 })
430}
431
432pub fn classify(p: &Posted, choices: &Choices, words: &crate::words::Words) -> Kind {
435 if p.package == OWN || p.ongoing || p.full_screen || p.summary || UNTOUCHED.contains(&p.category.as_str()) {
436 return Kind::Untouched;
437 }
438 if is_code(p, words) {
439 return Kind::Code;
440 }
441 let app = choices.app(&p.package);
442 if app.kind == AppKind::AtOnce {
443 return Kind::AtOnce;
444 }
445 let site = site_of(p);
446 if let Some(site) = &site
447 && choices.site.get(site).is_some_and(|s| s.at_once)
448 {
449 return Kind::AtOnce;
450 }
451 let automaton = Kind::Automaton { site: site.clone().unwrap_or_default() };
452 if app.kind == AppKind::Automaton || (app.kind == AppKind::Usual && site.is_some()) {
453 return automaton;
454 }
455 let mail = MAIL_APPS.contains(&p.package.as_str()) || p.category == "email";
456 let sms = p.sms_app || SMS_APPS.contains(&p.package.as_str());
457 let via = if matches!(p.category.as_str(), "missed_call" | "voicemail") {
458 Channel::Calls
459 } else if mail {
460 Channel::Mail
461 } else {
462 Channel::Messages
463 };
464 let styled = p.template.ends_with("$MessagingStyle") || !p.messages.is_empty() || p.is_conversation || !p.shortcut.trim().is_empty();
465 let identified = p.people.iter().any(|person| person.uri.starts_with("tel:") || person.uri.starts_with("mailto:"));
466 let between = app.kind == AppKind::People || via != Channel::Messages || styled || identified || sms || p.category == "msg";
467 let advert = matches!(p.category.as_str(), "promo" | "social" | "recommendation");
468 if !between || (advert && app.kind != AppKind::People) {
469 return automaton;
470 }
471 let book = sms || via == Channel::Calls || BOOK_APPS.contains(&p.package.as_str());
473 let last = p.messages.iter().rev().find_map(|m| m.sender.as_ref().filter(|s| !is_user(p, s)));
475 let person = last.or_else(|| p.people.first());
476 if person.is_some_and(|s| s.bot) && app.kind != AppKind::People {
478 return automaton;
479 }
480 let sender = match person {
481 Some(person) => sender_of(person, book),
482 None => {
483 let title = one_line(&p.title);
484 if sms && short_code(&title) {
485 return automaton;
486 }
487 sender_of(&Person { name: title, ..Person::default() }, book)
488 }
489 };
490 if sms && let Sender::Named { name, .. } = &sender
491 && short_code(name)
492 {
493 return automaton;
494 }
495 let to = if via == Channel::Mail { address_in(&p.sub).unwrap_or_default() } else { String::new() };
496 let conversation = if via == Channel::Messages { conversation_of(p, person, sms) } else { None };
497 Kind::People(Talk { via, sender, conversation, to, sms })
498}
499
500pub fn named(found: Option<Who>, book: bool) -> Who {
506 match found {
507 Some(who) if book => who,
508 Some(who @ (Who::Restricted | Who::Blocked)) => who,
509 _ => Who::Stranger,
510 }
511}
512
513#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(rename_all = "kebab-case")]
518pub enum AppKind {
519 #[default]
521 Usual,
522 People,
524 Automaton,
526 AtOnce,
528}
529
530impl AppKind {
531 pub const ALL: [AppKind; 4] = [AppKind::Usual, AppKind::People, AppKind::Automaton, AppKind::AtOnce];
532
533 pub fn id(self) -> &'static str {
534 match self {
535 AppKind::Usual => "usual",
536 AppKind::People => "people",
537 AppKind::Automaton => "automaton",
538 AppKind::AtOnce => "at-once",
539 }
540 }
541
542 pub fn read(id: &str) -> Option<AppKind> {
543 AppKind::ALL.into_iter().find(|k| k.id() == id)
544 }
545}
546
547#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
549#[serde(rename_all = "kebab-case")]
550pub enum Through {
551 #[default]
553 Usual,
554 Always,
556 Gathered,
558 Never,
560}
561
562impl Through {
563 pub const ALL: [Through; 4] = [Through::Usual, Through::Always, Through::Gathered, Through::Never];
564
565 pub fn id(self) -> &'static str {
566 match self {
567 Through::Usual => "usual",
568 Through::Always => "always",
569 Through::Gathered => "gathered",
570 Through::Never => "never",
571 }
572 }
573
574 pub fn read(id: &str) -> Option<Through> {
575 Through::ALL.into_iter().find(|t| t.id() == id)
576 }
577}
578
579#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
580pub struct AppChoice {
581 #[serde(default)]
582 pub kind: AppKind,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub area: Option<Area>,
587 #[serde(default, skip_serializing_if = "String::is_empty")]
589 pub label: String,
590}
591
592#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
593pub struct TalkChoice {
594 #[serde(default)]
595 pub through: Through,
596 #[serde(default, skip_serializing_if = "String::is_empty")]
598 pub title: String,
599 #[serde(default, skip_serializing_if = "String::is_empty")]
600 pub app: String,
601}
602
603#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
604pub struct SiteChoice {
605 #[serde(default)]
606 pub at_once: bool,
607}
608
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
612pub struct Choices {
613 #[serde(default = "yes")]
615 pub hold: bool,
616 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
618 pub app: BTreeMap<String, AppChoice>,
619 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
621 pub conversation: BTreeMap<String, TalkChoice>,
622 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
624 pub site: BTreeMap<String, SiteChoice>,
625}
626
627fn yes() -> bool {
628 true
629}
630
631impl Default for Choices {
632 fn default() -> Choices {
633 Choices { hold: true, app: BTreeMap::new(), conversation: BTreeMap::new(), site: BTreeMap::new() }
634 }
635}
636
637impl Choices {
638 pub fn default_path() -> PathBuf {
639 crate::config::config_dir().join("app-notes.toml")
640 }
641
642 pub fn load(path: &Path) -> Choices {
644 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
645 }
646
647 pub fn save(&self, path: &Path) -> Result<(), String> {
648 write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
649 }
650
651 pub fn app(&self, package: &str) -> AppChoice {
652 self.app.get(package).cloned().unwrap_or_default()
653 }
654
655 pub fn through(&self, conversation: &str) -> Through {
656 self.conversation.get(conversation).map_or(Through::Usual, |c| c.through)
657 }
658}
659
660fn write_whole(path: &Path, text: &str) -> Result<(), String> {
662 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
663 if let Some(parent) = path.parent() {
664 std::fs::create_dir_all(parent).map_err(fail)?;
665 }
666 let temporary = path.with_extension(format!("toml.{}", std::process::id()));
667 std::fs::write(&temporary, text).map_err(fail)?;
668 std::fs::rename(&temporary, path).map_err(fail)
669}
670
671pub trait TimeSource {
676 fn mode(&self, at: &Zoned) -> Mode;
677 fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned>;
680}
681
682#[derive(Debug, Clone)]
684pub struct Live {
685 pub clock: crate::reach::Clock,
686}
687
688impl TimeSource for Live {
689 fn mode(&self, at: &Zoned) -> Mode {
690 self.clock.mode(at)
691 }
692
693 fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned> {
694 let stamp = from.timestamp().as_second();
695 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()
696 }
697}
698
699pub struct Gate {
702 pub until: Option<i64>,
704}
705
706pub struct Ask<'a> {
708 pub now: &'a Zoned,
709 pub clock: &'a dyn TimeSource,
710 pub attention: &'a Attention,
712 pub gathered: &'a [String],
714 pub area: Option<Area>,
716 pub gate: Option<Gate>,
717 pub slot_at: &'a dyn Fn(&Zoned) -> bool,
719 pub nothing: bool,
721 pub also: &'a [i64],
724 pub sources: &'a [String],
727 pub reachable: Option<i64>,
730 pub silenced: bool,
734}
735
736impl Ask<'_> {
737 fn stamp(&self) -> i64 {
738 self.now.timestamp().as_second()
739 }
740
741 fn reachable_now(&self) -> bool {
743 self.reachable.is_some_and(|until| self.stamp() < until)
744 }
745
746 fn gated(&self, at: &Zoned) -> bool {
748 self.gate.as_ref().is_some_and(|g| at.timestamp().as_second() < g.until.unwrap_or(self.stamp() + GATE_RECHECK))
749 }
750
751 fn moments(&self) -> Vec<Zoned> {
755 let stamp = self.stamp();
756 let Ok(until) = self.now.checked_add(jiff::Span::new().hours(HORIZON_DAYS * 24)) else { return Vec::new() };
757 let mut moments = self.clock.starts(self.now, &until);
758 let gate_end = self.gate.as_ref().map(|g| g.until.unwrap_or(stamp + GATE_RECHECK));
759 for second in self.also.iter().copied().chain(gate_end).chain(self.reachable).filter(|s| *s > stamp && *s < until.timestamp().as_second()) {
760 if let Ok(t) = jiff::Timestamp::from_second(second) {
761 moments.push(t.to_zoned(self.now.time_zone().clone()));
762 }
763 }
764 moments.sort_by_key(Zoned::timestamp);
765 moments.dedup_by_key(|z| z.timestamp());
766 moments
767 }
768
769 fn moment(&self, at: &Zoned, mode: &Mode) -> attention::Now {
772 let mut now = attention::Now::of(mode).layers((self.slot_at)(at), self.gated(at));
773 now.nothing = self.nothing && mode.free();
774 now.reachable = self.reachable.is_some_and(|until| at.timestamp().as_second() < until);
775 now.silenced = self.silenced && at.timestamp().as_second() <= self.stamp();
777 now
778 }
779
780 fn level(&self, kind: attention::Kind, at: &Zoned, mode: &Mode) -> Level {
782 self.attention.level(attention::Row::Own(kind), &self.moment(at, mode))
783 }
784
785 fn area_fits(&self, mode: &Mode) -> bool {
787 self.area.is_none_or(|a| in_view(a, mode.time, mode.week))
788 }
789}
790
791#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
793#[serde(rename_all = "kebab-case")]
794pub enum Why {
795 Untouched,
797 Code,
799 Times,
801 Off,
803 AtOnce,
805 Allowed,
807 Always,
809 Waiting,
811 Gathered,
813 Never,
815 Again,
817 Answered,
820 Released,
823}
824
825impl Why {
826 pub fn id(self) -> &'static str {
827 match self {
828 Why::Untouched => "untouched",
829 Why::Code => "code",
830 Why::Times => "times",
831 Why::Off => "off",
832 Why::AtOnce => "at-once",
833 Why::Allowed => "allowed",
834 Why::Always => "always",
835 Why::Waiting => "waiting",
836 Why::Gathered => "gathered",
837 Why::Never => "never",
838 Why::Again => "again",
839 Why::Answered => "answered",
840 Why::Released => "released",
841 }
842 }
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
847pub struct Decision {
848 pub until: Option<i64>,
850 pub why: Why,
851}
852
853impl Decision {
854 pub fn through(why: Why) -> Decision {
855 Decision { until: None, why }
856 }
857
858 pub fn held(until: i64, why: Why) -> Decision {
859 Decision { until: Some(until), why }
860 }
861
862 pub fn answered(self) -> Decision {
867 if self.until.is_some() && self.why != Why::Never { Decision::through(Why::Answered) } else { self }
868 }
869
870 pub fn released(self, held: &Held, ledger: &Ledger) -> Decision {
876 if self.until.is_some() && self.why != Why::Never && ledger.released_before(held) { Decision::through(Why::Released) } else { self }
877 }
878}
879
880pub fn first_moment(ask: &Ask, fits: &dyn Fn(&Zoned, &Mode) -> bool) -> Option<Zoned> {
883 if fits(ask.now, &ask.clock.mode(ask.now)) {
884 return Some(ask.now.clone());
885 }
886 ask.moments().into_iter().find(|at| fits(at, &ask.clock.mode(at)))
887}
888
889pub fn times_of(gathered: &[String]) -> Vec<jiff::civil::Time> {
892 let read = |text: &str| {
893 let (h, m) = text.trim().split_once([':', 'h', 'H'])?;
894 let (h, m) = (h.trim().parse::<i8>().ok()?, if m.trim().is_empty() { 0 } else { m.trim().parse::<i8>().ok()? });
895 jiff::civil::Time::new(h, m, 0, 0).ok()
896 };
897 let mut times: Vec<jiff::civil::Time> = gathered.iter().filter_map(|t| read(t)).collect();
898 if times.is_empty() {
899 times = [9, 13, 18].iter().filter_map(|h| jiff::civil::Time::new(*h, 0, 0, 0).ok()).collect();
900 }
901 times.sort();
902 times.dedup();
903 times
904}
905
906pub fn next_gathering(ask: &Ask) -> Option<Zoned> {
912 let times = times_of(ask.gathered);
913 let zone = ask.now.time_zone().clone();
914 let stamp = ask.stamp();
915 for day in 0..=HORIZON_DAYS {
916 let Ok(date) = ask.now.date().checked_add(jiff::Span::new().days(day)) else { continue };
917 for time in × {
918 let Ok(at) = date.to_datetime(*time).to_zoned(zone.clone()) else { continue };
919 if at.timestamp().as_second() + GRACE <= stamp {
920 continue;
921 }
922 let mode = ask.clock.mode(&at);
923 if ask.level(attention::Kind::AppAutomatons, &at, &mode) == Level::Gathered && ask.area_fits(&mode) {
924 return Some(at);
925 }
926 }
927 }
928 None
929}
930
931fn gatherings(ask: &Ask) -> Vec<Zoned> {
934 let zone = ask.now.time_zone().clone();
935 let stamp = ask.stamp();
936 let times = times_of(ask.gathered);
937 let mut out = Vec::new();
938 for day in 0..=HORIZON_DAYS {
939 let Ok(date) = ask.now.date().checked_add(jiff::Span::new().days(day)) else { continue };
940 for time in × {
941 if let Ok(at) = date.to_datetime(*time).to_zoned(zone.clone())
942 && at.timestamp().as_second() + GRACE > stamp
943 {
944 out.push(at);
945 }
946 }
947 }
948 out
949}
950
951pub fn first_time(ask: &Ask, level_at: &dyn Fn(&Zoned, &Mode) -> Level) -> Option<Zoned> {
955 let mut candidates: Vec<(Zoned, bool)> = vec![(ask.now.clone(), false)];
956 candidates.extend(ask.moments().into_iter().map(|at| (at, false)));
957 candidates.extend(gatherings(ask).into_iter().map(|at| (at, true)));
958 candidates.sort_by_key(|(at, gathering)| (at.timestamp(), !*gathering));
959 candidates
960 .into_iter()
961 .find(|(at, gathering)| match level_at(at, &ask.clock.mode(at)) {
962 Level::Now => true,
963 Level::Gathered => *gathering,
964 _ => false,
965 })
966 .map(|(at, _)| at)
967}
968
969fn by_matrix(ask: &Ask, events: &[Event], now: Why, later: Why) -> Decision {
976 let stamp = ask.stamp();
977 let Some(first) = events.first() else { return Decision::through(now) };
978 let chosen = ask.attention.decide(first, &ask.moment(ask.now, &ask.clock.mode(ask.now))).step == attention::Step::Chosen;
979 let why = |usual: Why| if chosen { Why::Times } else { usual };
980 let next = events.iter().find_map(|event| first_time(ask, &|at, mode| ask.attention.decide(event, &ask.moment(at, mode)).level));
981 match next {
982 Some(at) if at.timestamp().as_second() <= stamp => Decision::through(why(now)),
983 Some(at) => Decision::held(at.timestamp().as_second(), why(later)),
984 None => Decision::held(stamp + RECHECK, why(later)),
985 }
986}
987
988fn gathered(ask: &Ask) -> Decision {
994 let event = Event::own(attention::Kind::AppAutomatons).for_area(ask.area).from_sources(ask.sources.to_vec());
995 by_matrix(ask, &[event], Why::Gathered, Why::Gathered)
996}
997
998fn at_once(ask: &Ask) -> Decision {
1002 if ask.attention.has_sources(ask.sources) {
1003 let event = Event::own(attention::Kind::AppAtOnce).for_area(ask.area).from_sources(ask.sources.to_vec());
1004 return by_matrix(ask, &[event], Why::AtOnce, Why::AtOnce);
1005 }
1006 match first_moment(ask, &|at, mode| ask.level(attention::Kind::AppAtOnce, at, mode) == Level::Now && ask.area_fits(mode)) {
1007 Some(at) if at.timestamp().as_second() <= ask.stamp() => Decision::through(Why::AtOnce),
1008 Some(at) => Decision::held(at.timestamp().as_second(), Why::AtOnce),
1009 None => Decision::held(ask.stamp() + RECHECK, Why::AtOnce),
1010 }
1011}
1012
1013fn person(talk: &Talk, who: Who, group: bool, always: bool, ask: &Ask) -> Decision {
1019 if who == Who::Blocked {
1020 return Decision::held(ask.stamp() + FOR_GOOD, Why::Never);
1021 }
1022 if ask.attention.has_sources(ask.sources) {
1024 let event = Event::of(Source::Message { via: talk.via, who, group, always }).from_sources(ask.sources.to_vec());
1025 return by_matrix(ask, &[event.clone().for_area(ask.area), event], Why::Allowed, Why::Waiting);
1026 }
1027 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;
1028 let next = first_moment(ask, &|at, mode| open(at, mode, ask.area)).or_else(|| first_moment(ask, &|at, mode| open(at, mode, None)));
1030 match next {
1031 Some(at) if at.timestamp().as_second() <= ask.stamp() => Decision::through(Why::Allowed),
1032 Some(at) => Decision::held(at.timestamp().as_second(), Why::Waiting),
1033 None => Decision::held(ask.stamp() + RECHECK, Why::Waiting),
1034 }
1035}
1036
1037fn always(talk: &Talk, who: Who, group: bool, ask: &Ask) -> Decision {
1040 match person(talk, who, group, true, ask) {
1041 Decision { until: None, why: Why::Allowed } => Decision::through(Why::Always),
1042 other => other,
1043 }
1044}
1045
1046enum Route<'a> {
1049 Through(Why),
1051 AtOnce,
1053 Gathered(Gathering),
1055 ForGood,
1057 Person { talk: &'a Talk, who: Who, group: bool, always: bool, conversation: bool },
1060}
1061
1062fn route<'a>(kind: &'a Kind, who: Option<Who>, listed: bool, choices: &Choices) -> Route<'a> {
1066 match kind {
1067 Kind::Untouched => Route::Through(Why::Untouched),
1068 Kind::Code => Route::Through(Why::Code),
1069 _ if !choices.hold => Route::Through(Why::Off),
1070 Kind::AtOnce => Route::AtOnce,
1071 Kind::Automaton { .. } => Route::Gathered(Gathering::Automaton),
1072 Kind::People(talk) => {
1073 if who == Some(Who::Blocked) {
1074 return Route::ForGood;
1075 }
1076 let group = talk.conversation.as_ref().is_some_and(|c| c.group);
1078 let through = talk.conversation.as_ref().map_or(Through::Usual, |c| choices.through(&c.key));
1079 let known = who.unwrap_or(Who::Stranger);
1080 match through {
1081 Through::Always => Route::Person { talk, who: known, group, always: true, conversation: true },
1082 Through::Gathered => Route::Gathered(Gathering::Conversation),
1083 Through::Never => Route::ForGood,
1084 Through::Usual if talk.sms && who.is_none() && matches!(talk.sender, Sender::Named { .. }) => Route::Gathered(Gathering::Named),
1086 Through::Usual => Route::Person { talk, who: known, group, always: listed && !group, conversation: false },
1087 }
1088 }
1089 }
1090}
1091
1092fn conversation_gathered(ask: &Ask) -> Decision {
1096 if ask.reachable_now() { Decision::through(Why::Allowed) } else { gathered(ask) }
1097}
1098
1099pub fn decide(kind: &Kind, who: Option<Who>, listed: bool, choices: &Choices, ask: &Ask) -> Decision {
1105 match route(kind, who, listed, choices) {
1106 Route::Through(why) => Decision::through(why),
1107 Route::AtOnce => at_once(ask),
1108 Route::Gathered(Gathering::Conversation) => conversation_gathered(ask),
1109 Route::Gathered(_) => gathered(ask),
1110 Route::ForGood => Decision::held(ask.stamp() + FOR_GOOD, Why::Never),
1111 Route::Person { talk, who, group, conversation: true, .. } => always(talk, who, group, ask),
1112 Route::Person { talk, who, group, always, .. } => person(talk, who, group, always, ask),
1113 }
1114}
1115
1116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1118pub enum Gathering {
1119 Automaton,
1121 App,
1123 Conversation,
1125 Named,
1128 Machine,
1132}
1133
1134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1136pub enum Own {
1137 Conversation,
1138 Person,
1140 App,
1141}
1142
1143#[derive(Debug, Clone, Default, PartialEq, Eq)]
1147pub struct Cause {
1148 pub for_good: bool,
1150 pub gathered: Option<Gathering>,
1152 pub own: Option<Own>,
1154 pub area: Option<Area>,
1156 pub columns: Vec<attention::Column>,
1159 pub nothing: bool,
1161}
1162
1163pub fn cause(package: &str, kind: &Kind, who: Option<Who>, listed: bool, choices: &Choices, ask: &Ask) -> Option<Cause> {
1169 let mode = ask.clock.mode(ask.now);
1170 let moment = ask.moment(ask.now, &mode);
1171 let mut cause = Cause { nothing: moment.nothing, ..Cause::default() };
1172 let sources = ask.sources.to_vec();
1173 let event = match route(kind, who, listed, choices) {
1174 Route::Through(_) => return None,
1175 Route::ForGood => {
1176 cause.for_good = true;
1177 return Some(cause);
1178 }
1179 Route::Gathered(how) => {
1180 cause.gathered = Some(if how == Gathering::Automaton && choices.app(package).kind == AppKind::Automaton { Gathering::App } else { how });
1181 Event::own(attention::Kind::AppAutomatons)
1182 }
1183 Route::AtOnce => Event::own(attention::Kind::AppAtOnce),
1184 Route::Person { talk, who, group, always, .. } => Event::of(Source::Message { via: talk.via, who, group, always }),
1185 };
1186 let event = event.for_area(ask.area).from_sources(sources);
1187 let out = ask.attention.decide(&event, &moment);
1188 if out.step == attention::Step::Chosen {
1189 let rows = if matches!(event.source, Source::Message { always: true, .. }) { event.sources.iter().filter(|id| id.starts_with(attention::CONVERSATION_ROW)).cloned().collect() } else { event.sources.clone() };
1190 cause.own = ask.attention.chosen_source(&rows, &moment).map(|id| if id.starts_with(attention::CONVERSATION_ROW) { Own::Conversation } else if attention::is_person(id) { Own::Person } else { Own::App });
1191 }
1192 if out.step == attention::Step::Area {
1193 cause.area = ask.area;
1194 }
1195 cause.columns = ask.attention.holding(&event, &moment);
1196 if cause.gathered.is_none() && cause.own.is_none() && cause.area.is_none() && cause.columns.is_empty() && !ask.area_fits(&mode) {
1198 cause.area = ask.area;
1199 }
1200 Some(cause)
1201}
1202
1203#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1208pub struct Held {
1209 pub app: String,
1211 pub label: String,
1212 pub kind: String,
1214 #[serde(default, skip_serializing_if = "String::is_empty")]
1216 pub via: String,
1217 #[serde(default, skip_serializing_if = "String::is_empty")]
1218 pub who: String,
1219 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1221 pub admitted: bool,
1222 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1224 pub group: bool,
1225 #[serde(default, skip_serializing_if = "String::is_empty")]
1226 pub conversation: String,
1227 #[serde(default, skip_serializing_if = "String::is_empty")]
1228 pub site: String,
1229 #[serde(default, skip_serializing_if = "Option::is_none")]
1230 pub area: Option<Area>,
1231 pub since: i64,
1232 pub until: i64,
1233 #[serde(default, skip_serializing_if = "is_zero_i64")]
1238 pub first: i64,
1239 #[serde(default, skip_serializing_if = "is_zero")]
1241 pub again: u32,
1242 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1247 pub persons: Vec<String>,
1248}
1249
1250impl Held {
1251 pub fn first_held(&self) -> i64 {
1253 if self.first > 0 { self.first } else { self.since }
1254 }
1255}
1256
1257fn is_zero(n: &u32) -> bool {
1258 *n == 0
1259}
1260
1261#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1263pub struct SeenApp {
1264 pub label: String,
1265 pub seen: i64,
1266 pub why: String,
1268 #[serde(default, skip_serializing_if = "is_zero_i64")]
1269 pub until: i64,
1270 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1273 pub rang: BTreeMap<String, Rang>,
1274}
1275
1276fn is_zero_i64(n: &i64) -> bool {
1277 *n == 0
1278}
1279
1280#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1281pub struct Rang {
1282 pub name: String,
1283 pub at: i64,
1284}
1285
1286#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1288pub struct SeenTalk {
1289 pub app: String,
1290 pub label: String,
1291 pub title: String,
1292 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1293 pub group: bool,
1294 pub seen: i64,
1295}
1296
1297#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1299pub struct SeenSite {
1300 pub app: String,
1301 pub seen: i64,
1302}
1303
1304#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1306pub struct Back {
1307 pub at: i64,
1308 pub apps: Vec<String>,
1309}
1310
1311#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1313pub struct Ledger {
1314 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1316 pub held: BTreeMap<String, Held>,
1317 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1318 pub apps: BTreeMap<String, SeenApp>,
1319 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1320 pub conversations: BTreeMap<String, SeenTalk>,
1321 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1322 pub sites: BTreeMap<String, SeenSite>,
1323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1324 pub back: Vec<Back>,
1325 #[serde(default, skip_serializing_if = "is_zero_i64")]
1327 pub heard: i64,
1328 #[serde(default, skip_serializing_if = "is_zero_i64")]
1332 pub released: i64,
1333 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1338 pub released_by: BTreeMap<String, i64>,
1339 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1343 pub applied: BTreeMap<String, i64>,
1344 #[serde(default, skip_serializing_if = "is_zero_i64")]
1348 pub released_through: i64,
1349 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1354 pub ahead: BTreeMap<String, i64>,
1355}
1356
1357pub const RELEASE_AHEAD: i64 = 24 * 3600;
1360
1361impl Ledger {
1362 pub fn apply_releases(&mut self, stamps: &[(String, i64)], now: i64) -> (bool, Vec<String>) {
1369 let (mut applied, mut ahead) = (false, Vec::new());
1370 for (device, stamp) in stamps {
1371 let done = self.released_by.get(device).copied().unwrap_or(0).max(self.released);
1372 if *stamp <= done || self.ahead.get(device) == Some(stamp) || self.applied.get(device) == Some(stamp) {
1375 continue;
1376 }
1377 if stamp / 1000 > now.saturating_add(RELEASE_AHEAD) {
1378 if self.ahead.get(device) != Some(stamp) {
1379 self.ahead.insert(device.clone(), *stamp);
1380 ahead.push(device.clone());
1381 }
1382 continue;
1383 }
1384 self.released_by.insert(device.clone(), (*stamp).min(now.saturating_mul(1000)));
1388 self.applied.insert(device.clone(), *stamp);
1389 self.released_through = self.released_through.max((stamp / 1000).min(now));
1390 applied = true;
1391 }
1392 (applied, ahead)
1393 }
1394
1395 pub fn released_before(&self, held: &Held) -> bool {
1397 self.released_through > 0 && held.first_held() <= self.released_through
1398 }
1399
1400 pub fn default_path() -> PathBuf {
1401 crate::config::state_dir().join("app-notes-seen.toml")
1402 }
1403
1404 pub fn load(path: &Path) -> Ledger {
1406 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
1407 }
1408
1409 pub fn save(&self, path: &Path) -> Result<(), String> {
1410 write_whole(path, &toml::to_string(self).map_err(|e| e.to_string())?)
1411 }
1412
1413 pub fn note(&mut self, p: &Posted, kind: &Kind, who: Option<Who>, admitted: bool, area: Option<Area>, decision: &Decision, now: i64) {
1415 self.heard = now;
1416 let label = if p.app.trim().is_empty() { p.package.clone() } else { one_line(&p.app) };
1417 let app = self.apps.entry(p.package.clone()).or_default();
1418 app.label = label.clone();
1419 app.seen = now;
1420 app.why = decision.why.id().to_string();
1421 app.until = decision.until.unwrap_or(0);
1422 if !p.channel.is_empty() {
1424 if decision.until.is_some() && p.alerted && !p.intercepted {
1425 app.rang.insert(p.channel.clone(), Rang { name: one_line(&p.channel_name), at: now });
1426 } else if (0..3).contains(&p.importance) {
1427 app.rang.remove(&p.channel);
1428 }
1429 }
1430 match kind {
1431 Kind::People(talk) => {
1432 if let Some(c) = &talk.conversation {
1433 self.conversations.insert(c.key.clone(), SeenTalk { app: p.package.clone(), label: label.clone(), title: c.title.clone(), group: c.group, seen: now });
1434 }
1435 }
1436 Kind::Automaton { site } if !site.is_empty() => {
1437 self.sites.insert(site.clone(), SeenSite { app: p.package.clone(), seen: now });
1438 }
1439 _ => {}
1440 }
1441 let hash = key_hash(&p.key);
1442 let Some(until) = decision.until else {
1443 self.held.remove(&hash);
1444 return;
1445 };
1446 let (kind_id, via, conversation, site) = match kind {
1447 Kind::People(talk) => ("people", talk.via.id(), talk.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(), String::new()),
1448 Kind::Automaton { site } => ("gathered", "", String::new(), site.clone()),
1449 Kind::AtOnce => ("at-once", "", String::new(), String::new()),
1450 _ => ("gathered", "", String::new(), String::new()),
1451 };
1452 let kind_id = match decision.why {
1453 Why::Never => "never",
1454 Why::Gathered => "gathered",
1455 _ => kind_id,
1456 };
1457 let group = matches!(kind, Kind::People(t) if t.conversation.as_ref().is_some_and(|c| c.group));
1460 let admitted = admitted && !group;
1461 let again = self.held.get(&hash).map_or(0, |h| h.again);
1462 let first = self.held.get(&hash).map_or(0, Held::first_held);
1463 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, first, again, persons: Vec::new() });
1464 }
1465
1466 pub fn came_back(&mut self, hash: &str, now: i64) {
1469 let Some(held) = self.held.remove(hash) else { return };
1470 match self.back.last_mut() {
1471 Some(back) if now - back.at < 120 => {
1472 if !back.apps.contains(&held.label) {
1473 back.apps.push(held.label);
1474 }
1475 }
1476 _ => self.back.push(Back { at: now, apps: vec![held.label] }),
1477 }
1478 }
1479
1480 pub fn returning(&self, hash: &str, now: i64) -> bool {
1482 self.held.get(hash).is_some_and(|h| now >= h.until - 120)
1483 }
1484
1485 pub fn forget_old(&mut self, now: i64, choices: &Choices) {
1489 self.held.retain(|_, h| h.until + 86_400 > now);
1490 self.apps.retain(|package, a| now - a.seen < SEEN_KEPT || choices.app.contains_key(package));
1491 self.sites.retain(|host, s| now - s.seen < SEEN_KEPT || choices.site.contains_key(host));
1492 self.conversations.retain(|key, c| now - c.seen < TALK_KEPT || choices.conversation.contains_key(key));
1493 if self.conversations.len() > TALKS_MAX {
1494 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();
1495 by_age.sort();
1496 for (_, key) in by_age.into_iter().take(self.conversations.len() - TALKS_MAX) {
1497 self.conversations.remove(&key);
1498 }
1499 }
1500 for app in self.apps.values_mut() {
1501 app.rang.retain(|_, r| now - r.at < TALK_KEPT);
1502 }
1503 self.back.retain(|b| now - b.at < 86_400);
1504 }
1505}
1506
1507pub fn again(held: &Held, choices: &Choices, ask: &Ask) -> Decision {
1511 if !choices.hold {
1512 return Decision::through(Why::Off);
1513 }
1514 let app = choices.app(&held.app);
1515 if app.kind == AppKind::AtOnce || (!held.site.is_empty() && choices.site.get(&held.site).is_some_and(|s| s.at_once)) {
1516 return at_once(ask);
1517 }
1518 let who = Who::read(&held.who);
1519 if who == Some(Who::Blocked) {
1521 return Decision::held(held.until.max(ask.stamp() + 60), Why::Never);
1522 }
1523 let talk = Talk { via: Channel::read(&held.via).unwrap_or(Channel::Messages), sender: Sender::Unknown, conversation: None, to: String::new(), sms: false };
1524 if !held.conversation.is_empty() {
1525 match choices.through(&held.conversation) {
1526 Through::Always => return always(&talk, who.unwrap_or(Who::Stranger), held.group, ask),
1527 Through::Gathered => return conversation_gathered(ask),
1528 Through::Never => return Decision::held(held.until.max(ask.stamp() + 60), Why::Never),
1529 Through::Usual => {}
1530 }
1531 }
1532 match held.kind.as_str() {
1533 _ if app.kind == AppKind::Automaton => gathered(ask),
1534 "people" | "never" => person(&talk, who.unwrap_or(Who::Stranger), held.group, held.admitted, ask),
1535 _ => gathered(ask),
1536 }
1537}
1538
1539pub fn apps_in_words(tr: &Translator, labels: &[String]) -> String {
1545 let and = tr.text("word-and", None);
1546 match labels {
1547 [] => String::new(),
1548 [one] => one.clone(),
1549 [a, b] => format!("{a} {and} {b}"),
1550 [a, b, c] => format!("{a}, {b} {and} {c}"),
1551 [a, b, c, rest @ ..] => {
1552 let others = if rest.len() > 12 { tr.text("appnotes-others-many", None) } else { tr.text("appnotes-others", Some(&tr.counted(rest.len()))) };
1553 format!("{a}, {b}, {c} {and} {others}")
1554 }
1555 }
1556}
1557
1558pub fn porch_line(tr: &Translator, ledger: &Ledger, now: &Zoned) -> String {
1561 let stamp = now.timestamp().as_second();
1562 let mut sentences = Vec::new();
1563 let mut waiting: Vec<&Held> = ledger.held.values().filter(|h| h.kind != "never" && h.until > stamp).collect();
1564 waiting.sort_by_key(|h| (h.until, h.label.clone()));
1565 if let Some(first) = waiting.first() {
1566 let mut labels: Vec<String> = Vec::new();
1567 for h in &waiting {
1568 if !labels.contains(&h.label) {
1569 labels.push(h.label.clone());
1570 }
1571 }
1572 let mut args = crate::i18n::args();
1573 args.set("apps", apps_in_words(tr, &labels));
1574 args.set("when", when_text(tr, first.until, now));
1575 let one_time = waiting.iter().all(|h| h.until == first.until);
1576 sentences.push(tr.text(if one_time { "appnotes-porch-held" } else { "appnotes-porch-held-first" }, Some(&args)));
1577 }
1578 if let Some(back) = ledger.back.iter().rev().find(|b| stamp - b.at < BACK_SAID && !b.apps.is_empty()) {
1579 let mut args = crate::i18n::args();
1580 args.set("apps", apps_in_words(tr, &back.apps));
1581 args.set("when", when_text(tr, back.at, now));
1582 sentences.push(tr.text("appnotes-porch-back", Some(&args)));
1583 }
1584 sentences.join(" ")
1585}
1586
1587fn when_text(tr: &Translator, at: i64, now: &Zoned) -> String {
1589 match jiff::Timestamp::from_second(at) {
1590 Ok(t) => crate::quiet::until_text(tr, &t.to_zoned(now.time_zone().clone()), now),
1591 Err(_) => String::new(),
1592 }
1593}
1594
1595pub fn why_text(tr: &Translator, why: &str, until: i64, now: &Zoned) -> String {
1597 let mut args = crate::i18n::args();
1598 args.set("when", if until > 0 { when_text(tr, until, now) } else { String::new() });
1599 let held = until > now.timestamp().as_second();
1600 let id = match (why, held) {
1601 ("waiting", true) | ("gathered", true) | ("machine", true) | ("at-once", true) | ("never", true) | ("times", true) => format!("appnotes-why-{why}-held"),
1602 _ => format!("appnotes-why-{why}"),
1603 };
1604 tr.text(&id, Some(&args))
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609 use super::*;
1610
1611 fn classify(p: &Posted, choices: &Choices) -> Kind {
1613 super::classify(p, choices, crate::words::Words::builtin_ref())
1614 }
1615
1616 fn is_code(p: &Posted) -> bool {
1617 super::is_code(p, crate::words::Words::builtin_ref())
1618 }
1619 use crate::attention::{Column, Person, Row};
1620 use crate::needs::{Days, Needs};
1621 use crate::quiet::{Blocks, Overrides};
1622 use crate::window::AdminWindow;
1623
1624 fn at(text: &str) -> Zoned {
1625 text.parse().unwrap()
1626 }
1627
1628 struct TestClock {
1632 overrides: Overrides,
1633 }
1634
1635 impl TestClock {
1636 fn new() -> TestClock {
1637 TestClock { overrides: Overrides::default() }
1638 }
1639 }
1640
1641 fn week() -> Vec<AdminWindow> {
1642 ["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()
1643 }
1644
1645 impl TimeSource for TestClock {
1646 fn mode(&self, at: &Zoned) -> Mode {
1647 let needs = Needs { meals_on: true, naps_on: true, sleep_on: true, ..Needs::default() };
1648 crate::quiet::mode(&week(), &[], &self.overrides, &Blocks::of(&needs, &Days::default(), &[], at), at)
1649 }
1650
1651 fn starts(&self, from: &Zoned, until: &Zoned) -> Vec<Zoned> {
1652 let mut out = Vec::new();
1654 let mut last = self.mode(from);
1655 let mut at = from.clone();
1656 while at.timestamp() < until.timestamp() {
1657 at = at.checked_add(jiff::Span::new().minutes(5)).unwrap();
1658 let mode = self.mode(&at);
1659 if (mode.time, mode.reason.clone()) != (last.time, last.reason.clone()) {
1660 out.push(at.clone());
1661 last = mode;
1662 }
1663 }
1664 out
1665 }
1666 }
1667
1668 fn never_slot(_: &Zoned) -> bool {
1669 false
1670 }
1671
1672 fn usual() -> &'static Attention {
1675 static USUAL: std::sync::OnceLock<Attention> = std::sync::OnceLock::new();
1676 USUAL.get_or_init(|| {
1677 let mut matrix = Attention::usual();
1678 for channel in [Channel::Mail, Channel::Messages] {
1679 matrix.set(Row::People(channel, Person::Neutral), Column::Leisure, Level::Now).unwrap();
1680 }
1681 matrix
1682 })
1683 }
1684
1685 fn ask<'a>(now: &'a Zoned, clock: &'a TestClock, area: Option<Area>, gate: Option<Gate>) -> Ask<'a> {
1686 static GATHERED: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1687 let gathered = GATHERED.get_or_init(|| vec!["09:00".into(), "13:00".into(), "18:00".into()]);
1688 Ask { reachable: None, silenced: false, now, clock, attention: usual(), gathered, area, gate, slot_at: &never_slot, nothing: false, also: &[], sources: &[] }
1689 }
1690
1691 fn posted(json: &str) -> Posted {
1692 serde_json::from_str(json).unwrap()
1693 }
1694
1695 fn until_of(d: Decision) -> String {
1696 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()
1697 }
1698
1699 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"}"#;
1701 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}"#;
1702 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}"#;
1703 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}"#;
1704 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}"#;
1705 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 ?"}]}"#;
1706 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."}]}"#;
1707 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"}]}"##;
1708 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 ?"}]}"#;
1709 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"}"#;
1710 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"}"#;
1711 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"}"#;
1712 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."}"#;
1713 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}"#;
1714 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."}"#;
1715 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."}"#;
1716 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}"#;
1717
1718 #[test]
1719 fn what_a_notification_is() {
1720 let choices = Choices::default();
1721 let talk = |json: &str| match classify(&posted(json), &choices) {
1722 Kind::People(t) => t,
1723 other => panic!("{other:?}"),
1724 };
1725 let sms = talk(SMS_NUMBER);
1727 assert_eq!((sms.via, sms.sms), (Channel::Messages, true));
1728 assert_eq!(sms.sender, Sender::Known { numbers: vec!["+262 6 39 98 01 01".into()], emails: vec![], contact: false });
1729 assert_eq!(talk(SMS_CONTACT).sender, Sender::Known { numbers: vec!["+262639980102".into()], emails: vec![], contact: false });
1730 assert_eq!(talk(SMS_SENDER_ID).sender, Sender::Named { name: "AMELI".into(), book: true });
1731 assert_eq!(classify(&posted(SMS_SHORT), &choices), Kind::Automaton { site: String::new() });
1732 assert_eq!(classify(&posted(SMS_CODE), &choices), Kind::Code);
1733 let jeanne = talk(WHATSAPP_PERSON);
1735 assert_eq!(jeanne.sender, Sender::Known { numbers: vec!["+262 6 39 98 01 03".into()], emails: vec![], contact: true }, "the phone's contact");
1736 let school = talk(WHATSAPP_GROUP);
1737 let conversation = school.conversation.clone().unwrap();
1738 assert_eq!((conversation.title.as_str(), conversation.group), ("École Jules Ferry", true));
1739 assert_eq!(conversation.key, talk_key("com.whatsapp", "120363000000000001@g.us"));
1740 assert_eq!(school.sender, Sender::Named { name: "Mme Durand".into(), book: true });
1741 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"}]}"#;
1743 assert_eq!(talk(by_name).sender, Sender::Known { numbers: vec!["+262 6 39 98 01 04".into()], emails: vec![], contact: true });
1744 let discord_by_name = by_name.replace("com.whatsapp", "com.discord");
1745 assert_eq!(talk(discord_by_name.as_str()).sender, Sender::Named { name: "Paul".into(), book: false });
1746 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."}]}"#;
1748 assert_eq!(classify(&posted(bot), &choices), Kind::Automaton { site: String::new() });
1749 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() });
1750 assert_eq!(number_in("06 39 98 01"), Some("06 39 98 01".into()), "eight digits");
1751 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"}]}"#);
1753 assert_eq!(replied.sender, Sender::Named { name: "alice".into(), book: false });
1754 assert!(!replied.conversation.unwrap().group);
1755 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"}]}"#);
1757 assert!(two.conversation.unwrap().group);
1758 let missed = talk(r#"{"key":"m","package":"com.android.dialer","category":"missed_call","title":"Maman"}"#);
1760 assert_eq!((missed.via, missed.sender), (Channel::Calls, Sender::Named { name: "Maman".into(), book: true }));
1761 let plain = talk(r#"{"key":"k2","package":"com.android.mms","app":"SMS","title":"Paul","text":"Salut"}"#);
1763 assert_eq!(plain.conversation.map(|c| c.key), Some(talk_key("com.android.mms", "Paul")));
1764 let server = talk(DISCORD_SERVER);
1766 assert!(server.conversation.as_ref().unwrap().group);
1767 assert_eq!(server.sender, Sender::Named { name: "ferris".into(), book: false });
1768 let dm = talk(DISCORD_DM);
1769 assert_eq!((dm.conversation.as_ref().unwrap().group, dm.conversation.as_ref().unwrap().title.as_str()), (false, "alice"));
1770 assert_eq!(classify(&posted(CHROME_SITE), &choices), Kind::Automaton { site: "forum.example.net".into() });
1772 assert_eq!(classify(&posted(FIREFOX_SITE), &choices), Kind::Automaton { site: "news.example.com".into() });
1773 let proton = talk(PROTON);
1775 assert_eq!((proton.via, proton.to.as_str()), (Channel::Mail, "me@proton.example"));
1776 assert_eq!(proton.sender, Sender::Named { name: "Alice Martin".into(), book: false });
1777 assert_eq!(classify(&posted(PROTON_LOGIN), &choices), Kind::Code);
1778 assert_eq!(classify(&posted(SHOP), &choices), Kind::Automaton { site: String::new() });
1780 for json in [BANK_CODE, BANK_APPROVAL, REDACTED] {
1781 assert_eq!(classify(&posted(json), &choices), Kind::Code, "{json}");
1782 }
1783 let asked = |text: &str| is_code(&Posted { title: "Alice".into(), text: text.into(), ..Posted::default() });
1784 assert!(asked("Is this you signing in from Lyon?") && asked("Est-ce bien vous qui tentez une connexion ?"));
1785 assert!(!asked("Is this you on the photo? Haha") && !asked("C'est bien vous sur la photo ?"));
1786 let config: crate::config::Config = toml::from_str("[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n[words.approvals.phrases]\nadd = [\"Zahlung freigeben\"]\n").unwrap();
1788 let german = Posted { title: "Bank".into(), text: "Bitte Zahlung freigeben in der App.".into(), ..Posted::default() };
1789 assert!(!is_code(&german) && super::is_code(&german, &crate::words::Words::of(&config)));
1790 for json in [
1792 r#"{"package":"com.whatsapp","category":"call","title":"Jeanne"}"#,
1793 r#"{"package":"org.fossify.clock","category":"alarm","full_screen":true}"#,
1794 r#"{"package":"org.example.music","ongoing":true,"title":"A song"}"#,
1795 r#"{"package":"com.whatsapp","summary":true,"title":"3 messages"}"#,
1796 r#"{"package":"ws.xsoh.etar","category":"event","title":"Dentist at 14:00"}"#,
1797 r#"{"package":"com.aurelienpierre.sioul","title":"Taken"}"#,
1798 ] {
1799 assert_eq!(classify(&posted(json), &choices), Kind::Untouched, "{json}");
1800 }
1801 let mut chosen = Choices::default();
1803 chosen.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, ..AppChoice::default() });
1804 chosen.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, ..AppChoice::default() });
1805 chosen.site.insert("forum.example.net".into(), SiteChoice { at_once: true });
1806 assert_eq!(classify(&posted(DISCORD_DM), &chosen), Kind::Automaton { site: String::new() });
1807 assert_eq!(classify(&posted(SHOP), &chosen), Kind::AtOnce);
1808 assert_eq!(classify(&posted(CHROME_SITE), &chosen), Kind::AtOnce);
1809 chosen.app.insert("org.example.old.sms".into(), AppChoice { kind: AppKind::People, ..AppChoice::default() });
1811 let old = classify(&posted(r#"{"package":"org.example.old.sms","title":"+262 6 39 98 01 05","text":"Coucou"}"#), &chosen);
1812 assert!(matches!(old, Kind::People(Talk { sender: Sender::Known { .. }, .. })), "{old:?}");
1813 }
1814
1815 #[test]
1816 fn names_anyone_can_take_only_hold_more() {
1817 assert_eq!(named(Some(Who::Safe), true), Who::Safe, "WhatsApp names people as the address book does");
1818 assert_eq!(named(Some(Who::Safe), false), Who::Stranger, "a Discord name is anyone's");
1819 assert_eq!(named(Some(Who::Neutral), false), Who::Stranger);
1820 assert_eq!(named(Some(Who::Restricted), false), Who::Restricted);
1821 assert_eq!(named(Some(Who::Blocked), false), Who::Blocked);
1822 assert_eq!(named(None, true), Who::Stranger);
1823 }
1824
1825 #[test]
1826 fn who_and_when_decide() {
1827 let clock = TestClock::new();
1828 let choices = Choices::default();
1829 let kind = |json: &str| classify(&posted(json), &choices);
1830 let morning = at("2026-10-06T10:00[Europe/Paris]");
1832 let evening = at("2026-10-06T20:30[Europe/Paris]");
1833 let night = at("2026-10-06T23:30[Europe/Paris]");
1834 let decide_at = |json: &str, who: Option<Who>, now: &Zoned| decide(&kind(json), who, false, &choices, &ask(now, &clock, None, None));
1835 assert_eq!(decide_at(SMS_NUMBER, Some(Who::Stranger), &morning), Decision::through(Why::Allowed));
1837 let held = decide_at(SMS_NUMBER, Some(Who::Stranger), &evening);
1838 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1839 assert_eq!(decide_at(WHATSAPP_PERSON, Some(Who::Neutral), &evening), Decision::through(Why::Allowed));
1841 let held = decide_at(WHATSAPP_PERSON, Some(Who::Neutral), &night);
1842 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1843 assert_eq!(decide_at(SMS_CONTACT, Some(Who::Safe), &evening), Decision::through(Why::Allowed));
1846 let held = decide_at(SMS_CONTACT, Some(Who::Safe), &night);
1847 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1848 assert_eq!(decide(&kind(SMS_CONTACT), Some(Who::Safe), true, &choices, &ask(&night, &clock, None, None)), Decision::through(Why::Allowed));
1849 assert_eq!(decide_at(SMS_CONTACT, Some(Who::Blocked), &morning).why, Why::Never);
1850 assert_eq!(decide(&kind(SMS_CONTACT), Some(Who::Blocked), true, &choices, &ask(&morning, &clock, None, None)).why, Why::Never);
1851 let ameli = decide_at(SMS_SENDER_ID, None, &morning);
1853 assert_eq!((ameli.why, until_of(ameli).as_str()), (Why::Gathered, "2026-10-06T13:00:00"));
1854 assert_eq!(decide_at(SMS_SENDER_ID, Some(Who::Neutral), &morning), Decision::through(Why::Allowed));
1855 assert_eq!(decide_at(WHATSAPP_GROUP, Some(Who::Safe), &evening), Decision::through(Why::Allowed));
1858 let group = decide_at(WHATSAPP_GROUP, Some(Who::Safe), &night);
1859 assert_eq!((group.why, until_of(group).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
1860 let mut chosen = Choices::default();
1862 let school = talk_key("com.whatsapp", "120363000000000001@g.us");
1863 chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Always, title: "École Jules Ferry".into(), app: "WhatsApp".into() });
1864 for now in [&evening, &night] {
1865 assert_eq!(decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(now, &clock, None, None)), Decision::through(Why::Always));
1866 }
1867 assert_eq!(decide(&kind(WHATSAPP_GROUP), Some(Who::Blocked), false, &chosen, &ask(&evening, &clock, None, None)).why, Why::Never);
1869 chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Gathered, ..TalkChoice::default() });
1871 let gathered = decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(&morning, &clock, None, None));
1872 assert_eq!((gathered.why, until_of(gathered).as_str()), (Why::Gathered, "2026-10-06T13:00:00"));
1873 chosen.conversation.insert(school, TalkChoice { through: Through::Never, ..TalkChoice::default() });
1874 assert_eq!(decide(&kind(WHATSAPP_GROUP), None, false, &chosen, &ask(&morning, &clock, None, None)).why, Why::Never);
1875 assert_eq!(decide_at(BANK_CODE, None, &night), Decision::through(Why::Code));
1877 let off = Choices { hold: false, ..Choices::default() };
1878 assert_eq!(decide(&kind(SHOP), None, false, &off, &ask(&night, &clock, None, None)), Decision::through(Why::Off));
1879 let mut at_once = Choices::default();
1881 at_once.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, ..AppChoice::default() });
1882 let shop = classify(&posted(SHOP), &at_once);
1883 assert_eq!(decide(&shop, None, false, &at_once, &ask(&evening, &clock, None, None)), Decision::through(Why::AtOnce));
1884 let waking = decide(&shop, None, false, &at_once, &ask(&night, &clock, None, None));
1885 assert_eq!((waking.why, until_of(waking).as_str()), (Why::AtOnce, "2026-10-07T07:00:00"));
1886 }
1887
1888 #[test]
1889 fn mail_and_areas() {
1890 let clock = TestClock::new();
1891 let choices = Choices::default();
1892 let proton = classify(&posted(PROTON), &choices);
1893 let evening = at("2026-10-06T20:30[Europe/Paris]");
1894 let morning = at("2026-10-06T10:00[Europe/Paris]");
1895 let who = named(Some(Who::Safe), false);
1897 let held = decide(&proton, Some(who), false, &choices, &ask(&evening, &clock, Some(Area::WORK), None));
1898 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T09:00:00"));
1899 let held = decide(&proton, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None));
1901 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-06T17:00:00"));
1902 assert_eq!(decide(&proton, Some(Who::Safe), false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None)).why, Why::Allowed);
1904 let shop = classify(&posted(SHOP), &choices);
1906 let held = decide(&shop, None, false, &choices, &ask(&evening, &clock, Some(Area::WORK), None));
1907 assert_eq!(until_of(held), "2026-10-07T09:00:00");
1908 let held = decide(&shop, None, false, &choices, &ask(&morning, &clock, Some(Area::LEISURE), None));
1910 assert_eq!(until_of(held), "2026-10-06T18:00:00");
1911 }
1912
1913 #[test]
1914 fn gathered_times_across_midnight_sleep_and_pauses() {
1915 let clock = TestClock::new();
1916 let choices = Choices::default();
1917 let shop = classify(&posted(SHOP), &choices);
1918 let when = |now: &str, clock: &TestClock| until_of(decide(&shop, None, false, &choices, &ask(&at(now), clock, None, None)));
1919 assert_eq!(when("2026-10-06T08:00[Europe/Paris]", &clock), "2026-10-06T09:00:00");
1921 assert_eq!(when("2026-10-06T09:30[Europe/Paris]", &clock), "2026-10-06T13:00:00");
1922 assert_eq!(when("2026-10-06T13:20[Europe/Paris]", &clock), "2026-10-06T18:00:00");
1923 assert_eq!(when("2026-10-06T18:30[Europe/Paris]", &clock), "2026-10-07T09:00:00");
1925 assert_eq!(decide(&shop, None, false, &choices, &ask(&at("2026-10-06T13:00:30[Europe/Paris]"), &clock, None, None)), Decision::through(Why::Gathered));
1927 let late = vec!["06:30".to_string(), "13:00".into(), "22:30".into()];
1929 let ask_late = |now: &'static str| {
1930 let now = Box::leak(Box::new(at(now)));
1931 Ask { reachable: None, silenced: false, now, clock: &clock, attention: usual(), gathered: &late, area: None, gate: None, slot_at: &never_slot, nothing: false, also: &[], sources: &[] }
1932 };
1933 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");
1934 let paused = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1936 let now = at("2026-10-06T08:30[Europe/Paris]");
1937 let held = decide(&shop, None, false, &choices, &ask(&now, &paused, None, None));
1938 assert_eq!((held.why, until_of(held).as_str()), (Why::Gathered, "2026-10-06T14:30:00"));
1939 let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
1941 let held = decide(&shop, None, false, &choices, &ask(&at("2026-10-06T16:00[Europe/Paris]"), &free, None, None));
1942 assert_eq!(until_of(held), "2026-10-07T09:00:00", "18:00 falls in free time, which lasts until the night");
1943 let gate = Gate { until: Some(at("2026-10-06T18:30[Europe/Paris]").timestamp().as_second()) };
1945 let held = decide(&shop, None, false, &choices, &ask(&at("2026-10-06T14:00[Europe/Paris]"), &clock, None, Some(gate)));
1946 assert_eq!(until_of(held), "2026-10-07T09:00:00");
1947 let slot = |z: &Zoned| z.date() == at("2026-10-06T00:00[Europe/Paris]").date() && z.hour() == 13;
1949 let now = at("2026-10-06T10:00[Europe/Paris]");
1950 let usual = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1951 let with_slot = Ask { reachable: None, silenced: false, now: &now, clock: &clock, attention: self::usual(), gathered: &usual, area: None, gate: None, slot_at: &slot, nothing: false, also: &[], sources: &[] };
1952 assert_eq!(until_of(decide(&shop, None, false, &choices, &with_slot)), "2026-10-06T18:00:00");
1953 assert_eq!(times_of(&["later".into()]).len(), 3);
1955 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"]);
1956 }
1957
1958 #[test]
1959 fn do_not_disturb_lets_its_list_through() {
1960 let clock = TestClock::new();
1961 let choices = Choices::default();
1962 let sms = classify(&posted(SMS_CONTACT), &choices);
1963 let morning = at("2026-10-06T10:00[Europe/Paris]");
1964 let gate = || Some(Gate { until: Some(at("2026-10-06T11:00[Europe/Paris]").timestamp().as_second()) });
1965 assert_eq!(decide(&sms, Some(Who::Neutral), true, &choices, &ask(&morning, &clock, None, gate())).why, Why::Allowed);
1967 let held = decide(&sms, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, None, gate()));
1968 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-06T11:00:00"));
1969 let open = Some(Gate { until: None });
1971 let held = decide(&sms, Some(Who::Neutral), false, &choices, &ask(&morning, &clock, None, open));
1972 assert_eq!(until_of(held), "2026-10-06T10:30:00");
1973 }
1974
1975 #[test]
1983 fn what_held_one_is_said() {
1984 use crate::attention::Column as C;
1985 let clock = TestClock::new();
1986 let choices = Choices::default();
1987 let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
1988 let morning = at("2026-10-06T10:00[Europe/Paris]");
1989 let evening = at("2026-10-06T20:30[Europe/Paris]");
1990 let night = at("2026-10-06T23:30[Europe/Paris]");
1991 let said = |json: &str, who: Option<Who>, choices: &Choices, ask: &Ask| {
1993 let p = posted(json);
1994 cause(&p.package, &classify(&p, choices), who, false, choices, ask)
1995 };
1996 let columns = |columns: &[C]| Cause { columns: columns.to_vec(), ..Cause::default() };
1997 let focus = ask(&morning, &clock, None, Some(Gate { until: Some(at("2026-10-06T10:50[Europe/Paris]").timestamp().as_second()) }));
1999 assert_eq!(until_of(decide(&classify(&posted(SMS_CONTACT), &choices), Some(Who::Neutral), false, &choices, &focus)), "2026-10-06T10:50:00");
2000 assert_eq!(said(SMS_CONTACT, Some(Who::Neutral), &choices, &focus), Some(columns(&[C::Dnd])));
2001 assert_eq!(said(WHATSAPP_PERSON, Some(Who::Neutral), &choices, &ask(&night, &clock, None, None)), Some(columns(&[C::Sleep])));
2003 assert_eq!(said(SMS_NUMBER, Some(Who::Stranger), &choices, &ask(&evening, &clock, None, Some(Gate { until: None }))), Some(columns(&[C::Leisure, C::Dnd])));
2004 let named = |columns: &[C]| Some(Cause { gathered: Some(Gathering::Named), columns: columns.to_vec(), ..Cause::default() });
2006 assert_eq!(said(SMS_SENDER_ID, None, &choices, &ask(&morning, &clock, None, None)), named(&[]));
2007 assert_eq!(said(SMS_SENDER_ID, None, &choices, &ask(&evening, &clock, None, None)), named(&[]));
2008 assert_eq!(said(SMS_SENDER_ID, None, &choices, &ask(&night, &clock, None, None)), named(&[C::Sleep]));
2009 let gathering = |json: &str, choices: &Choices| said(json, None, choices, &ask(&morning, &clock, None, None)).and_then(|c| c.gathered);
2011 assert_eq!(gathering(SHOP, &choices), Some(Gathering::Automaton));
2012 let mut set = Choices::default();
2013 set.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, ..AppChoice::default() });
2014 assert_eq!(gathering(DISCORD_DM, &set), Some(Gathering::App));
2015 let school = talk_key("com.whatsapp", "120363000000000001@g.us");
2016 set.conversation.insert(school.clone(), TalkChoice { through: Through::Gathered, ..TalkChoice::default() });
2017 assert_eq!(gathering(WHATSAPP_GROUP, &set), Some(Gathering::Conversation));
2018 let for_good = Some(Cause { for_good: true, ..Cause::default() });
2020 assert_eq!(said(SMS_CONTACT, Some(Who::Blocked), &choices, &ask(&morning, &clock, None, None)), for_good);
2021 set.conversation.insert(school, TalkChoice { through: Through::Never, ..TalkChoice::default() });
2022 assert_eq!(said(WHATSAPP_GROUP, Some(Who::Safe), &set, &ask(&morning, &clock, None, None)), for_good);
2023 assert_eq!(said(PROTON, Some(Who::Neutral), &choices, &ask(&morning, &clock, Some(Area::LEISURE), None)), Some(Cause { area: Some(Area::LEISURE), ..Cause::default() }));
2025 assert_eq!(said(BANK_CODE, None, &choices, &ask(&night, &clock, None, None)), None);
2027 let key = |json: &str| match classify(&posted(json), &choices) {
2029 Kind::People(t) => t.conversation.map(|c| c.key).unwrap_or_default(),
2030 _ => String::new(),
2031 };
2032 let dm_rows = attention::source_rows("com.discord", &key(DISCORD_DM));
2033 let server_rows = attention::source_rows("com.discord", &key(DISCORD_SERVER));
2034 let mut rows = usual().clone();
2035 rows.set_source("app.com.discord", C::Leisure, Level::Later).unwrap();
2036 rows.set_source(&server_rows[0], C::Work, Level::Later).unwrap();
2037 let own = Ask { reachable: None, silenced: false, now: &evening, clock: &clock, attention: &rows, gathered: &gathered, area: None, gate: None, slot_at: &never_slot, nothing: false, also: &[], sources: &dm_rows };
2038 assert_eq!(said(DISCORD_DM, Some(Who::Safe), &choices, &own), Some(Cause { own: Some(Own::App), columns: vec![C::Leisure], ..Cause::default() }));
2039 let own = Ask { now: &morning, sources: &server_rows, ..own };
2040 assert_eq!(said(DISCORD_SERVER, Some(Who::Stranger), &choices, &own), Some(Cause { own: Some(Own::Conversation), columns: vec![C::Work], ..Cause::default() }));
2041 let person_rows = attention::source_rows_with("com.discord", &key(DISCORD_DM), &["person.contact:card-1".to_string()]);
2043 let mut theirs = usual().clone();
2044 theirs.set_source("person.contact:card-1", C::Leisure, Level::Later).unwrap();
2045 let own = Ask { now: &evening, attention: &theirs, sources: &person_rows, ..own };
2046 assert_eq!(said(DISCORD_DM, Some(Who::Safe), &choices, &own), Some(Cause { own: Some(Own::Person), columns: vec![C::Leisure], ..Cause::default() }));
2047 let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
2049 let afternoon = at("2026-10-06T16:00[Europe/Paris]");
2050 let nothing = Ask { reachable: None, silenced: false, now: &afternoon, clock: &free, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &never_slot, nothing: true, also: &[], sources: &[] };
2051 assert_eq!(said(SMS_CONTACT, Some(Who::Safe), &choices, ¬hing), Some(Cause { columns: vec![C::Free], nothing: true, ..Cause::default() }));
2052 let in_slot = |z: &Zoned| z.hour() == 9;
2054 let nine = at("2026-10-06T09:20[Europe/Paris]");
2055 let slotted = Ask { reachable: None, silenced: false, now: &nine, clock: &clock, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &in_slot, nothing: false, also: &[], sources: &[] };
2056 assert_eq!(said(SHOP, None, &choices, &slotted), Some(Cause { gathered: Some(Gathering::Automaton), columns: vec![C::Slot], ..Cause::default() }));
2057 let mut at_once = Choices::default();
2060 at_once.app.insert("com.shop.example".into(), AppChoice { kind: AppKind::AtOnce, area: Some(Area::WORK), ..AppChoice::default() });
2061 let mut awake = usual().clone();
2062 awake.set(Row::Own(attention::Kind::AppAtOnce), C::Sleep, Level::Now).unwrap();
2063 let fixtures = [SMS_NUMBER, SMS_CONTACT, SMS_SENDER_ID, SMS_SHORT, WHATSAPP_PERSON, WHATSAPP_GROUP, DISCORD_SERVER, DISCORD_DM, CHROME_SITE, FIREFOX_SITE, PROTON, SHOP];
2064 struct AtNow<'a>(&'a TestClock);
2066 impl TimeSource for AtNow<'_> {
2067 fn mode(&self, at: &Zoned) -> Mode {
2068 self.0.mode(at)
2069 }
2070 fn starts(&self, _: &Zoned, _: &Zoned) -> Vec<Zoned> {
2071 Vec::new()
2072 }
2073 }
2074 let quick = AtNow(&clock);
2075 let mut held = 0;
2076 for (choices, matrix) in [(&Choices::default(), usual()), (&at_once, &awake)] {
2077 for fixture in fixtures {
2078 let p = posted(fixture);
2079 let kind = classify(&p, choices);
2080 for hour in ["03:00", "08:30", "09:20", "10:00", "12:30", "13:00", "17:30", "20:00", "22:30"] {
2081 let now = at(&format!("2026-10-06T{hour}[Europe/Paris]"));
2082 for (dnd, slot) in [(false, false), (true, false), (false, true)] {
2083 let slot_at = move |z: &Zoned| slot && z.hour() == 9;
2084 for area in [None, Some(Area::WORK), Some(Area::LEISURE)] {
2085 for who in [None, Some(Who::Safe), Some(Who::Neutral), Some(Who::Restricted), Some(Who::Stranger), Some(Who::Blocked)] {
2086 let ask = Ask { now: &now, clock: &quick, attention: matrix, gathered: &gathered, area, gate: dnd.then_some(Gate { until: None }), slot_at: &slot_at, nothing: false, also: &[], sources: &[], reachable: None, silenced: false };
2087 if decide(&kind, who, false, choices, &ask).until.is_none() {
2088 continue;
2089 }
2090 held += 1;
2091 let c = cause(&p.package, &kind, who, false, choices, &ask).expect("a held one has a cause");
2092 assert!(c.for_good || c.gathered.is_some() || c.own.is_some() || c.area.is_some() || !c.columns.is_empty(), "{fixture} {hour} {who:?} {area:?} dnd {dnd} slot {slot}: {c:?}");
2093 }
2094 }
2095 }
2096 }
2097 }
2098 }
2099 assert!(held > 1_000, "{held} holds tried");
2100 }
2101
2102 #[test]
2103 fn the_matrix_changes_when() {
2104 let clock = TestClock::new();
2105 let choices = Choices::default();
2106 let sms = classify(&posted(SMS_CONTACT), &choices);
2107 let shop = classify(&posted(SHOP), &choices);
2108 let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
2109 let decided = |kind: &Kind, who: Option<Who>, listed: bool, matrix: &Attention, now: &Zoned, clock: &TestClock, gate: Option<Gate>| {
2110 decide(kind, who, listed, &choices, &Ask { reachable: None, silenced: false, now, clock, attention: matrix, gathered: &gathered, area: None, gate, slot_at: &never_slot, nothing: false, also: &[], sources: &[] })
2111 };
2112 let mut matrix = usual().clone();
2113 let evening = at("2026-10-06T20:30[Europe/Paris]");
2115 let gate = || Some(Gate { until: Some(at("2026-10-06T21:00[Europe/Paris]").timestamp().as_second()) });
2116 assert_eq!(decided(&sms, Some(Who::Stranger), true, &matrix, &evening, &clock, gate()).why, Why::Allowed);
2117 matrix.set(Row::People(Channel::Messages, Person::Always), Column::Leisure, Level::As).unwrap();
2118 matrix.set(Row::People(Channel::Messages, Person::Always), Column::Dnd, Level::Through).unwrap();
2119 assert_eq!(decided(&sms, Some(Who::Stranger), true, &matrix, &evening, &clock, gate()).why, Why::Waiting, "a stranger's row in the evening");
2120 assert_eq!(decided(&sms, Some(Who::Neutral), true, &matrix, &evening, &clock, gate()).why, Why::Allowed, "the neutral come in leisure");
2121 assert_eq!(decided(&sms, Some(Who::Safe), false, &matrix, &evening, &clock, gate()).why, Why::Waiting, "not on the list: do-not-disturb holds them");
2122 let free = TestClock { overrides: Overrides { free_since: Some(at("2026-10-06T15:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
2125 let afternoon = at("2026-10-06T16:00[Europe/Paris]");
2126 assert_eq!(decided(&sms, Some(Who::Safe), false, &matrix, &afternoon, &free, None).why, Why::Allowed, "as usual");
2127 matrix.set(Row::People(Channel::Messages, Person::Safe), Column::Free, Level::Later).unwrap();
2128 let held = decided(&sms, Some(Who::Safe), false, &matrix, &afternoon, &free, None);
2129 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
2130 let morning = at("2026-10-06T08:00[Europe/Paris]");
2132 assert_eq!(until_of(decided(&shop, None, false, &matrix, &morning, &clock, None)), "2026-10-06T09:00:00", "as usual");
2133 matrix.set(Row::Own(crate::attention::Kind::AppAutomatons), Column::Work, Level::Later).unwrap();
2134 assert_eq!(until_of(decided(&shop, None, false, &matrix, &morning, &clock, None)), "2026-10-06T18:00:00");
2135 let in_slot = |z: &Zoned| z.hour() == 9;
2137 let slotted = Ask { reachable: None, silenced: false, now: &morning, clock: &clock, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &in_slot, nothing: false, also: &[], sources: &[] };
2138 assert_eq!(until_of(decide(&shop, None, false, &choices, &slotted)), "2026-10-06T13:00:00");
2139 let nothing = Ask { reachable: None, silenced: false, now: &afternoon, clock: &free, attention: usual(), gathered: &gathered, area: None, gate: None, slot_at: &never_slot, nothing: true, also: &[], sources: &[] };
2141 assert_eq!(decide(&sms, Some(Who::Safe), false, &choices, ¬hing).why, Why::Waiting);
2142 assert_eq!(decide(&sms, Some(Who::Safe), true, &choices, ¬hing).why, Why::Allowed);
2143 let proton = classify(&posted(PROTON), &choices);
2145 let night = at("2026-10-06T23:30[Europe/Paris]");
2146 let held = decide(&proton, Some(Who::Safe), false, &choices, &ask(&night, &clock, None, None));
2147 assert_eq!((held.why, until_of(held).as_str()), (Why::Waiting, "2026-10-07T07:00:00"));
2148 let missed = classify(&posted(r#"{"key":"m","package":"com.android.dialer","category":"missed_call","title":"+262 6 39 98 01 07"}"#), &choices);
2150 assert_eq!(decide(&missed, Some(Who::Stranger), false, &choices, &ask(&at("2026-10-06T10:00[Europe/Paris]"), &clock, None, None)).why, Why::Allowed);
2151 assert_eq!(decide(&missed, Some(Who::Stranger), false, &choices, &ask(&night, &clock, None, None)).why, Why::Waiting);
2152 }
2153
2154 #[test]
2155 fn what_was_held_and_came_back() {
2156 let clock = TestClock::new();
2157 let choices = Choices::default();
2158 let now = at("2026-10-06T10:00[Europe/Paris]");
2159 let stamp = now.timestamp().as_second();
2160 let mut ledger = Ledger::default();
2161 for json in [SHOP, DISCORD_SERVER, CHROME_SITE, WHATSAPP_GROUP] {
2162 let p = posted(json);
2163 let kind = classify(&p, &choices);
2164 let decision = decide(&kind, Some(Who::Stranger), false, &choices, &ask(&now, &clock, None, None));
2165 ledger.note(&p, &kind, Some(Who::Stranger), false, None, &decision, stamp);
2166 }
2167 assert_eq!(ledger.apps["com.shop.example"].rang.keys().collect::<Vec<_>>(), ["offers"]);
2169 assert_eq!(ledger.conversations.len(), 2, "Discord's channel and the school's group");
2170 assert!(ledger.sites.contains_key("forum.example.net"));
2171 assert_eq!(ledger.held.len(), 2, "the shop and the site wait for 13:00; strangers write in work time");
2172 let tr = Translator::new("en");
2173 assert_eq!(porch_line(&tr, &ledger, &now), "Notifications held until 13:00: Chrome and Shop.");
2174 let fr = Translator::new("fr");
2175 assert_eq!(porch_line(&fr, &ledger, &now), "Notifications retenues jusqu’à 13:00\u{202f}: Chrome et Shop.");
2176 let one = at("2026-10-06T13:00[Europe/Paris]");
2178 let hash = key_hash(&posted(SHOP).key);
2179 assert!(ledger.returning(&hash, one.timestamp().as_second()) && !ledger.returning(&hash, stamp));
2180 ledger.came_back(&hash, one.timestamp().as_second());
2181 ledger.came_back(&key_hash(&posted(CHROME_SITE).key), one.timestamp().as_second() + 5);
2182 assert!(ledger.held.is_empty());
2183 assert_eq!(porch_line(&tr, &ledger, &one), "Back in your notifications at 13:00: Shop and Chrome.");
2184 assert_eq!(porch_line(&tr, &ledger, &at("2026-10-06T15:30[Europe/Paris]")), "");
2185 let text = toml::to_string(&ledger).unwrap();
2187 for said in ["chaussures", "Sortie", "Nouvelle version", "répondu", "+33", "Mme Durand", "ferris", "120363"] {
2188 assert!(!text.contains(said), "{said} kept: {text}");
2189 }
2190 assert!(text.contains("École Jules Ferry") && text.contains("Rust FR #général"), "{text}");
2191 let mut chosen = Choices::default();
2193 let school = talk_key("com.whatsapp", "120363000000000001@g.us");
2194 chosen.conversation.insert(school.clone(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
2195 ledger.forget_old(stamp + 8 * 86_400, &chosen);
2196 assert!(ledger.back.is_empty());
2197 assert_eq!(ledger.conversations.keys().collect::<Vec<_>>(), [&school]);
2198 }
2199
2200 #[test]
2201 fn held_ones_worked_out_again() {
2202 let choices = Choices::default();
2203 let paused = TestClock { overrides: Overrides { paused_since: Some(at("2026-10-06T08:00[Europe/Paris]").timestamp().as_second()), ..Overrides::default() } };
2204 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() } };
2205 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() };
2206 let now = at("2026-10-06T10:00:30[Europe/Paris]");
2207 assert_eq!(again(&held, &choices, &ask(&now, &paused, None, None)).why, Why::Gathered);
2209 assert_eq!(until_of(again(&held, &choices, &ask(&now, &back, None, None))), "2026-10-06T13:00:00");
2211 let talk = Held { kind: "people".into(), via: "messages".into(), who: "stranger".into(), conversation: "c1".into(), ..held.clone() };
2213 let mut chosen = Choices::default();
2214 chosen.conversation.insert("c1".into(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
2215 let evening = at("2026-10-06T20:30[Europe/Paris]");
2216 assert_eq!(again(&talk, &chosen, &ask(&evening, &TestClock::new(), None, None)), Decision::through(Why::Always));
2217 assert_eq!(until_of(again(&talk, &choices, &ask(&evening, &TestClock::new(), None, None))), "2026-10-07T09:00:00");
2218 let clock = TestClock::new();
2220 let night = at("2026-10-06T23:30[Europe/Paris]");
2221 let mut ledger = Ledger::default();
2222 let p = posted(WHATSAPP_GROUP);
2223 let kind = classify(&p, &choices);
2224 let decision = decide(&kind, Some(Who::Safe), true, &choices, &ask(&night, &clock, None, None));
2225 ledger.note(&p, &kind, Some(Who::Safe), true, None, &decision, night.timestamp().as_second());
2226 let group = ledger.held.values().next().unwrap().clone();
2227 assert_eq!((group.who.as_str(), group.admitted, group.group), ("safe", false, true));
2228 assert_eq!(until_of(again(&group, &choices, &ask(&night, &clock, None, None))), "2026-10-07T07:00:00");
2229 let mut gathered_then_always = Choices::default();
2231 gathered_then_always.conversation.insert(group.conversation.clone(), TalkChoice { through: Through::Always, ..TalkChoice::default() });
2232 let gathered_held = Held { kind: "gathered".into(), ..group.clone() };
2233 assert_eq!(again(&gathered_held, &gathered_then_always, &ask(&evening, &clock, None, None)), Decision::through(Why::Always));
2234 let off = Choices { hold: false, ..Choices::default() };
2236 assert_eq!(again(&held, &off, &ask(&now, &paused, None, None)), Decision::through(Why::Off));
2237 }
2238
2239 #[test]
2249 fn reachable_lets_people_through_and_leaves_automatons_gathered() {
2250 let clock = TestClock::new();
2251 let choices = Choices::default();
2252 let sms = classify(&posted(SMS_CONTACT), &choices);
2253 let shop = classify(&posted(SHOP), &choices);
2254 for hour in ["03:00", "10:00", "12:30", "20:00", "23:30"] {
2255 let now = at(&format!("2026-10-06T{hour}[Europe/Paris]"));
2256 let until = now.timestamp().as_second() + 3600;
2257 for gated in [false, true] {
2258 let gate = || gated.then_some(Gate { until: None });
2259 let usual = ask(&now, &clock, None, gate());
2260 let reach = Ask { reachable: Some(until), ..ask(&now, &clock, None, gate()) };
2261 for who in [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger] {
2262 let d = decide(&sms, Some(who), false, &choices, &reach);
2263 assert_eq!(d.until, None, "{who:?} at {hour}, gated {gated}: {d:?}");
2264 }
2265 assert_eq!(decide(&sms, Some(Who::Blocked), false, &choices, &reach).why, Why::Never, "{hour}");
2266 assert_eq!(until_of(decide(&shop, None, false, &choices, &reach)), until_of(decide(&shop, None, false, &choices, &usual)), "an automaton keeps its gathered time at {hour}");
2267 }
2268 }
2269 let night = at("2026-10-06T23:30[Europe/Paris]");
2271 let over = Ask { reachable: Some(night.timestamp().as_second() - 60), ..ask(&night, &clock, None, None) };
2272 assert!(decide(&sms, Some(Who::Neutral), false, &choices, &over).until.is_some(), "at night, after Reachable, a contact's text waits");
2273 }
2274
2275 #[test]
2281 fn a_text_in_the_shade_comes_when_the_phone_silences_it() {
2282 let clock = TestClock::new();
2283 let choices = Choices::default();
2284 let sms = classify(&posted(SMS_CONTACT), &choices);
2285 let morning = at("2026-10-06T10:00[Europe/Paris]");
2286 let end = morning.timestamp().as_second() + 1800;
2287 let gate = || Some(Gate { until: Some(end) });
2288 let loud = ask(&morning, &clock, None, gate());
2289 let silenced = Ask { silenced: true, ..ask(&morning, &clock, None, gate()) };
2290 assert_eq!(decide(&sms, Some(Who::Neutral), false, &choices, &loud).until, Some(end), "not silenced: it waits for the session's end");
2291 assert_eq!(decide(&sms, Some(Who::Neutral), false, &choices, &silenced), Decision::through(Why::Allowed));
2292 assert_eq!(decide(&sms, Some(Who::Safe), false, &choices, &silenced), Decision::through(Why::Allowed));
2293 assert!(decide(&sms, Some(Who::Restricted), false, &choices, &silenced).until.is_some(), "restricted: held, as before");
2294 let held = Held { app: "com.google.android.apps.messaging".into(), label: "Messages".into(), kind: "people".into(), via: "messages".into(), who: "neutral".into(), admitted: false, group: false, conversation: String::new(), site: String::new(), area: None, since: end - 3600, until: end, first: 0, again: 0, persons: Vec::new() };
2295 assert_eq!(again(&held, &choices, &silenced).until, None, "a review, the phone silenced now: it comes");
2296 assert_eq!(again(&held, &choices, &loud).until, Some(end));
2297 }
2298
2299 #[test]
2303 fn a_release_passed_over_as_ahead_is_passed_over_for_good() {
2304 let now = 1_790_000_000;
2305 let stamps = vec![("odd".to_string(), (now + 2 * 86_400) * 1000)];
2306 let mut ledger = Ledger::default();
2307 assert_eq!(ledger.apply_releases(&stamps, now), (false, vec!["odd".to_string()]));
2308 let later = now + 86_400 + 60;
2309 assert_eq!(ledger.apply_releases(&stamps, later), (false, vec![]), "never applied later");
2310 assert_eq!(ledger.released_through, 0);
2311 assert!(ledger.apply_releases(&[("odd".to_string(), later * 1000)], later).0);
2313 }
2314
2315 #[test]
2319 fn under_reachable_a_conversation_gathered_comes_at_once() {
2320 let clock = TestClock::new();
2321 let mut choices = Choices::default();
2322 let talk = classify(&posted(WHATSAPP_GROUP), &choices);
2323 let key = match &talk {
2324 Kind::People(t) => t.conversation.as_ref().unwrap().key.clone(),
2325 _ => unreachable!(),
2326 };
2327 choices.conversation.insert(key.clone(), TalkChoice { through: Through::Gathered, ..TalkChoice::default() });
2328 let evening = at("2026-10-06T20:00[Europe/Paris]");
2329 let until = evening.timestamp().as_second() + 3600;
2330 let reach = Ask { reachable: Some(until), ..ask(&evening, &clock, None, None) };
2331 assert_eq!(decide(&talk, Some(Who::Neutral), false, &choices, &reach), Decision::through(Why::Allowed));
2332 let held = Held { app: "com.whatsapp".into(), label: "WhatsApp".into(), kind: "people".into(), via: "messages".into(), who: "neutral".into(), admitted: false, group: true, conversation: key.clone(), site: String::new(), area: None, since: until - 7200, until: until + 7200, first: 0, again: 0, persons: Vec::new() };
2333 assert_eq!(again(&held, &choices, &reach).until, None, "looked at again under Reachable: at once");
2334 let usual = ask(&evening, &clock, None, None);
2335 assert!(decide(&talk, Some(Who::Neutral), false, &choices, &usual).until.is_some(), "as usual: its gathered time");
2336 choices.conversation.insert(key, TalkChoice { through: Through::Never, ..TalkChoice::default() });
2337 assert_eq!(decide(&talk, Some(Who::Neutral), false, &choices, &reach).why, Why::Never, "set to never: held");
2338 }
2339
2340 #[test]
2344 fn a_release_lets_through_what_was_held_before_it_once() {
2345 let held = |since: i64| Held { app: "com.example".into(), label: "Example".into(), kind: "people".into(), via: String::new(), who: String::new(), admitted: false, group: false, conversation: String::new(), site: String::new(), area: None, since, until: since + 3600, first: 0, again: 0, persons: Vec::new() };
2346 let mut ledger = Ledger::default();
2347 assert!(!ledger.released_before(&held(1_000)), "no release yet");
2348 let one = |stamp: i64| vec![("desk".to_string(), stamp)];
2349 assert!(ledger.apply_releases(&one(2_000_000), 5_000).0);
2350 assert!(ledger.released_before(&held(1_000)) && ledger.released_before(&held(2_000)));
2351 assert!(!ledger.released_before(&held(2_001)), "held after the press: not released");
2352 assert!(!ledger.apply_releases(&one(2_000_000), 6_000).0, "the same stamp, applied once");
2353 assert!(!ledger.apply_releases(&one(1_000_000), 6_000).0, "an older stamp: passed over");
2354 assert!(ledger.apply_releases(&one(9_000_000), 3_000 + 4_000).0);
2356 assert_eq!(ledger.released_through, 7_000);
2357 assert!(!ledger.released_before(&held(7_001)));
2358 let back: Ledger = toml::from_str(&toml::to_string(&ledger).unwrap()).unwrap();
2360 assert_eq!((back.released_by.get("desk").copied(), back.applied.get("desk").copied(), back.released_through), (Some(7_000_000), Some(9_000_000), 7_000), "its mark the phone's now, the stamp kept beside (C-1)");
2361 assert!(!back.clone().apply_releases(&one(9_000_000), 8_000).0, "the same stamp ahead, once");
2362 let older: Ledger = toml::from_str("released = 9000000\nreleased_through = 7000\n").unwrap();
2364 let mut older = older;
2365 assert!(!older.apply_releases(&[("phone".to_string(), 8_000_000)], 7_500).0);
2366 assert!(older.apply_releases(&[("phone".to_string(), 9_500_000)], 9_600).0);
2367 let before = held(6_000);
2370 assert_eq!(Decision::held(9_000, Why::Gathered).released(&before, &ledger), Decision::through(Why::Released));
2371 assert_eq!(Decision::held(9_000, Why::Waiting).released(&before, &ledger), Decision::through(Why::Released));
2372 assert_eq!(Decision::held(i64::MAX, Why::Never).released(&before, &ledger), Decision::held(i64::MAX, Why::Never), "the blocked, a conversation set to never");
2373 assert_eq!(Decision::held(9_000, Why::Gathered).released(&held(7_500), &ledger), Decision::held(9_000, Why::Gathered), "held after the release: its gathered time");
2374 assert_eq!(Decision::through(Why::Allowed).released(&before, &ledger), Decision::through(Why::Allowed));
2375 assert_eq!(Decision::held(9_000, Why::Gathered).released(&before, &Ledger::default()), Decision::held(9_000, Why::Gathered), "no release");
2376 }
2377
2378 #[test]
2379 fn an_app_s_own_row_decides_when() {
2380 fn with<'a>(now: &'a Zoned, clock: &'a TestClock, attention: &'a Attention, gathered: &'a [String], sources: &'a [String], gate: Option<Gate>) -> Ask<'a> {
2381 Ask { reachable: None, silenced: false, now, clock, attention, gathered, area: None, gate, slot_at: &never_slot, nothing: false, also: &[], sources }
2382 }
2383 let clock = TestClock::new();
2384 let choices = Choices::default();
2385 let gathered = vec!["09:00".to_string(), "13:00".into(), "18:00".into()];
2386 let (morning, evening) = (at("2026-10-06T10:00[Europe/Paris]"), at("2026-10-06T20:00[Europe/Paris]"));
2387 let dm = classify(&posted(DISCORD_DM), &choices);
2388 let server = classify(&posted(DISCORD_SERVER), &choices);
2389 let key = |k: &Kind| match k {
2390 Kind::People(t) => t.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
2391 _ => String::new(),
2392 };
2393 let dm_rows = attention::source_rows("com.discord", &key(&dm));
2394 let server_rows = attention::source_rows("com.discord", &key(&server));
2395 assert_eq!(server_rows, vec![format!("conversation.{}", key(&server)), "app.com.discord".to_string()]);
2396 for fixture in [SMS_NUMBER, SMS_SENDER_ID, WHATSAPP_PERSON, WHATSAPP_GROUP, DISCORD_SERVER, DISCORD_DM, CHROME_SITE, PROTON, SHOP, BANK_CODE] {
2398 let p = posted(fixture);
2399 let kind = classify(&p, &choices);
2400 let rows = attention::source_rows(&p.package, &key(&kind));
2401 for hour in ["03:00", "08:30", "10:00", "12:30", "17:30", "20:00", "22:30"] {
2402 let now = at(&format!("2026-10-06T{hour}[Europe/Paris]"));
2403 for who in [None, Some(Who::Safe), Some(Who::Stranger)] {
2404 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:?}");
2405 }
2406 }
2407 }
2408 let mut work_only = usual().clone();
2410 for column in Column::ALL {
2411 work_only.set_source("app.com.discord", column, if column == Column::Work { Level::Now } else { Level::Later }).unwrap();
2412 }
2413 assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)), Decision::through(Why::Times));
2414 let held = decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None));
2415 assert_eq!((held.why, until_of(held).as_str()), (Why::Times, "2026-10-07T09:00:00"));
2416 assert_eq!(decide(&dm, Some(Who::Safe), false, &choices, &with(&evening, &clock, usual(), &gathered, &dm_rows, None)), Decision::through(Why::Allowed));
2418 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");
2419 assert_eq!(decide(&dm, Some(Who::Blocked), false, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)).why, Why::Never);
2421 let code = classify(&posted(BANK_CODE), &choices);
2422 let mut code_held = usual().clone();
2423 code_held.set_source("app.com.bank.example", Column::Work, Level::Later).unwrap();
2424 let code_rows = attention::source_rows("com.bank.example", "");
2425 assert_eq!(decide(&code, None, false, &choices, &with(&morning, &clock, &code_held, &gathered, &code_rows, None)), Decision::through(Why::Code));
2426 let mut server_evening = work_only.clone();
2428 server_evening.set_source(&server_rows[0], Column::Leisure, Level::Now).unwrap();
2429 assert_eq!(decide(&server, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &server_rows, None)), Decision::through(Why::Times));
2430 assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &dm_rows, None)).why, Why::Times);
2431 assert!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&evening, &clock, &server_evening, &gathered, &dm_rows, None)).until.is_some());
2432 let mut by_day = usual().clone();
2434 for column in [Column::Work, Column::Admin, Column::Meals] {
2435 by_day.set_source("app.com.discord", column, Level::Gathered).unwrap();
2436 }
2437 let held = decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &by_day, &gathered, &dm_rows, None));
2438 assert_eq!((held.why, until_of(held).as_str()), (Why::Times, "2026-10-06T13:00:00"));
2439 let gate = || Some(Gate { until: Some(at("2026-10-06T11:00[Europe/Paris]").timestamp().as_second()) });
2441 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");
2442 let mut through_dnd = work_only.clone();
2443 through_dnd.set_source("app.com.discord", Column::Dnd, Level::As).unwrap();
2444 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");
2445 through_dnd.set_source("app.com.discord", Column::Dnd, Level::Now).unwrap();
2446 assert_eq!(decide(&dm, Some(Who::Stranger), false, &choices, &with(&morning, &clock, &through_dnd, &gathered, &dm_rows, gate())), Decision::through(Why::Times));
2447 let shop = classify(&posted(SHOP), &choices);
2449 let shop_rows = attention::source_rows("com.shop.example", "");
2450 let mut shop_evening = usual().clone();
2451 shop_evening.set_source("app.com.shop.example", Column::Leisure, Level::Now).unwrap();
2452 assert_eq!(decide(&shop, None, false, &choices, &with(&evening, &clock, &shop_evening, &gathered, &shop_rows, None)), Decision::through(Why::Times));
2453 assert_eq!(until_of(decide(&shop, None, false, &choices, &with(&evening, &clock, usual(), &gathered, &shop_rows, None))), "2026-10-07T09:00:00");
2454 let reachable = crate::attention::Preset::Reachable.matrix();
2456 assert_eq!(decide(&shop, None, false, &choices, &with(&morning, &clock, &reachable, &gathered, &[], None)), Decision::through(Why::Gathered));
2457 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() };
2459 assert_eq!(again(&kept, &choices, &with(&morning, &clock, &work_only, &gathered, &dm_rows, None)), Decision::through(Why::Times));
2460 assert_eq!(until_of(again(&kept, &choices, &with(&evening, &clock, &work_only, &gathered, &dm_rows, None))), "2026-10-07T09:00:00");
2461 let mut dm_evening = work_only.clone();
2462 dm_evening.set_source(&dm_rows[0], Column::Leisure, Level::Now).unwrap();
2463 assert_eq!(again(&kept, &choices, &with(&evening, &clock, &dm_evening, &gathered, &dm_rows, None)), Decision::through(Why::Times));
2464 }
2465
2466 #[test]
2467 fn choices_and_ledger_read_and_written() {
2468 let dir = std::env::temp_dir().join(format!("sioul-appnotes-{}", std::process::id()));
2469 let _ = std::fs::remove_dir_all(&dir);
2470 let mut choices = Choices::default();
2471 assert!(choices.hold, "holding is on until switched off");
2472 choices.app.insert("com.discord".into(), AppChoice { kind: AppKind::Automaton, area: Some(Area::LEISURE), label: "Discord".into() });
2473 choices.conversation.insert("c1".into(), TalkChoice { through: Through::Always, title: "École".into(), app: "WhatsApp".into() });
2474 let path = dir.join("app-notes.toml");
2475 choices.save(&path).unwrap();
2476 assert_eq!(Choices::load(&path), choices);
2477 let text = std::fs::read_to_string(&path).unwrap();
2478 assert!(text.contains("kind = \"automaton\"") && text.contains("area = \"leisure\"") && text.contains("through = \"always\""), "{text}");
2479 assert_eq!(Choices::load(&dir.join("none.toml")), Choices::default());
2480 std::fs::write(&path, "hold = false\n").unwrap();
2481 assert!(!Choices::load(&path).hold);
2482 std::fs::write(&path, "hold = [broken").unwrap();
2483 assert_eq!(Choices::load(&path), Choices::default(), "a file that does not read is no choice at all");
2484 let _ = std::fs::remove_dir_all(&dir);
2485 assert_eq!(AppKind::read("at-once"), Some(AppKind::AtOnce));
2486 assert_eq!(Through::read("never"), Some(Through::Never));
2487 }
2488
2489 #[test]
2490 fn words_in_both_languages() {
2491 let now = at("2026-10-06T10:00[Europe/Paris]");
2492 let names = |n: usize| (1..=n).map(|i| format!("App{i}")).collect::<Vec<_>>();
2493 for (language, n, said) in [
2494 ("en", 1, "App1"),
2495 ("en", 2, "App1 and App2"),
2496 ("en", 3, "App1, App2 and App3"),
2497 ("en", 4, "App1, App2, App3 and one other app"),
2498 ("en", 6, "App1, App2, App3 and three other apps"),
2499 ("en", 15, "App1, App2, App3 and twelve other apps"),
2500 ("en", 16, "App1, App2, App3 and many other apps"),
2501 ("fr", 2, "App1 et App2"),
2502 ("fr", 4, "App1, App2, App3 et une autre application"),
2503 ("fr", 5, "App1, App2, App3 et deux autres applications"),
2504 ("fr", 20, "App1, App2, App3 et beaucoup d’autres applications"),
2505 ] {
2506 assert_eq!(apps_in_words(&Translator::new(language), &names(n)), said, "{language} {n}");
2507 }
2508 let tr = Translator::new("en");
2509 let at13 = at("2026-10-06T13:00[Europe/Paris]").timestamp().as_second();
2510 assert_eq!(why_text(&tr, "gathered", at13, &now), "From an automaton: held until 13:00, a gathered time.");
2511 assert_eq!(why_text(&tr, "allowed", 0, &now), "From someone who may reach you now: let through.");
2512 let fr = Translator::new("fr");
2513 assert_eq!(why_text(&fr, "gathered", at13, &now), "D’un automate\u{202f}: retenue jusqu’à 13:00, une heure de regroupement.");
2514 assert_eq!(why_text(&tr, "times", at13, &now), "As you chose for this time: held until 13:00.");
2515 assert_eq!(why_text(&tr, "machine", at13, &now), "From a number kept for automated systems: held until 13:00, a gathered time.");
2517 assert_eq!(why_text(&tr, "released", 0, &now), "Held before you asked your phone to let what it held through: let through.");
2518 for why in ["untouched", "code", "off", "at-once", "allowed", "always", "waiting", "gathered", "machine", "never", "again", "times", "released"] {
2519 for tr in [&tr, &fr] {
2520 let said = why_text(tr, why, 0, &now);
2521 assert!(!said.starts_with("appnotes-"), "{why}: {said}");
2522 let held = why_text(tr, why, at13, &now);
2523 assert!(!held.starts_with("appnotes-"), "{why}: {held}");
2524 }
2525 }
2526 }
2527}