1use crate::areas::{Area, Time, Week, in_view};
47use crate::config::{Config, SettingValue};
48use crate::i18n::Translator;
49use crate::quiet::Mode;
50use crate::reach::{Channel, Who};
51use serde::{Deserialize, Serialize};
52use std::collections::BTreeMap;
53use std::path::{Path, PathBuf};
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum Person {
61 Always,
64 Safe,
65 Neutral,
66 Restricted,
67 Stranger,
69 Hidden,
71 Groups,
73 Blocked,
75}
76
77impl Person {
78 pub const ALL: [Person; 8] = [Person::Always, Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger, Person::Hidden, Person::Groups, Person::Blocked];
79
80 pub fn id(self) -> &'static str {
81 match self {
82 Person::Always => "always",
83 Person::Safe => "safe",
84 Person::Neutral => "neutral",
85 Person::Restricted => "restricted",
86 Person::Stranger => "stranger",
87 Person::Hidden => "hidden",
88 Person::Groups => "groups",
89 Person::Blocked => "blocked",
90 }
91 }
92
93 pub fn read(text: &str) -> Option<Person> {
94 let text = text.trim().to_ascii_lowercase();
95 match text.as_str() {
96 "strangers" => Some(Person::Stranger),
97 "group" => Some(Person::Groups),
98 _ => Person::ALL.into_iter().find(|p| p.id() == text),
99 }
100 }
101
102 pub fn of(who: Who) -> Person {
104 match who {
105 Who::Safe => Person::Safe,
106 Who::Neutral => Person::Neutral,
107 Who::Restricted => Person::Restricted,
108 Who::Stranger => Person::Stranger,
109 Who::Blocked => Person::Blocked,
110 }
111 }
112
113 pub fn state(self) -> bool {
115 !matches!(self, Person::Always | Person::Blocked)
116 }
117}
118
119pub fn persons(channel: Channel) -> &'static [Person] {
121 use Person::*;
122 match channel {
123 Channel::Mail => &[Always, Safe, Neutral, Restricted, Stranger, Blocked],
124 Channel::Calls => &[Always, Safe, Neutral, Restricted, Stranger, Hidden, Blocked],
125 Channel::Messages => &[Always, Safe, Neutral, Restricted, Stranger, Groups, Blocked],
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
131#[serde(rename_all = "kebab-case")]
132pub enum Kind {
133 Codes,
135 Doses,
137 Wake,
139 Alarms,
141 Before,
143 DayBefore,
145 Dates,
147 Needs,
149 Move,
151 WorkOver,
153 Time,
155 Sites,
157 SitesLive,
159 SiteCalls,
161 AppAutomatons,
163 AppAtOnce,
165}
166
167impl Kind {
168 pub const ALL: [Kind; 16] = [
169 Kind::Codes,
170 Kind::Doses,
171 Kind::Wake,
172 Kind::Alarms,
173 Kind::Before,
174 Kind::DayBefore,
175 Kind::Dates,
176 Kind::Needs,
177 Kind::Move,
178 Kind::WorkOver,
179 Kind::Time,
180 Kind::Sites,
181 Kind::SitesLive,
182 Kind::SiteCalls,
183 Kind::AppAutomatons,
184 Kind::AppAtOnce,
185 ];
186
187 pub fn id(self) -> &'static str {
188 match self {
189 Kind::Codes => "codes",
190 Kind::Doses => "doses",
191 Kind::Wake => "wake",
192 Kind::Alarms => "alarms",
193 Kind::Before => "before",
194 Kind::DayBefore => "day-before",
195 Kind::Dates => "dates",
196 Kind::Needs => "needs",
197 Kind::Move => "move",
198 Kind::WorkOver => "work-over",
199 Kind::Time => "time",
200 Kind::Sites => "sites",
201 Kind::SitesLive => "sites-live",
202 Kind::SiteCalls => "site-calls",
203 Kind::AppAutomatons => "app-automatons",
204 Kind::AppAtOnce => "app-at-once",
205 }
206 }
207
208 pub fn read(text: &str) -> Option<Kind> {
209 let text = text.trim().to_ascii_lowercase().replace('_', "-");
210 Kind::ALL.into_iter().find(|k| k.id() == text)
211 }
212
213 pub fn group(self) -> &'static str {
216 match self {
217 Kind::Codes | Kind::Doses | Kind::Wake | Kind::Alarms => "asked",
218 Kind::Before | Kind::DayBefore | Kind::Dates => "reminders",
219 Kind::Needs | Kind::Move | Kind::WorkOver | Kind::Time => "day",
220 Kind::Sites | Kind::SitesLive | Kind::SiteCalls => "sites",
221 Kind::AppAutomatons | Kind::AppAtOnce => "apps",
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
228pub enum Row {
229 People(Channel, Person),
230 Own(Kind),
231}
232
233impl Row {
234 pub const ALL: [Row; 36] = [
236 Row::People(Channel::Mail, Person::Always),
237 Row::People(Channel::Mail, Person::Safe),
238 Row::People(Channel::Mail, Person::Neutral),
239 Row::People(Channel::Mail, Person::Restricted),
240 Row::People(Channel::Mail, Person::Stranger),
241 Row::People(Channel::Mail, Person::Blocked),
242 Row::People(Channel::Calls, Person::Always),
243 Row::People(Channel::Calls, Person::Safe),
244 Row::People(Channel::Calls, Person::Neutral),
245 Row::People(Channel::Calls, Person::Restricted),
246 Row::People(Channel::Calls, Person::Stranger),
247 Row::People(Channel::Calls, Person::Hidden),
248 Row::People(Channel::Calls, Person::Blocked),
249 Row::People(Channel::Messages, Person::Always),
250 Row::People(Channel::Messages, Person::Safe),
251 Row::People(Channel::Messages, Person::Neutral),
252 Row::People(Channel::Messages, Person::Restricted),
253 Row::People(Channel::Messages, Person::Stranger),
254 Row::People(Channel::Messages, Person::Groups),
255 Row::People(Channel::Messages, Person::Blocked),
256 Row::Own(Kind::Codes),
257 Row::Own(Kind::Doses),
258 Row::Own(Kind::Wake),
259 Row::Own(Kind::Alarms),
260 Row::Own(Kind::Before),
261 Row::Own(Kind::DayBefore),
262 Row::Own(Kind::Dates),
263 Row::Own(Kind::Needs),
264 Row::Own(Kind::Move),
265 Row::Own(Kind::WorkOver),
266 Row::Own(Kind::Time),
267 Row::Own(Kind::Sites),
268 Row::Own(Kind::SitesLive),
269 Row::Own(Kind::SiteCalls),
270 Row::Own(Kind::AppAutomatons),
271 Row::Own(Kind::AppAtOnce),
272 ];
273
274 pub fn id(self) -> String {
276 match self {
277 Row::People(channel, person) => format!("{}.{}", channel.id(), person.id()),
278 Row::Own(kind) => kind.id().to_string(),
279 }
280 }
281
282 pub fn read(text: &str) -> Option<Row> {
285 let text = text.trim();
286 if let Some((channel, person)) = text.split_once('.') {
287 let (channel, person) = (Channel::read(channel)?, Person::read(person)?);
288 return persons(channel).contains(&person).then_some(Row::People(channel, person));
289 }
290 Kind::read(text).map(Row::Own)
291 }
292
293 fn index(self) -> usize {
294 Row::ALL.iter().position(|r| *r == self).unwrap_or(0)
295 }
296
297 pub fn group(self) -> &'static str {
299 match self {
300 Row::People(channel, _) => channel.id(),
301 Row::Own(kind) => kind.group(),
302 }
303 }
304
305 pub fn always(self) -> bool {
307 matches!(self, Row::People(_, Person::Always))
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
313#[serde(rename_all = "lowercase")]
314pub enum Column {
315 Work,
316 Admin,
317 Leisure,
318 Meals,
319 Sleep,
321 Pause,
323 Free,
325 Slot,
327 Dnd,
329}
330
331impl Column {
332 pub const ALL: [Column; 9] = [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free, Column::Slot, Column::Dnd];
333 pub const TIMES: [Column; 7] = [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free];
335
336 pub fn id(self) -> &'static str {
337 match self {
338 Column::Work => "work",
339 Column::Admin => "admin",
340 Column::Leisure => "leisure",
341 Column::Meals => "meals",
342 Column::Sleep => "sleep",
343 Column::Pause => "pause",
344 Column::Free => "free",
345 Column::Slot => "slot",
346 Column::Dnd => "dnd",
347 }
348 }
349
350 pub fn read(word: &str) -> Option<Column> {
353 Some(match word.trim().to_lowercase().as_str() {
354 "work" | "travail" => Column::Work,
355 "admin" | "démarches" | "demarches" => Column::Admin,
356 "leisure" | "loisirs" | "rest" | "personal" | "perso" => Column::Leisure,
357 "meals" | "meal" | "repas" => Column::Meals,
358 "sleep" | "sommeil" | "night" | "nuit" => Column::Sleep,
359 "pause" | "paused" | "en pause" => Column::Pause,
360 "free" | "free-time" | "free time" | "temps libre" => Column::Free,
361 "slot" | "for-you" | "time-for-you" | "time for you" | "du temps pour vous" => Column::Slot,
362 "dnd" | "do-not-disturb" | "do not disturb" | "ne pas déranger" => Column::Dnd,
363 _ => return None,
364 })
365 }
366
367 pub fn is_time(self) -> bool {
369 !matches!(self, Column::Slot | Column::Dnd)
370 }
371
372 pub fn ends(self) -> bool {
375 !matches!(self, Column::Pause | Column::Slot | Column::Dnd)
376 }
377
378 fn index(self) -> usize {
379 Column::ALL.iter().position(|c| *c == self).unwrap_or(0)
380 }
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
386#[serde(rename_all = "kebab-case")]
387pub enum Level {
388 Now,
390 Quiet,
393 Event,
395 Gathered,
397 Later,
399 Never,
401 As,
403 Through,
405}
406
407impl Level {
408 pub const ALL: [Level; 8] = [Level::Now, Level::Quiet, Level::Event, Level::Gathered, Level::Later, Level::Never, Level::As, Level::Through];
409
410 pub fn id(self) -> &'static str {
411 match self {
412 Level::Now => "now",
413 Level::Quiet => "quiet",
414 Level::Event => "event",
415 Level::Gathered => "gathered",
416 Level::Later => "later",
417 Level::Never => "never",
418 Level::As => "as",
419 Level::Through => "through",
420 }
421 }
422
423 pub fn read(text: &str) -> Option<Level> {
424 let text = text.trim().to_ascii_lowercase();
425 match text.as_str() {
426 "shown" => Some(Level::Quiet),
427 _ => Level::ALL.into_iter().find(|l| l.id() == text),
428 }
429 }
430
431 pub fn mark(self, always: bool) -> &'static str {
433 match self {
434 Level::Now if always => "★",
435 Level::Now => "●",
436 Level::Quiet => "◑",
437 Level::Event => "◐",
438 Level::Gathered => "◎",
439 Level::Later => "○",
440 Level::Never => "–",
441 Level::As => "=",
442 Level::Through => "☆",
443 }
444 }
445}
446
447pub fn usual(row: Row, column: Column) -> Level {
455 use Column::*;
456 use Level::*;
457 let hours = matches!(column, Work | Admin | Leisure | Meals);
458 let rests = matches!(column, Sleep | Pause | Free);
459 match row {
460 Row::People(_, Person::Blocked) => Never,
461 Row::People(Channel::Mail, Person::Always) => if matches!(column, Sleep | Pause) { Quiet } else { Now },
462 Row::People(_, Person::Always) => Now,
463 Row::People(Channel::Mail, Person::Safe) => if hours { Now } else { Quiet },
464 Row::People(Channel::Mail, Person::Neutral | Person::Stranger) => match column {
465 Work | Admin => Now,
466 Slot | Dnd => Quiet,
467 _ => Later,
468 },
469 Row::People(Channel::Mail, Person::Restricted) => match column {
470 Work => Now,
471 Slot | Dnd => Quiet,
472 _ => Later,
473 },
474 Row::People(Channel::Calls | Channel::Messages, Person::Safe) => if matches!(column, Sleep | Pause | Dnd) { Later } else { Now },
475 Row::People(Channel::Calls | Channel::Messages, Person::Neutral) => if matches!(column, Work | Admin | Leisure | Meals | Slot) { Now } else { Later },
479 Row::People(Channel::Calls | Channel::Messages, Person::Hidden | Person::Groups) => if matches!(column, Work | Admin | Slot) { Now } else { Later },
480 Row::People(Channel::Messages, Person::Stranger) => if matches!(column, Work | Admin | Slot) { Now } else { Later },
481 Row::People(Channel::Calls | Channel::Messages, Person::Restricted) => if matches!(column, Work | Slot) { Now } else { Later },
482 Row::People(Channel::Calls, Person::Stranger) => if column == Slot { Now } else { Later },
483 Row::People(_, _) => Later,
484 Row::Own(kind) => match kind {
485 Kind::Codes | Kind::Doses | Kind::Wake | Kind::Time => Now,
486 Kind::Alarms | Kind::Before => if matches!(column, Sleep | Free) { Event } else { Now },
487 Kind::DayBefore | Kind::Dates => if rests { Later } else { Now },
488 Kind::Needs => if rests { Never } else { Now },
489 Kind::Move => if hours { Now } else { Never },
490 Kind::WorkOver => if column == Work || rests { Never } else { Now },
491 Kind::Sites | Kind::AppAutomatons => if hours { Gathered } else { Later },
492 Kind::SitesLive | Kind::AppAtOnce => if hours { Now } else { Later },
493 Kind::SiteCalls => if hours || column == Slot { Now } else { Later },
494 },
495 }
496}
497
498pub fn choices(row: Row, column: Column) -> &'static [Level] {
500 use Level::*;
501 let layer = !column.is_time();
502 match row {
503 Row::People(_, Person::Blocked) => &[Never],
504 Row::People(Channel::Mail, Person::Always) if layer => &[Now, Quiet, Through, As],
505 Row::People(Channel::Mail, Person::Always) => &[Now, Quiet, As],
506 Row::People(_, Person::Always) if layer => &[Now, Through, As],
507 Row::People(_, Person::Always) => &[Now, As],
508 Row::People(Channel::Mail, _) => &[Now, Quiet, Later, Never],
509 Row::People(_, _) => &[Now, Later],
510 Row::Own(kind) => match kind {
511 Kind::Codes => &[Now, Never],
512 Kind::Doses => &[Now, Later],
513 Kind::Wake => &[Now],
514 Kind::Alarms | Kind::Before if column.ends() => &[Now, Event, Later],
515 Kind::Alarms | Kind::Before => &[Now, Later],
516 Kind::DayBefore | Kind::Dates | Kind::SitesLive | Kind::SiteCalls | Kind::AppAtOnce => &[Now, Later],
517 Kind::Needs | Kind::Move | Kind::WorkOver | Kind::Time => &[Now, Never],
518 Kind::Sites => &[Gathered, Later],
519 Kind::AppAutomatons => &[Now, Gathered, Later],
520 },
521 }
522}
523
524pub fn lock(row: Row, column: Column) -> Option<&'static str> {
530 match row {
531 Row::People(_, Person::Blocked) => Some("attention-lock-blocked"),
532 Row::Own(Kind::Doses) if !matches!(column, Column::Sleep | Column::Pause) => Some("attention-lock-doses"),
533 Row::Own(Kind::Codes) if column != Column::Sleep => Some("attention-lock-codes"),
534 Row::Own(Kind::Alarms) if matches!(column, Column::Pause | Column::Dnd) => Some("attention-lock-alarms"),
535 Row::Own(Kind::Wake) => Some("attention-lock-wake"),
536 _ => None,
537 }
538}
539
540pub const APP_ROW: &str = "app.";
552pub const CONVERSATION_ROW: &str = "conversation.";
553pub const NAME: &str = "name=";
556
557pub const SOURCE_CHOICES: [Level; 4] = [Level::Now, Level::Gathered, Level::Later, Level::As];
560
561pub fn is_source(id: &str) -> bool {
563 [APP_ROW, CONVERSATION_ROW].iter().any(|prefix| id.strip_prefix(prefix).is_some_and(|rest| !rest.is_empty() && !rest.contains(char::is_whitespace)))
564}
565
566pub fn source_rows(package: &str, conversation: &str) -> Vec<String> {
569 let (package, conversation) = (package.trim(), conversation.trim());
570 let mut rows = Vec::new();
571 if !conversation.is_empty() {
572 rows.push(format!("{CONVERSATION_ROW}{conversation}"));
573 }
574 if !package.is_empty() {
575 rows.push(format!("{APP_ROW}{package}"));
576 }
577 rows
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583enum Base {
584 Row(Row),
585 Person(Channel, Person, bool),
586}
587
588#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
593pub struct Holds {
594 pub porch_rests: bool,
596 pub meeting: bool,
598 pub chats: bool,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct Now {
605 pub times: Vec<Column>,
608 pub time: Time,
611 pub week: Week,
612 pub slot: bool,
614 pub dnd: bool,
616 pub nothing: bool,
618 pub span: (i64, i64),
620 pub gathering: bool,
622 pub realtime: bool,
624 pub through: bool,
626 pub holds: Holds,
627}
628
629impl Now {
630 pub fn of(mode: &Mode) -> Now {
633 let times = if mode.paused() {
634 vec![Column::Pause]
635 } else if mode.sleeps() {
636 vec![Column::Sleep]
637 } else if mode.free() {
638 vec![Column::Free]
639 } else {
640 match mode.time {
641 Time::Work => vec![Column::Work],
642 Time::Admin => vec![Column::Admin],
643 Time::Leisure => vec![Column::Leisure],
644 Time::Meals => vec![Column::Meals],
645 Time::Sleep => vec![Column::Sleep],
646 Time::Several(open) if open.work != open.admin => vec![if open.work { Column::Work } else { Column::Admin }],
647 Time::Any | Time::Several(_) => vec![Column::Work, Column::Admin],
648 }
649 };
650 let end = if mode.paused() { i64::MAX } else { mode.until.as_ref().map_or(i64::MAX, |u| u.timestamp().as_second()) };
651 Now { times, time: mode.time, week: mode.week, slot: false, dnd: false, nothing: false, span: (i64::MIN, end), gathering: false, realtime: false, through: false, holds: Holds::default() }
652 }
653
654 pub fn of_moment(moment: &crate::reach::Moment) -> Now {
657 let times = if moment.paused {
658 vec![Column::Pause]
659 } else if moment.time == Time::Sleep {
660 vec![Column::Sleep]
661 } else if moment.free {
662 vec![Column::Free]
663 } else {
664 match moment.time {
665 Time::Work => vec![Column::Work],
666 Time::Admin => vec![Column::Admin],
667 Time::Meals => vec![Column::Meals],
668 Time::Several(open) if open.work != open.admin => vec![if open.work { Column::Work } else { Column::Admin }],
669 Time::Any | Time::Several(_) => vec![Column::Work, Column::Admin],
670 _ => vec![Column::Leisure],
671 }
672 };
673 Now { times, time: moment.time, week: moment.week, slot: false, dnd: false, nothing: moment.free && moment.nothing, span: (i64::MIN, i64::MAX), gathering: false, realtime: false, through: false, holds: Holds::default() }
674 }
675
676 pub fn time(column: Column) -> Now {
678 let time = match column {
679 Column::Work => Time::Work,
680 Column::Admin => Time::Admin,
681 Column::Meals => Time::Meals,
682 Column::Sleep | Column::Pause => Time::Sleep,
683 _ => Time::Leisure,
684 };
685 let week = Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
686 Now { times: vec![column], time, week, slot: false, dnd: false, nothing: false, span: (i64::MIN, i64::MAX), gathering: false, realtime: false, through: false, holds: Holds::default() }
687 }
688
689 pub fn layers(mut self, slot: bool, dnd: bool) -> Now {
691 self.slot = slot;
692 self.dnd = dnd;
693 self
694 }
695
696 pub fn is(&self, column: Column) -> bool {
698 self.times.contains(&column)
699 }
700}
701
702#[derive(Debug, Clone, PartialEq, Eq)]
708pub struct Attention {
709 cells: [[Level; 9]; 36],
710 sources: BTreeMap<String, [Level; 9]>,
711 names: BTreeMap<String, String>,
712}
713
714impl Default for Attention {
715 fn default() -> Attention {
716 Attention::usual()
717 }
718}
719
720impl Attention {
721 pub fn usual() -> Attention {
723 let mut cells = [[Level::Now; 9]; 36];
724 for row in Row::ALL {
725 for column in Column::ALL {
726 cells[row.index()][column.index()] = usual(row, column);
727 }
728 }
729 Attention { cells, sources: BTreeMap::new(), names: BTreeMap::new() }
730 }
731
732 pub fn of(config: &Config) -> Attention {
737 let Some(settings) = config.attention.as_ref() else { return seeded(config) };
738 let mut attention = Attention::usual();
739 for row in Row::ALL {
740 for (column, level) in settings.words(&row.id()).unwrap_or_default().iter().filter_map(|w| word(w)) {
741 let _ = attention.set(row, column, level);
742 }
743 }
744 for (id, words) in settings.sources() {
746 for text in &words {
747 match text.strip_prefix(NAME) {
748 Some(name) => attention.name_source(&id, name),
749 None => {
750 if let Some((column, level)) = word(text) {
751 let _ = attention.set_source(&id, column, level);
752 }
753 }
754 }
755 }
756 }
757 let sources = &attention.sources;
758 attention.names.retain(|id, _| sources.contains_key(id));
759 attention
760 }
761
762 pub fn cell(&self, row: Row, column: Column) -> Level {
763 self.cells[row.index()][column.index()]
764 }
765
766 pub fn set(&mut self, row: Row, column: Column, level: Level) -> Result<(), String> {
769 if self.cell(row, column) == level {
770 return Ok(());
771 }
772 if lock(row, column).is_some() {
773 return Err(format!("attention.{}: {} is fixed", row.id(), column.id()));
774 }
775 if !choices(row, column).contains(&level) {
776 let offered: Vec<&str> = choices(row, column).iter().map(|l| l.id()).collect();
777 return Err(format!("attention.{}: {}: {}", row.id(), column.id(), offered.join(", ")));
778 }
779 self.cells[row.index()][column.index()] = level;
780 Ok(())
781 }
782
783 pub fn words(&self, row: Row) -> Vec<String> {
786 Column::ALL
787 .iter()
788 .map(|column| match self.cell(row, *column) {
789 Level::Now => column.id().to_string(),
790 other => format!("{}:{}", column.id(), other.id()),
791 })
792 .collect()
793 }
794
795 pub fn changes_from(&self, other: &Attention) -> usize {
798 Row::ALL.iter().flat_map(|r| Column::ALL.iter().map(move |c| (*r, *c))).filter(|(r, c)| self.cell(*r, *c) != other.cell(*r, *c)).count()
799 }
800
801 pub fn source(&self, id: &str) -> Option<[Level; 9]> {
804 self.sources.get(id).copied()
805 }
806
807 pub fn sources(&self) -> &BTreeMap<String, [Level; 9]> {
809 &self.sources
810 }
811
812 pub fn source_name(&self, id: &str) -> &str {
815 self.names.get(id).map_or("", String::as_str)
816 }
817
818 pub fn set_source(&mut self, id: &str, column: Column, level: Level) -> Result<(), String> {
822 if !is_source(id) {
823 return Err(format!("attention.{id}: no such row"));
824 }
825 if !SOURCE_CHOICES.contains(&level) {
826 let offered: Vec<&str> = SOURCE_CHOICES.iter().map(|l| l.id()).collect();
827 return Err(format!("attention.{id}: {}: {}", column.id(), offered.join(", ")));
828 }
829 let mut cells = self.source(id).unwrap_or([Level::As; 9]);
830 cells[column.index()] = level;
831 if cells.iter().all(|l| *l == Level::As) {
832 self.sources.remove(id);
833 } else {
834 self.sources.insert(id.to_string(), cells);
835 }
836 Ok(())
837 }
838
839 pub fn name_source(&mut self, id: &str, name: &str) {
841 let name = name.trim();
842 if name.is_empty() {
843 self.names.remove(id);
844 } else {
845 self.names.insert(id.to_string(), name.to_string());
846 }
847 }
848
849 pub fn source_words(&self, id: &str) -> Vec<String> {
853 let Some(cells) = self.source(id) else { return Vec::new() };
854 let mut words: Vec<String> = Column::ALL
855 .iter()
856 .filter(|c| cells[c.index()] != Level::As)
857 .map(|c| match cells[c.index()] {
858 Level::Now => c.id().to_string(),
859 other => format!("{}:{}", c.id(), other.id()),
860 })
861 .collect();
862 if let Some(name) = self.names.get(id).filter(|n| !n.is_empty()) {
863 words.push(format!("{NAME}{name}"));
864 }
865 words
866 }
867
868 pub fn has_sources(&self, ids: &[String]) -> bool {
870 ids.iter().any(|id| self.sources.contains_key(id))
871 }
872
873 fn source_cells(&self, ids: &[String]) -> Vec<[Level; 9]> {
875 ids.iter().filter_map(|id| self.source(id)).collect()
876 }
877
878 fn base_at(&self, base: Base, column: Column, now: &Now) -> Level {
882 let free_nothing = column == Column::Free && now.nothing;
883 match base {
884 Base::Row(row) => {
885 if free_nothing && matches!(row, Row::People(_, person) if person.state()) {
886 return Level::Later;
887 }
888 match self.lent(row, column, now.week) {
889 Level::As => Level::Later,
890 Level::Through => Level::Now,
891 other => other,
892 }
893 }
894 Base::Person(channel, person, always) => {
895 if person == Person::Blocked {
896 return Level::Never;
897 }
898 let own = Row::People(channel, if person == Person::Always { Person::Safe } else { person });
899 if !always && person != Person::Always {
900 return self.base_at(Base::Row(own), column, now);
901 }
902 let always_row = Row::People(channel, Person::Always);
903 let level = match self.lent(always_row, column, now.week) {
904 Level::As => self.lent(own, column, now.week),
905 Level::Through => Level::Now,
906 level => level,
907 };
908 if free_nothing && self.cell(always_row, Column::Free) == Level::As { level.max(Level::Later) } else { level }
909 }
910 }
911 }
912
913 fn over(&self, base: Base, sources: &[[Level; 9]], now: &Now) -> (Level, bool) {
920 let at = |column: Column| match sources.iter().map(|cells| cells[column.index()]).find(|l| *l != Level::As) {
921 Some(level) => (level, true),
922 None => (self.base_at(base, column, now), false),
923 };
924 let times: Vec<(Level, bool)> = now.times.iter().map(|c| at(*c)).collect();
925 let mut level = times.iter().map(|(l, _)| *l).min().unwrap_or(Level::Now);
926 let mut chosen = times.iter().any(|(_, set)| *set);
927 for (holds, column) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
928 if holds {
929 let (layer, set) = at(column);
930 level = level.max(layer);
931 chosen |= set;
932 }
933 }
934 (level, chosen)
935 }
936
937 fn lent(&self, row: Row, column: Column, week: Week) -> Level {
941 let own = self.cell(row, column);
942 if !matches!(row, Row::People(..)) {
943 return own;
944 }
945 match column {
946 Column::Work if !week.admin_hours => own.min(self.cell(row, Column::Admin)),
947 Column::Admin if !week.work_hours => own.min(self.cell(row, Column::Work)),
948 _ => own,
949 }
950 }
951
952 pub fn level(&self, row: Row, now: &Now) -> Level {
958 if let Row::People(_, person) = row
959 && person.state()
960 && now.nothing
961 && now.is(Column::Free)
962 {
963 return Level::Later;
964 }
965 let read = |level: Level| match level {
966 Level::As => Level::Later,
967 Level::Through => Level::Now,
968 other => other,
969 };
970 let mut level = now.times.iter().map(|c| read(self.lent(row, *c, now.week))).min().unwrap_or(Level::Now);
971 if now.slot {
972 level = level.max(read(self.cell(row, Column::Slot)));
973 }
974 if now.dnd {
975 level = level.max(read(self.cell(row, Column::Dnd)));
976 }
977 level
978 }
979
980 pub fn person(&self, channel: Channel, person: Person, always: bool, now: &Now) -> Level {
984 if person == Person::Blocked {
985 return Level::Never;
986 }
987 let own = Row::People(channel, if person == Person::Always { Person::Safe } else { person });
988 if !always && person != Person::Always {
989 return self.level(own, now);
990 }
991 let always_row = Row::People(channel, Person::Always);
992 let at = |column: Column| match self.lent(always_row, column, now.week) {
993 Level::As => self.lent(own, column, now.week),
994 Level::Through => Level::Now,
995 level => level,
996 };
997 let mut level = now.times.iter().map(|c| at(*c)).min().unwrap_or(Level::Now);
998 if now.nothing && now.is(Column::Free) && self.cell(always_row, Column::Free) == Level::As {
1000 level = level.max(Level::Later);
1001 }
1002 for (holds, column) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
1003 if holds {
1004 level = level.max(at(column));
1005 }
1006 }
1007 level
1008 }
1009
1010 pub fn listed(&self, person: Person, always: bool, now: &Now) -> bool {
1016 if person == Person::Blocked {
1017 return false;
1018 }
1019 if self.person(Channel::Calls, person, always, now) == Level::Now {
1020 return true;
1021 }
1022 let row = Row::People(Channel::Calls, if person == Person::Always { Person::Safe } else { person });
1023 let never_rings = !always && Column::TIMES.iter().all(|c| self.cell(row, *c) != Level::Now);
1024 never_rings && !now.dnd && now.times.iter().any(|c| matches!(c, Column::Work | Column::Admin))
1025 }
1026
1027 fn meets(&self, channel: Channel, person: Person, area: Area, week: Week) -> bool {
1030 Time::STATES.into_iter().filter(|t| week.has(*t)).any(|t| {
1031 let column = match t {
1032 Time::Work => Column::Work,
1033 Time::Admin => Column::Admin,
1034 Time::Leisure => Column::Leisure,
1035 Time::Meals => Column::Meals,
1036 _ => Column::Sleep,
1037 };
1038 matches!(self.lent(Row::People(channel, person), column, week), Level::Now | Level::Quiet) && in_view(area, t, week)
1039 })
1040 }
1041}
1042
1043pub fn word(text: &str) -> Option<(Column, Level)> {
1046 match text.rsplit_once(':') {
1047 Some((column, value)) => Some((Column::read(column)?, Level::read(value)?)),
1048 None => Some((Column::read(text)?, Level::Now)),
1049 }
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Eq)]
1056pub struct Event {
1057 pub source: Source,
1058 pub area: Option<Area>,
1061 pub starts: Option<i64>,
1063 pub sources: Vec<String>,
1066}
1067
1068impl Event {
1069 pub fn own(kind: Kind) -> Event {
1070 Event { source: Source::Own(kind), area: None, starts: None, sources: Vec::new() }
1071 }
1072
1073 pub fn of(source: Source) -> Event {
1074 Event { source, area: None, starts: None, sources: Vec::new() }
1075 }
1076
1077 pub fn from_sources(mut self, sources: Vec<String>) -> Event {
1079 self.sources = sources;
1080 self
1081 }
1082
1083 pub fn for_area(mut self, area: Option<Area>) -> Event {
1085 self.area = area;
1086 self
1087 }
1088
1089 pub fn starting(mut self, at: Option<i64>) -> Event {
1091 self.starts = at;
1092 self
1093 }
1094}
1095
1096#[derive(Debug, Clone, PartialEq, Eq)]
1098pub enum Source {
1099 Own(Kind),
1101 Mail { who: Who, always: bool, lane: Lane },
1105 Call { who: Option<Who>, always: bool, emergency: bool, repeat: bool },
1109 Message { via: Channel, who: Who, group: bool, always: bool },
1112 Site { call: bool, live: bool, named: Option<Who> },
1115}
1116
1117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1119pub enum Lane {
1120 Usual,
1122 Code,
1124 Own,
1126 Untold,
1129}
1130
1131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1133#[serde(rename_all = "kebab-case")]
1134pub enum Step {
1135 Emergency,
1137 Blocked,
1139 Content,
1141 Floor,
1143 Matrix,
1145 Chosen,
1147 Area,
1149 Hold,
1151}
1152
1153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1155pub struct Output {
1156 pub level: Level,
1158 pub shown: bool,
1160 pub told: bool,
1162 pub pierce: bool,
1166 pub step: Step,
1167}
1168
1169impl Output {
1170 fn at(level: Level, step: Step) -> Output {
1171 Output { level, shown: level != Level::Later, told: level == Level::Now, pierce: false, step }
1172 }
1173}
1174
1175impl Attention {
1176 pub fn decide(&self, event: &Event, now: &Now) -> Output {
1181 match &event.source {
1182 Source::Own(kind) => self.own(*kind, event, now),
1183 Source::Mail { who, always, lane } => self.letter(*who, *always, *lane, event, now),
1184 Source::Call { who, always, emergency, repeat } => self.call(*who, *always, *emergency, *repeat, now),
1185 Source::Message { via, who, group, always } => self.message(*via, *who, *group, *always, event, now),
1186 Source::Site { call, live, named } => self.site(*call, *live, *named, event, now),
1187 }
1188 }
1189
1190 pub fn mail(&self, t: &crate::porch::Triaged, senders: &crate::porch::Senders, always: &crate::everywhere::People, area: Area, now: &Now) -> Output {
1196 use crate::porch::{Lane as PorchLane, Reason};
1197 let unproven = t.lane == PorchLane::SetAside || t.reasons.contains(&Reason::NotAuthenticated);
1198 let who = if unproven { Who::Stranger } else { senders.who_of(&t.card) };
1199 let always = !unproven && t.card.from_address.as_deref().is_some_and(|address| always.admits_address(address));
1200 let lane = if t.lane == PorchLane::RightNow {
1201 Lane::Code
1202 } else if t.reasons.contains(&Reason::FromYourself) {
1203 Lane::Own
1204 } else {
1205 Lane::Usual
1206 };
1207 self.decide(&Event::of(Source::Mail { who, always, lane }).for_area(Some(area)), now)
1208 }
1209
1210 fn own(&self, kind: Kind, event: &Event, now: &Now) -> Output {
1211 let sources = self.source_cells(&event.sources);
1213 let (mut level, chosen) = if sources.is_empty() { (self.level(Row::Own(kind), now), false) } else { self.over(Base::Row(Row::Own(kind)), &sources, now) };
1214 if level == Level::Event {
1215 level = if event.starts.is_some_and(|s| now.span.0 <= s && s < now.span.1) { Level::Now } else { Level::Later };
1216 }
1217 let mut step = if chosen { Step::Chosen } else { Step::Matrix };
1218 let hours = !now.times.iter().any(|c| matches!(c, Column::Sleep | Column::Pause | Column::Free));
1222 if !chosen && hours && matches!(level, Level::Now | Level::Gathered) && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1223 (level, step) = (Level::Later, Step::Area);
1224 }
1225 if now.holds.meeting && matches!(kind, Kind::Needs | Kind::WorkOver) && level == Level::Now {
1227 (level, step) = (Level::Never, Step::Hold);
1228 }
1229 let told = level == Level::Now || (level == Level::Gathered && now.gathering);
1230 Output { level, shown: true, told, pierce: told && matches!(kind, Kind::Codes | Kind::Doses | Kind::Wake | Kind::Alarms), step }
1231 }
1232
1233 fn letter(&self, who: Who, always: bool, lane: Lane, event: &Event, now: &Now) -> Output {
1234 match lane {
1236 Lane::Code => {
1237 let told = self.level(Row::Own(Kind::Codes), now) == Level::Now;
1238 return Output { level: if told { Level::Now } else { Level::Quiet }, shown: true, told, pierce: told, step: Step::Content };
1239 }
1240 Lane::Own => return Output { level: Level::Quiet, shown: true, told: false, pierce: false, step: Step::Content },
1241 _ => {}
1242 }
1243 let person = Person::of(who);
1244 if person == Person::Blocked {
1245 return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1246 }
1247 let mut level = self.person(Channel::Mail, person, always, now);
1248 let mut step = Step::Matrix;
1249 if let Some(area) = event.area
1253 && matches!(level, Level::Now | Level::Quiet)
1254 && person != Person::Safe
1255 && !always
1256 && !in_view(area, now.time, now.week)
1257 && self.meets(Channel::Mail, person, area, now.week)
1258 {
1259 (level, step) = (Level::Later, Step::Area);
1260 }
1261 let mut out = Output::at(level, step);
1262 out.told = level == Level::Now && lane == Lane::Usual;
1263 if out.told && now.holds.porch_rests {
1264 (out.told, out.step) = (false, Step::Hold);
1265 }
1266 out.pierce = out.told && always;
1267 out
1268 }
1269
1270 fn call(&self, who: Option<Who>, always: bool, emergency: bool, repeat: bool, now: &Now) -> Output {
1271 let rings = |step: Step| Output { level: Level::Now, shown: true, told: true, pierce: true, step };
1272 if emergency {
1273 return rings(Step::Emergency);
1274 }
1275 let person = who.map_or(Person::Hidden, Person::of);
1276 if person == Person::Blocked {
1277 return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1278 }
1279 if now.through || (repeat && who.is_some()) {
1280 return rings(Step::Floor);
1281 }
1282 let level = self.person(Channel::Calls, person, always && who.is_some(), now);
1283 let mut out = Output::at(level, Step::Matrix);
1284 out.pierce = out.told && always;
1285 out
1286 }
1287
1288 fn message(&self, via: Channel, who: Who, group: bool, always: bool, event: &Event, now: &Now) -> Output {
1289 let person = if who == Who::Blocked { Person::Blocked } else if group && via == Channel::Messages { Person::Groups } else { Person::of(who) };
1290 if person == Person::Blocked {
1291 return Output { level: Level::Never, shown: false, told: false, pierce: false, step: Step::Blocked };
1292 }
1293 let sources = if always { self.source_cells(&event.sources.iter().filter(|id| id.starts_with(CONVERSATION_ROW)).cloned().collect::<Vec<_>>()) } else { self.source_cells(&event.sources) };
1296 let (mut level, chosen) = if !sources.is_empty() {
1298 self.over(Base::Person(via, person, always), &sources, now)
1299 } else if via == Channel::Calls {
1300 (if self.listed(person, always, now) { Level::Now } else { Level::Later }, false)
1301 } else {
1302 (self.person(via, person, always, now), false)
1303 };
1304 if level == Level::Quiet {
1306 level = Level::Later;
1307 }
1308 let mut step = if chosen { Step::Chosen } else { Step::Matrix };
1309 if !chosen && level == Level::Now && person != Person::Safe && !always && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1310 (level, step) = (Level::Later, Step::Area);
1311 }
1312 let mut out = Output::at(level, step);
1313 out.pierce = out.told && always;
1314 out
1315 }
1316
1317 fn site(&self, call: bool, live: bool, named: Option<Who>, event: &Event, now: &Now) -> Output {
1318 let kind = if call {
1319 Kind::SiteCalls
1320 } else if live || now.realtime {
1321 Kind::SitesLive
1322 } else {
1323 Kind::Sites
1324 };
1325 let mut level = self.level(Row::Own(kind), now);
1326 let mut step = Step::Matrix;
1327 if let Some(who) = named {
1329 let theirs = self.person(Channel::Messages, Person::of(who), false, now);
1330 if theirs > level {
1331 level = if theirs == Level::Never { Level::Never } else { Level::Later };
1332 step = if who == Who::Blocked { Step::Blocked } else { Step::Matrix };
1333 }
1334 }
1335 if matches!(level, Level::Now | Level::Gathered) && event.area.is_some_and(|area| !in_view(area, now.time, now.week)) {
1336 (level, step) = (Level::Later, Step::Area);
1337 }
1338 if matches!(level, Level::Now | Level::Gathered) && now.holds.chats {
1339 (level, step) = (Level::Later, Step::Hold);
1340 }
1341 let told = level == Level::Now || (level == Level::Gathered && now.gathering);
1342 Output { level, shown: level != Level::Never, told, pierce: false, step }
1343 }
1344}
1345
1346#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1351#[serde(rename_all = "lowercase")]
1352pub enum Senders {
1353 None,
1354 Starred,
1355 Contacts,
1356 Anyone,
1357}
1358
1359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1363pub struct Silence {
1364 pub calls: Senders,
1365 pub messages: Senders,
1366 pub repeat: bool,
1368 pub conversations: bool,
1370 pub alarms: bool,
1372 pub doses: bool,
1374 pub events: bool,
1376}
1377
1378#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1380pub struct Phone {
1381 pub screens: bool,
1383}
1384
1385impl Attention {
1386 pub fn silence_at(&self, now: &Now, phone: Phone) -> Silence {
1400 let passes = |channel: Channel, person: Person| self.person(channel, person, false, now) == Level::Now;
1401 let always = |channel: Channel| self.person(channel, Person::Always, true, now) == Level::Now;
1402 let senders = |channel: Channel, own: bool| {
1403 let strangers = [Person::Stranger, Person::Hidden, Person::Groups].iter().any(|p| persons(channel).contains(p) && passes(channel, *p));
1404 let known = [Person::Safe, Person::Neutral, Person::Restricted].iter().any(|p| passes(channel, *p));
1405 match (own, strangers, known || always(channel)) {
1406 (true, true, _) => Senders::Anyone,
1407 (true, false, true) => Senders::Contacts,
1408 (false, _, true) | (false, true, _) => Senders::Starred,
1409 _ => Senders::None,
1410 }
1411 };
1412 let calls = if now.through { Senders::Anyone } else { senders(Channel::Calls, phone.screens) };
1413 Silence {
1414 calls,
1415 messages: senders(Channel::Messages, false),
1416 repeat: calls != Senders::None,
1417 conversations: always(Channel::Messages),
1418 alarms: true,
1419 doses: self.level(Row::Own(Kind::Doses), now) == Level::Now,
1420 events: matches!(self.level(Row::Own(Kind::Alarms), now), Level::Now | Level::Event),
1421 }
1422 }
1423
1424 pub fn silence(&self, columns: &[Column], nothing: bool, phone: Phone) -> Silence {
1429 let mut now = Now::time(Column::Leisure);
1430 now.times = columns.iter().copied().filter(|c| c.is_time()).collect();
1431 now.slot = columns.contains(&Column::Slot);
1432 now.dnd = columns.contains(&Column::Dnd);
1433 now.nothing = nothing;
1434 self.silence_at(&now, phone)
1435 }
1436}
1437
1438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1443#[serde(rename_all = "kebab-case")]
1444pub enum Preset {
1445 Usual,
1446 Quieter,
1447 Reachable,
1448}
1449
1450impl Preset {
1451 pub const ALL: [Preset; 3] = [Preset::Usual, Preset::Quieter, Preset::Reachable];
1452
1453 pub fn id(self) -> &'static str {
1454 match self {
1455 Preset::Usual => "usual",
1456 Preset::Quieter => "quieter",
1457 Preset::Reachable => "reachable",
1458 }
1459 }
1460
1461 pub fn read(text: &str) -> Option<Preset> {
1462 Preset::ALL.into_iter().find(|p| p.id() == text.trim())
1463 }
1464
1465 pub fn matrix(self) -> Attention {
1467 use Column::*;
1468 use Level::*;
1469 let mut matrix = Attention::usual();
1470 let changes: &[(Row, &[Column], Level)] = match self {
1471 Preset::Usual => &[],
1472 Preset::Quieter => &[
1473 (Row::People(Channel::Mail, Person::Safe), &[Leisure, Meals], Quiet),
1474 (Row::People(Channel::Calls, Person::Neutral), &[Admin, Leisure, Meals], Later),
1475 (Row::People(Channel::Messages, Person::Neutral), &[Leisure, Meals], Later),
1476 (Row::People(Channel::Calls, Person::Hidden), &[Admin], Later),
1477 (Row::People(Channel::Messages, Person::Safe), &[Free], Later),
1478 (Row::Own(Kind::Dates), &[Leisure, Meals], Later),
1479 (Row::Own(Kind::SitesLive), &[Leisure, Meals], Later),
1480 ],
1481 Preset::Reachable => &[
1482 (Row::People(Channel::Mail, Person::Neutral), &[Leisure, Meals], Now),
1483 (Row::People(Channel::Mail, Person::Stranger), &[Leisure], Quiet),
1484 (Row::People(Channel::Calls, Person::Neutral), &[Leisure, Meals], Now),
1485 (Row::People(Channel::Calls, Person::Hidden), &[Leisure], Now),
1486 (Row::People(Channel::Messages, Person::Neutral), &[Leisure, Meals], Now),
1487 (Row::Own(Kind::AppAutomatons), &[Work, Admin], Now),
1488 ],
1489 };
1490 for (row, columns, level) in changes {
1491 for column in *columns {
1492 let _ = matrix.set(*row, *column, *level);
1493 }
1494 }
1495 matrix
1496 }
1497}
1498
1499#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1504#[serde(transparent)]
1505pub struct Settings(pub BTreeMap<String, toml::Value>);
1506
1507impl Settings {
1508 pub fn words(&self, row: &str) -> Option<Vec<String>> {
1511 let value = self.0.iter().find(|(key, _)| Row::read(key).is_some_and(|r| r.id() == row)).map(|(_, v)| v)?;
1512 Some(match value {
1513 toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1514 toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1515 _ => Vec::new(),
1516 })
1517 }
1518
1519 pub fn sources(&self) -> Vec<(String, Vec<String>)> {
1522 self.0
1523 .iter()
1524 .filter(|(key, _)| is_source(key.trim()))
1525 .map(|(key, value)| {
1526 let words = match value {
1527 toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1528 toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1529 _ => Vec::new(),
1530 };
1531 (key.trim().to_string(), words)
1532 })
1533 .collect()
1534 }
1535}
1536
1537#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
1541#[serde(transparent)]
1542pub struct NotifySettings(pub BTreeMap<String, toml::Value>);
1543
1544impl NotifySettings {
1545 pub fn words(&self, row: &str) -> Option<Vec<String>> {
1548 let value = self.0.iter().find(|(key, _)| key.trim().to_ascii_lowercase().replace('_', "-") == row).map(|(_, v)| v)?;
1549 Some(match value {
1550 toml::Value::Array(list) => list.iter().filter_map(|w| w.as_str().map(str::to_string)).collect(),
1551 toml::Value::String(text) => text.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect(),
1552 _ => Vec::new(),
1553 })
1554 }
1555}
1556
1557pub const OLDER: &[&str] = &["reach", "notify", "reminders.doses_in_sleep", "pause.doses", "pause.people", "dnd.people"];
1559
1560pub fn seeded(config: &Config) -> Attention {
1565 let (then, then_usual) = (older(config), older(&Config::default()));
1566 let mut attention = Attention::usual();
1567 for row in Row::ALL {
1568 for column in Column::ALL {
1569 let (was, usual_then) = (then[row.index()][column.index()], then_usual[row.index()][column.index()]);
1570 if was != usual_then {
1571 let _ = attention.set(row, column, was);
1572 }
1573 }
1574 }
1575 let always = |channel: Channel| Row::People(channel, Person::Always);
1579 if !config.dnd.people {
1580 for channel in Channel::ALL {
1581 for column in Column::ALL {
1582 let _ = attention.set(always(channel), column, Level::As);
1583 }
1584 }
1585 } else if !config.pause.people {
1586 for channel in [Channel::Calls, Channel::Messages] {
1587 let _ = attention.set(always(channel), Column::Pause, Level::As);
1588 }
1589 }
1590 attention
1591}
1592
1593fn older(config: &Config) -> [[Level; 9]; 36] {
1595 use Column::*;
1596 let mut cells = [[Level::Now; 9]; 36];
1597 let notify = |row: &str, column: Column| -> &'static str {
1599 let usual = older_notify(row, column);
1600 let mut value = usual;
1601 for text in config.notify.words(row).unwrap_or_default() {
1602 let (col, val) = match text.rsplit_once(':') {
1603 Some((c, v)) => (Column::read(c), v.trim().to_ascii_lowercase()),
1604 None => (Column::read(&text), "now".to_string()),
1605 };
1606 if col == Some(column)
1607 && let Some(known) = ["now", "list-any", "list", "event", "gathered", "later", "never"].into_iter().find(|k| *k == val.replace('_', "-"))
1608 {
1609 value = known;
1610 }
1611 }
1612 value
1613 };
1614 let doses = |column: Column| match column {
1615 Sleep if config.notify.words("doses").is_none_or(|w| !w.iter().any(|w| w.starts_with("sleep"))) => if config.reminders.doses_in_sleep { "now" } else { "later" },
1616 Pause if config.notify.words("doses").is_none_or(|w| !w.iter().any(|w| w.starts_with("pause"))) => if config.pause.doses { "now" } else { "later" },
1617 _ => notify("doses", column),
1618 };
1619 let level = |value: &str| match value {
1620 "now" | "list-any" => Level::Now,
1621 "event" => Level::Event,
1622 "gathered" => Level::Gathered,
1623 "never" => Level::Never,
1624 _ => Level::Later,
1625 };
1626 for kind in Kind::ALL {
1627 for column in Column::ALL {
1628 let value = if kind == Kind::Doses { doses(column) } else { notify(kind.id(), column) };
1629 cells[Row::Own(kind).index()][column.index()] = level(value);
1630 }
1631 }
1632 let reach = &config.reach;
1634 let before = reach.stranger.is_none();
1635 let ticks = |words: &[String]| -> [bool; 6] {
1636 let mut on = [false; 6];
1637 for column in words.iter().filter_map(|w| Column::read(w)) {
1638 if let Some(at) = [Work, Admin, Leisure, Meals, Sleep, Pause].iter().position(|c| *c == column) {
1639 on[at] = true;
1640 }
1641 }
1642 on
1643 };
1644 let mail_usual = |person: Person| -> [bool; 6] {
1645 match person {
1646 Person::Safe => [true; 6],
1647 Person::Neutral | Person::Stranger => [true, true, false, false, false, false],
1648 _ => [true, false, false, false, false, false],
1649 }
1650 };
1651 let mail_row = |set: &Option<Vec<String>>, person: Person| -> [bool; 6] {
1652 match set {
1653 None => mail_usual(person),
1654 Some(words) => {
1655 let mut on = ticks(words);
1656 if before {
1658 on[5] = on[4];
1659 }
1660 on
1661 }
1662 }
1663 };
1664 let neutral = mail_row(&reach.neutral, Person::Neutral);
1665 let mail: BTreeMap<Person, [bool; 6]> = [
1666 (Person::Safe, mail_row(&reach.safe, Person::Safe)),
1667 (Person::Neutral, neutral),
1668 (Person::Restricted, mail_row(&reach.restricted, Person::Restricted)),
1669 (Person::Stranger, if before { neutral } else { mail_row(&reach.stranger, Person::Stranger) }),
1670 ]
1671 .into_iter()
1672 .collect();
1673 let calls_usual = |person: Person| -> [bool; 6] {
1674 match person {
1675 Person::Safe => [true, true, true, true, false, false],
1676 Person::Neutral | Person::Hidden => [true, true, false, false, false, false],
1677 Person::Restricted => [true, false, false, false, false, false],
1678 _ => [false; 6],
1679 }
1680 };
1681 let row_of = |set: &crate::config::RowSettings, person: Person| match person {
1682 Person::Safe => set.safe.clone(),
1683 Person::Neutral => set.neutral.clone(),
1684 Person::Restricted => set.restricted.clone(),
1685 Person::Stranger => set.stranger.clone(),
1686 Person::Hidden => set.hidden.clone(),
1687 _ => None,
1688 };
1689 let times6 = [Work, Admin, Leisure, Meals, Sleep, Pause];
1690 for person in [Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger] {
1691 let on = mail[&person];
1693 let row = Row::People(Channel::Mail, person).index();
1694 let told = |value: &str| match value {
1695 "now" => Level::Now,
1696 "never" => Level::Never,
1697 _ => Level::Quiet,
1698 };
1699 for (at, column) in times6.iter().enumerate() {
1700 cells[row][column.index()] = if on[at] { told(notify("mail", *column)) } else { Level::Later };
1701 }
1702 cells[row][Free.index()] = if person == Person::Safe && on[2] { told(notify("mail", Free)) } else { Level::Later };
1703 cells[row][Slot.index()] = told(notify("mail", Slot));
1704 cells[row][Dnd.index()] = match notify("mail", Dnd) {
1705 "now" => Level::Now,
1706 "never" => Level::Never,
1707 _ => Level::Quiet,
1708 };
1709 let on = row_of(&reach.messages, person).map_or(on, |w| ticks(&w));
1711 let row = Row::People(Channel::Messages, person).index();
1712 let now_or_later = |value: &str| if value == "now" { Level::Now } else { Level::Later };
1713 for (at, column) in times6.iter().enumerate() {
1714 cells[row][column.index()] = if on[at] { now_or_later(notify("app-people", *column)) } else { Level::Later };
1715 }
1716 cells[row][Free.index()] = if person == Person::Safe && on[2] { now_or_later(notify("app-people", Free)) } else { Level::Later };
1717 cells[row][Slot.index()] = now_or_later(notify("app-people", Slot));
1718 cells[row][Dnd.index()] = now_or_later(notify("app-people", Dnd));
1719 if person == Person::Stranger {
1720 cells[Row::People(Channel::Messages, Person::Groups).index()] = cells[row];
1721 }
1722 }
1723 for person in [Person::Safe, Person::Neutral, Person::Restricted, Person::Stranger, Person::Hidden] {
1725 let on = row_of(&reach.calls, person).map_or(calls_usual(person), |w| ticks(&w));
1726 let row = Row::People(Channel::Calls, person).index();
1727 for (at, column) in times6.iter().enumerate() {
1728 cells[row][column.index()] = if on[at] { Level::Now } else { Level::Later };
1729 }
1730 cells[row][Free.index()] = if person == Person::Safe && on[2] { Level::Now } else { Level::Later };
1731 cells[row][Slot.index()] = Level::Now;
1732 cells[row][Dnd.index()] = Level::Now;
1733 }
1734 for channel in Channel::ALL {
1737 let row = Row::People(channel, Person::Always).index();
1738 cells[row] = [Level::As; 9];
1739 if !config.dnd.people {
1740 continue;
1741 }
1742 match channel {
1743 Channel::Calls => cells[row] = [Level::Now; 9],
1744 Channel::Mail | Channel::Messages => {
1745 cells[row][Dnd.index()] = match notify(if channel == Channel::Mail { "mail" } else { "app-people" }, Dnd) {
1746 "list" => Level::Through,
1747 "list-any" => Level::Now,
1748 _ => Level::As,
1749 };
1750 }
1751 }
1752 if !config.pause.people && channel != Channel::Mail {
1754 cells[row][Pause.index()] = Level::As;
1755 }
1756 }
1757 for channel in Channel::ALL {
1758 cells[Row::People(channel, Person::Blocked).index()] = [Level::Never; 9];
1759 }
1760 cells
1761}
1762
1763fn older_notify(row: &str, column: Column) -> &'static str {
1765 use Column::*;
1766 let hours = matches!(column, Work | Admin | Leisure | Meals);
1767 let held = matches!(column, Sleep | Pause | Free);
1768 match row {
1769 "codes" => if matches!(column, Sleep | Pause) { "never" } else { "now" },
1770 "doses" | "wake" | "time" => "now",
1771 "alarms" | "before" => if matches!(column, Sleep | Free) { "event" } else { "now" },
1772 "day-before" | "dates" => if held { "later" } else { "now" },
1773 "needs" => if held { "never" } else { "now" },
1774 "move" => if hours { "now" } else { "never" },
1775 "work-over" => if column == Work || held { "never" } else { "now" },
1776 "mail" => match column {
1777 Dnd => "list",
1778 _ if hours => "now",
1779 _ => "later",
1780 },
1781 "sites" | "app-automatons" => if hours { "gathered" } else { "later" },
1782 "sites-live" | "app-at-once" => if hours { "now" } else { "later" },
1783 "site-calls" => if hours || column == Slot { "now" } else { "later" },
1784 "app-people" => if column == Dnd { "list" } else { "now" },
1785 _ => "now",
1786 }
1787}
1788
1789pub fn apply(path: &Path, config: &Config, key: &str, value: &SettingValue) -> Result<(), String> {
1800 crate::filelock::with_lock(path, || {
1801 let fresh = if path.exists() { Config::load(path)? } else { config.clone() };
1802 apply_locked(path, &fresh, key, value)
1803 })
1804}
1805
1806pub fn apply_change(path: &Path, key: &str, shown: Option<&SettingValue>, value: &SettingValue) -> Result<(), String> {
1811 let shown: Option<&[String]> = match shown {
1812 Some(SettingValue::Texts(words)) => Some(words),
1813 _ => None,
1814 };
1815 let value = match (value, shown) {
1816 (SettingValue::Texts(words), Some(shown)) if !key.starts_with("reach.") => SettingValue::Texts(words.iter().filter(|w| !shown.contains(w)).cloned().collect()),
1817 _ => value.clone(),
1818 };
1819 apply(path, &Config::default(), key, &value)
1820}
1821
1822fn apply_locked(path: &Path, config: &Config, key: &str, value: &SettingValue) -> Result<(), String> {
1823 let words: &[String] = match value {
1824 SettingValue::Texts(words) => words,
1825 SettingValue::Ints(none) if none.is_empty() => &[],
1826 _ => return Err(format!("{key}: a list of columns expected")),
1827 };
1828 let mut attention = Attention::of(config);
1829 if let Some(rest) = key.strip_prefix("reach.") {
1830 let (channel, person) = rest.split_once('.').unwrap_or(("mail", rest));
1832 let row = Row::read(&format!("{channel}.{person}")).filter(|r| matches!(r, Row::People(_, p) if p.state())).ok_or_else(|| format!("{key}: no such row"))?;
1833 let ticked: Vec<Column> = words.iter().filter_map(|w| Column::read(w)).collect();
1834 for column in [Column::Work, Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause] {
1835 let comes = matches!(attention.cell(row, column), Level::Now | Level::Quiet);
1836 let level = match (ticked.contains(&column), comes) {
1837 (true, true) => continue,
1838 (true, false) if matches!(row, Row::People(Channel::Mail, _)) && matches!(column, Column::Sleep | Column::Pause) => Level::Quiet,
1839 (true, false) => Level::Now,
1840 (false, _) => Level::Later,
1841 };
1842 attention.set(row, column, level)?;
1843 }
1844 return write(path, config, &attention, &[row]);
1845 }
1846 if let Some(id) = key.strip_prefix("attention.").filter(|id| is_source(id)) {
1848 for text in words {
1849 if let Some(name) = text.strip_prefix(NAME) {
1850 attention.name_source(id, name);
1851 continue;
1852 }
1853 let (column, level) = word(text).ok_or_else(|| format!("{key}: {text}: not understood"))?;
1854 attention.set_source(id, column, level)?;
1855 }
1856 return write_with(path, config, &attention, &[], &[id.to_string()]);
1857 }
1858 let rest = key.strip_prefix("attention.").or_else(|| key.strip_prefix("notify.")).ok_or_else(|| format!("{key}: no such row"))?;
1859 let row = Row::read(rest).ok_or_else(|| format!("{key}: no such row"))?;
1860 for text in words {
1861 let (column, level) = word(text).ok_or_else(|| format!("{key}: {text}: not understood"))?;
1862 attention.set(row, column, level)?;
1863 }
1864 write(path, config, &attention, &[row])
1865}
1866
1867pub fn apply_preset(path: &Path, config: &Config, preset: Preset) -> Result<(), String> {
1870 crate::filelock::with_lock(path, || {
1871 let fresh = if path.exists() { Config::load(path)? } else { config.clone() };
1872 let matrix = preset.matrix();
1873 write(path, &fresh, &matrix, &Row::ALL)
1874 })
1875}
1876
1877fn write(path: &Path, config: &Config, attention: &Attention, rows: &[Row]) -> Result<(), String> {
1880 write_with(path, config, attention, rows, &[])
1881}
1882
1883fn write_with(path: &Path, config: &Config, attention: &Attention, rows: &[Row], sources: &[String]) -> Result<(), String> {
1886 let usual = Attention::usual();
1887 let first = config.attention.is_none();
1888 let rows: Vec<Row> = if first { Row::ALL.to_vec() } else { rows.to_vec() };
1889 let mut said: Vec<(String, Option<Vec<String>>)> = rows
1890 .iter()
1891 .filter(|r| !matches!(r, Row::People(_, Person::Blocked)))
1892 .map(|row| {
1893 let same = Column::ALL.iter().all(|c| attention.cell(*row, *c) == usual.cell(*row, *c));
1894 (row.id(), (!same).then(|| attention.words(*row)))
1895 })
1896 .collect();
1897 for id in sources {
1898 let words = attention.source_words(id);
1899 said.push((id.clone(), (!words.is_empty()).then_some(words)));
1900 }
1901 crate::config::set_table(path, "attention", &said, if first { OLDER } else { &[] })
1902}
1903
1904pub const SLOTS_FILE: &str = "slots.toml";
1908
1909#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1913pub struct Slots {
1914 #[serde(default, skip_serializing_if = "Option::is_none")]
1916 pub day: Option<jiff::civil::Date>,
1917 #[serde(default)]
1919 pub slots: Vec<(i64, i64)>,
1920}
1921
1922impl Slots {
1923 pub fn default_path() -> PathBuf {
1924 crate::config::state_dir().join(SLOTS_FILE)
1925 }
1926
1927 pub fn load(path: &Path) -> Slots {
1929 std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
1930 }
1931
1932 pub fn save(&self, path: &Path) -> Result<bool, String> {
1934 if Slots::load(path) == *self && path.exists() {
1935 return Ok(false);
1936 }
1937 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1938 if let Some(parent) = path.parent() {
1939 std::fs::create_dir_all(parent).map_err(fail)?;
1940 }
1941 let fresh = path.with_extension(format!("toml.{}", std::process::id()));
1942 std::fs::write(&fresh, toml::to_string(self).map_err(|e| e.to_string())?).map_err(fail)?;
1943 std::fs::rename(&fresh, path).map_err(fail)?;
1944 Ok(true)
1945 }
1946
1947 pub fn at(&self, at: &jiff::Zoned) -> bool {
1949 self.day == Some(at.date()) && self.holds(at.timestamp().as_second())
1950 }
1951
1952 pub fn holds(&self, stamp: i64) -> bool {
1954 self.slots.iter().any(|&(from, to)| from <= stamp && stamp < to)
1955 }
1956
1957 pub fn edges(&self) -> Vec<i64> {
1959 let mut edges: Vec<i64> = self.slots.iter().flat_map(|&(from, to)| [from, to]).collect();
1960 edges.sort_unstable();
1961 edges.dedup();
1962 edges
1963 }
1964}
1965
1966pub fn dnd_from_files(config: &Config, now: i64) -> bool {
1970 use crate::everywhere::{Sources, Switch};
1971 let switch = Switch::load(&Switch::default_path());
1972 let focus = crate::timelog::running().and_then(|r| crate::everywhere::focus(&r, now));
1973 let sources = Sources { focus, ..Sources::default() };
1974 crate::everywhere::now(&config.dnd, &sources, switch.latest().as_ref(), now).gates()
1975}
1976
1977#[derive(Debug, Clone, PartialEq, Serialize)]
1981pub struct Named {
1982 pub id: String,
1983 pub label: String,
1984}
1985
1986#[derive(Debug, Clone, PartialEq, Serialize)]
1988pub struct Mark {
1989 pub id: String,
1990 pub mark: String,
1991 pub label: String,
1992}
1993
1994#[derive(Debug, Clone, PartialEq, Serialize)]
1998pub struct GridRow {
1999 pub id: String,
2000 pub label: String,
2001 pub group: String,
2002 pub group_id: String,
2004 pub help: String,
2005 pub choices: Vec<Mark>,
2006 pub cells: Vec<GridCell>,
2007 pub changed: bool,
2009}
2010
2011#[derive(Debug, Clone, PartialEq, Serialize)]
2014pub struct GridCell {
2015 pub column: String,
2016 pub value: String,
2017 pub choices: Vec<String>,
2018 #[serde(skip_serializing_if = "String::is_empty")]
2019 pub locked: String,
2020 #[serde(skip_serializing_if = "std::ops::Not::not")]
2021 pub changed: bool,
2022 pub said: String,
2023}
2024
2025#[derive(Debug, Clone, PartialEq, Serialize)]
2027pub struct PresetView {
2028 pub id: String,
2029 pub label: String,
2030 pub current: bool,
2032 pub changes: usize,
2034}
2035
2036#[derive(Debug, Clone, PartialEq, Serialize)]
2040pub struct Grid {
2041 pub columns: Vec<Named>,
2042 pub layers: Vec<String>,
2044 pub marks: Vec<Mark>,
2045 pub rows: Vec<GridRow>,
2046 pub presets: Vec<PresetView>,
2047 pub changed: String,
2049}
2050
2051pub fn column_label(tr: &Translator, column: Column) -> String {
2053 tr.text(&format!("attention-column-{}", column.id()), None)
2054}
2055
2056pub fn row_label(tr: &Translator, row: Row) -> String {
2058 match row {
2059 Row::People(_, person) => tr.text(&format!("attention-person-{}", person.id()), None),
2060 Row::Own(kind) => tr.text(&format!("attention-row-{}", kind.id()), None),
2061 }
2062}
2063
2064pub fn level_label(tr: &Translator, row: Row, level: Level) -> String {
2067 let own = match (row, level) {
2068 (Row::Own(Kind::Codes), Level::Never) => Some("attention-level-never-codes"),
2069 (Row::People(_, Person::Always), Level::Now) => Some("attention-level-now-always"),
2070 (Row::People(Channel::Mail, _), Level::Never) => Some("attention-level-never-mail"),
2071 (Row::People(Channel::Calls, _), Level::Later) => Some("attention-level-later-calls"),
2072 _ => None,
2073 };
2074 tr.text(own.unwrap_or(&format!("attention-level-{}", level.id())), None)
2075}
2076
2077pub fn grid(attention: &Attention, tr: &Translator, rows: &[Row], preset: Preset) -> Grid {
2081 let compared = preset.matrix();
2082 let columns = Column::ALL.iter().map(|c| Named { id: c.id().to_string(), label: column_label(tr, *c) }).collect();
2083 let marks = Level::ALL.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(false).to_string(), label: tr.text(&format!("attention-level-{}", l.id()), None) }).collect();
2084 let rows: Vec<GridRow> = rows
2085 .iter()
2086 .map(|row| {
2087 let mut offered: Vec<Level> = Vec::new();
2088 for column in Column::ALL {
2089 for level in choices(*row, column) {
2090 if !offered.contains(level) {
2091 offered.push(*level);
2092 }
2093 }
2094 }
2095 offered.sort();
2096 let label = row_label(tr, *row);
2097 let group = tr.text(&format!("attention-group-{}", row.group()), None);
2098 let cells: Vec<GridCell> = Column::ALL
2099 .iter()
2100 .map(|column| {
2101 let value = attention.cell(*row, *column);
2102 let locked = lock(*row, *column).map(|id| tr.text(id, None)).unwrap_or_default();
2103 let mut args = crate::i18n::args();
2104 args.set("row", if matches!(row, Row::People(..)) { format!("{group}, {label}") } else { label.clone() });
2105 args.set("column", column_label(tr, *column));
2106 args.set("value", level_label(tr, *row, value));
2107 let said = tr.text("attention-cell", Some(&args));
2108 GridCell {
2109 column: column.id().to_string(),
2110 value: value.id().to_string(),
2111 choices: choices(*row, *column).iter().map(|l| l.id().to_string()).collect(),
2112 changed: value != compared.cell(*row, *column),
2113 said: if locked.is_empty() { said } else { format!("{said}. {locked}") },
2114 locked,
2115 }
2116 })
2117 .collect();
2118 let help = match row {
2119 Row::People(_, person) => tr.text(&format!("attention-person-{}-help", person.id()), None),
2120 Row::Own(kind) => tr.text(&format!("attention-row-{}-help", kind.id()), None),
2121 };
2122 GridRow {
2123 id: row.id(),
2124 group: group.clone(),
2125 group_id: row.group().to_string(),
2126 help,
2127 choices: offered.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(row.always()).to_string(), label: level_label(tr, *row, *l) }).collect(),
2128 changed: cells.iter().any(|c| c.changed),
2129 label,
2130 cells,
2131 }
2132 })
2133 .collect();
2134 let presets: Vec<PresetView> = Preset::ALL
2135 .iter()
2136 .map(|p| {
2137 let changes = attention.changes_from(&p.matrix());
2138 PresetView { id: p.id().to_string(), label: tr.text(&format!("attention-preset-{}", p.id()), None), current: changes == 0, changes }
2139 })
2140 .collect();
2141 let from_usual = attention.changes_from(&Attention::usual());
2142 let changed = if from_usual == 0 { String::new() } else { tr.text("attention-changes", Some(&tr.counted(from_usual))) };
2143 Grid {
2144 columns,
2145 layers: vec![Column::Slot.id().to_string(), Column::Dnd.id().to_string()],
2146 marks,
2147 rows,
2148 presets,
2149 changed,
2150 }
2151}
2152
2153pub fn channel_rows(channel: Channel) -> Vec<Row> {
2155 persons(channel).iter().map(|p| Row::People(channel, *p)).collect()
2156}
2157
2158pub fn kind_rows() -> Vec<Row> {
2160 Kind::ALL.iter().map(|k| Row::Own(*k)).collect()
2161}
2162
2163#[derive(Debug, Clone, PartialEq, Eq)]
2167pub struct SourceLine {
2168 pub id: String,
2169 pub label: String,
2170 pub group: String,
2171 pub group_id: String,
2172}
2173
2174pub fn source_grid(attention: &Attention, tr: &Translator, lines: &[SourceLine]) -> Grid {
2179 let columns = Column::ALL.iter().map(|c| Named { id: c.id().to_string(), label: column_label(tr, *c) }).collect();
2180 let label = |level: Level| tr.text(&format!("attention-source-{}", level.id()), None);
2181 let choices: Vec<Mark> = SOURCE_CHOICES.iter().map(|l| Mark { id: l.id().to_string(), mark: l.mark(false).to_string(), label: label(*l) }).collect();
2182 let offered: Vec<String> = SOURCE_CHOICES.iter().map(|l| l.id().to_string()).collect();
2183 let rows = lines
2184 .iter()
2185 .map(|line| {
2186 let set = attention.source(&line.id).unwrap_or([Level::As; 9]);
2187 let cells: Vec<GridCell> = Column::ALL
2188 .iter()
2189 .map(|column| {
2190 let value = set[column.index()];
2191 let mut args = crate::i18n::args();
2192 args.set("row", line.label.clone());
2193 args.set("column", column_label(tr, *column));
2194 args.set("value", label(value));
2195 GridCell {
2196 column: column.id().to_string(),
2197 value: value.id().to_string(),
2198 choices: offered.clone(),
2199 locked: String::new(),
2200 changed: value != Level::As,
2201 said: tr.text("attention-cell", Some(&args)),
2202 }
2203 })
2204 .collect();
2205 GridRow {
2206 id: line.id.clone(),
2207 label: line.label.clone(),
2208 group: line.group.clone(),
2209 group_id: line.group_id.clone(),
2210 help: tr.text("attention-source-help", None),
2211 choices: choices.clone(),
2212 changed: cells.iter().any(|c| c.changed),
2213 cells,
2214 }
2215 })
2216 .collect();
2217 Grid {
2218 columns,
2219 layers: vec![Column::Slot.id().to_string(), Column::Dnd.id().to_string()],
2220 marks: choices,
2221 rows,
2222 presets: Vec::new(),
2223 changed: String::new(),
2224 }
2225}
2226
2227pub fn source_sentence(attention: &Attention, tr: &Translator, id: &str) -> String {
2232 let Some(cells) = attention.source(id) else { return tr.text("attention-source-usual", None) };
2233 let and = tr.text("word-and", None);
2234 let mut parts: Vec<String> = [Level::Now, Level::Gathered, Level::Later]
2235 .iter()
2236 .filter_map(|level| {
2237 let columns: Vec<String> = Column::ALL.iter().filter(|c| cells[c.index()] == *level).map(|c| column_label(tr, *c)).collect();
2238 let columns = match columns.as_slice() {
2239 [] => return None,
2240 [one] => one.clone(),
2241 [rest @ .., last] => format!("{} {and} {last}", rest.join(", ")),
2242 };
2243 let mut args = crate::i18n::args();
2244 args.set("columns", columns);
2245 Some(tr.text(&format!("attention-source-said-{}", level.id()), Some(&args)))
2246 })
2247 .collect();
2248 if cells.contains(&Level::As) {
2249 parts.push(tr.text("attention-source-said-as", None));
2250 }
2251 let mut args = crate::i18n::args();
2252 args.set("parts", parts.join(&tr.text("attention-source-said-join", None)));
2253 let said = tr.text("attention-source-said", Some(&args));
2254 let mut letters = said.chars();
2255 letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
2256}
2257
2258pub fn list_choice(tr: &Translator, who: Who, attention: &Attention, one: bool) -> String {
2262 let times: Vec<Column> = if who == Who::Blocked {
2263 Vec::new()
2264 } else {
2265 Column::TIMES.iter().copied().filter(|c| *c != Column::Free).filter(|c| matches!(attention.cell(Row::People(Channel::Mail, Person::of(who)), *c), Level::Now | Level::Quiet)).collect()
2266 };
2267 let words = if times.len() == 6 {
2268 tr.text("reach-any", None)
2269 } else if times.is_empty() {
2270 tr.text("reach-never", None)
2271 } else {
2272 times.iter().map(|c| tr.text(&format!("reach-word-{}", c.id()), None)).collect::<Vec<_>>().join(", ")
2273 };
2274 let mut args = crate::i18n::args();
2275 args.set("list", tr.text(&format!("sender-{}-{}", if one { "one" } else { "list" }, who.id()), None));
2276 args.set("times", words);
2277 tr.text("sender-list-times", Some(&args))
2278}
2279
2280#[cfg(test)]
2281mod tests {
2282 use super::*;
2283 use crate::areas::Area;
2284 use crate::quiet::Reason;
2285
2286 fn mode(time: Time, reason: Reason) -> Mode {
2287 Mode { quiet: !(time.works() || time == Time::Any), time, week: Week { work_hours: true, admin_hours: true, meals: true, sleep: true }, reason, until: None, back: None, label: String::new() }
2288 }
2289
2290 fn modes() -> Vec<Mode> {
2292 vec![
2293 mode(Time::Work, Reason::Working),
2294 mode(Time::Work, Reason::WorkingLate),
2295 mode(Time::Admin, Reason::AdminTime),
2296 mode(Time::Several(Area::MIXED), Reason::Working),
2297 mode(Time::Leisure, Reason::Evening),
2298 mode(Time::Leisure, Reason::DayOff),
2299 mode(Time::Leisure, Reason::DoneForTheDay),
2300 mode(Time::Meals, Reason::Meal),
2301 mode(Time::Any, Reason::NoHours),
2302 mode(Time::Sleep, Reason::Sleep),
2303 mode(Time::Sleep, Reason::WindingDown),
2304 mode(Time::Sleep, Reason::Nap),
2305 mode(Time::Leisure, Reason::FreeTime),
2306 mode(Time::Sleep, Reason::Paused),
2307 ]
2308 }
2309
2310 fn row(id: &str) -> Row {
2311 Row::read(id).unwrap_or_else(|| panic!("{id}"))
2312 }
2313
2314 #[test]
2318 fn the_usual_matrix_as_decided() {
2319 let expected = [
2320 ("mail.always", "●●●●◑◑●●●"),
2321 ("mail.safe", "●●●●◑◑◑◑◑"),
2322 ("mail.neutral", "●●○○○○○◑◑"),
2323 ("mail.restricted", "●○○○○○○◑◑"),
2324 ("mail.stranger", "●●○○○○○◑◑"),
2325 ("mail.blocked", "–––––––––"),
2326 ("calls.always", "●●●●●●●●●"),
2327 ("calls.safe", "●●●●○○●●○"),
2328 ("calls.neutral", "●●●●○○○●○"),
2329 ("calls.restricted", "●○○○○○○●○"),
2330 ("calls.stranger", "○○○○○○○●○"),
2331 ("calls.hidden", "●●○○○○○●○"),
2332 ("calls.blocked", "–––––––––"),
2333 ("messages.always", "●●●●●●●●●"),
2334 ("messages.safe", "●●●●○○●●○"),
2335 ("messages.neutral", "●●●●○○○●○"),
2336 ("messages.restricted", "●○○○○○○●○"),
2337 ("messages.stranger", "●●○○○○○●○"),
2338 ("messages.groups", "●●○○○○○●○"),
2339 ("messages.blocked", "–––––––––"),
2340 ("codes", "●●●●●●●●●"),
2341 ("doses", "●●●●●●●●●"),
2342 ("wake", "●●●●●●●●●"),
2343 ("alarms", "●●●●◐●◐●●"),
2344 ("before", "●●●●◐●◐●●"),
2345 ("day-before", "●●●●○○○●●"),
2346 ("dates", "●●●●○○○●●"),
2347 ("needs", "●●●●–––●●"),
2348 ("move", "●●●●–––––"),
2349 ("work-over", "–●●●–––●●"),
2350 ("time", "●●●●●●●●●"),
2351 ("sites", "◎◎◎◎○○○○○"),
2352 ("sites-live", "●●●●○○○○○"),
2353 ("site-calls", "●●●●○○○●○"),
2354 ("app-automatons", "◎◎◎◎○○○○○"),
2355 ("app-at-once", "●●●●○○○○○"),
2356 ];
2357 let usual = Attention::usual();
2358 assert_eq!(expected.len(), Row::ALL.len());
2359 for (id, marks) in expected {
2360 let r = row(id);
2361 let said: String = Column::ALL.iter().map(|c| usual.cell(r, *c).mark(false)).collect();
2362 assert_eq!(said, marks, "{id}");
2363 for column in Column::ALL {
2364 assert!(choices(r, column).contains(&usual.cell(r, column)), "{id} {column:?}");
2366 }
2367 assert_eq!(r.id(), id, "ids both ways");
2368 }
2369 assert_eq!(Attention::of(&Config::default()), usual, "an empty configuration is the usual matrix");
2370 assert_eq!(Row::read("calls.groups"), None, "calls have no groups' row");
2371 assert_eq!(Row::read("Day_Before"), Some(Row::Own(Kind::DayBefore)));
2372 }
2373
2374 #[test]
2375 fn fixed_cells_and_choices() {
2376 let mut a = Attention::usual();
2377 assert!(a.set(row("doses"), Column::Dnd, Level::Later).is_err(), "a dose comes during do-not-disturb");
2378 assert!(a.set(row("doses"), Column::Sleep, Level::Never).is_err(), "a dose is never dropped");
2379 assert!(a.set(row("doses"), Column::Pause, Level::Later).is_ok());
2380 assert!(a.set(row("codes"), Column::Pause, Level::Never).is_err(), "a code in a pause comes, fixed");
2381 assert!(a.set(row("codes"), Column::Sleep, Level::Never).is_ok(), "On the Porch only stays a choice for sleep");
2382 assert!(a.set(row("alarms"), Column::Pause, Level::Later).is_err() && a.set(row("alarms"), Column::Dnd, Level::Later).is_err());
2383 assert!(a.set(row("mail.blocked"), Column::Work, Level::Now).is_err(), "the blocked never");
2384 assert!(a.set(row("mail.always"), Column::Work, Level::Through).is_err(), "☆ is a layer's");
2385 assert!(a.set(row("mail.always"), Column::Dnd, Level::Through).is_ok());
2386 assert!(a.set(row("calls.safe"), Column::Sleep, Level::Quiet).is_err(), "a phone cannot show a call it holds");
2387 assert!(a.set(row("mail.neutral"), Column::Leisure, Level::Quiet).is_ok());
2388 assert!(a.set(row("before"), Column::Pause, Level::Event).is_err(), "a pause has no known end");
2389 for r in Row::ALL {
2390 for column in Column::ALL {
2391 assert!(!choices(r, column).is_empty(), "{r:?} {column:?}");
2392 if lock(r, column).is_some() {
2393 assert_eq!(choices(r, column).first(), Some(&usual(r, column)), "{r:?} {column:?}");
2394 }
2395 }
2396 }
2397 }
2398
2399 #[test]
2400 fn rows_from_the_configuration_and_their_words() {
2401 let config: Config = toml::from_str(
2402 "[attention]\n\"mail.neutral\" = [\"leisure\", \"Repas:quiet\", \"sleep:never\", \"nonsense\", \"work:gathered\"]\n\
2403 codes = \"sleep:never, pause:never\"\n\
2404 \"calls.groups\" = [\"work\"]\n\
2405 \"messages.always\" = [\"dnd:through\", \"sleep:as\"]\n\
2406 day_before = [\"free\"]\n",
2407 )
2408 .unwrap();
2409 let a = Attention::of(&config);
2410 assert_eq!(a.words(row("mail.neutral")), ["work", "admin", "leisure", "meals:quiet", "sleep:never", "pause:later", "free:later", "slot:quiet", "dnd:quiet"]);
2412 assert_eq!((a.cell(row("codes"), Column::Sleep), a.cell(row("codes"), Column::Pause)), (Level::Never, Level::Now));
2413 assert_eq!((a.cell(row("messages.always"), Column::Dnd), a.cell(row("messages.always"), Column::Sleep)), (Level::Through, Level::As));
2414 assert_eq!(a.cell(row("day-before"), Column::Free), Level::Now);
2415 let odd: Config = toml::from_str("[attention]\n\"mail.safe\" = 3\n").unwrap();
2417 assert_eq!(Attention::of(&odd), Attention::usual());
2418 assert_eq!(word("Temps libre:quiet"), Some((Column::Free, Level::Quiet)));
2419 assert_eq!((word("sleep:soon"), word("night-shift")), (None, None));
2420 }
2421
2422 #[test]
2423 fn times_together_layers_and_lending() {
2424 let a = Attention::usual();
2425 let any = Now::of(&mode(Time::Any, Reason::NoHours));
2427 assert_eq!(any.times, vec![Column::Work, Column::Admin]);
2428 assert_eq!(a.level(row("mail.restricted"), &any), Level::Now);
2429 let work = Now::time(Column::Work).layers(true, false);
2431 assert_eq!((a.level(row("site-calls"), &work), a.level(row("sites-live"), &work)), (Level::Now, Level::Later));
2432 assert_eq!(a.level(row("site-calls"), &work.clone().layers(true, true)), Level::Later);
2433 let mut admin_only = Attention::usual();
2435 admin_only.set(row("mail.restricted"), Column::Admin, Level::Now).unwrap();
2436 admin_only.set(row("mail.restricted"), Column::Work, Level::Later).unwrap();
2437 let mut no_admin = Now::time(Column::Work);
2438 no_admin.week.admin_hours = false;
2439 assert_eq!((admin_only.level(row("mail.restricted"), &no_admin), admin_only.level(row("mail.restricted"), &Now::time(Column::Work))), (Level::Now, Level::Later));
2440 let mut kinds = Attention::usual();
2441 kinds.set(row("dates"), Column::Work, Level::Later).unwrap();
2442 assert_eq!(kinds.level(row("dates"), &no_admin), Level::Later, "kinds are never lent");
2443 assert_eq!(Now::of(&mode(Time::Sleep, Reason::Paused)).times, vec![Column::Pause]);
2445 assert_eq!(Now::of(&mode(Time::Leisure, Reason::FreeTime)).times, vec![Column::Free]);
2446 assert_eq!(Now::of(&mode(Time::Several(Area::ADMIN), Reason::AdminTime)).times, vec![Column::Admin]);
2447 }
2448
2449 #[test]
2450 fn always_through_reads_their_own_row_as_said() {
2451 let mut a = Attention::usual();
2452 let leisure = Now::time(Column::Leisure);
2453 assert_eq!((a.person(Channel::Mail, Person::Neutral, true, &leisure), a.person(Channel::Mail, Person::Neutral, false, &leisure)), (Level::Now, Level::Later));
2455 a.set(row("mail.always"), Column::Leisure, Level::As).unwrap();
2457 assert_eq!(a.person(Channel::Mail, Person::Neutral, true, &leisure), Level::Later);
2458 assert_eq!(a.person(Channel::Mail, Person::Safe, true, &leisure), Level::Now);
2459 a.set(row("mail.always"), Column::Dnd, Level::Through).unwrap();
2461 let dnd = Now::time(Column::Work).layers(false, true);
2462 assert_eq!((a.person(Channel::Mail, Person::Restricted, true, &dnd), a.person(Channel::Mail, Person::Restricted, false, &dnd)), (Level::Now, Level::Quiet));
2463 let dnd_leisure = leisure.clone().layers(false, true);
2464 assert_eq!(a.person(Channel::Mail, Person::Neutral, true, &dnd_leisure), Level::Later, "☆ at their own row's times");
2465 for channel in Channel::ALL {
2467 assert_eq!(a.person(channel, Person::Blocked, true, &leisure), Level::Never);
2468 }
2469 let mut free = Now::of(&mode(Time::Leisure, Reason::FreeTime));
2471 free.nothing = true;
2472 let usual = Attention::usual();
2473 assert_eq!((usual.person(Channel::Calls, Person::Safe, false, &free), usual.person(Channel::Calls, Person::Safe, true, &free)), (Level::Later, Level::Now));
2474 }
2475
2476 fn shown_before(who: Who, time: Time, paused: bool, free: bool) -> bool {
2480 if time == Time::Any && !paused && !free {
2481 return true;
2482 }
2483 let ticks: [bool; 6] = match who {
2484 Who::Safe => [true; 6],
2485 Who::Neutral | Who::Stranger => [true, true, false, false, false, false],
2486 Who::Restricted => [true, false, false, false, false, false],
2487 Who::Blocked => [false; 6],
2488 };
2489 if paused {
2490 return ticks[5];
2491 }
2492 if free {
2493 return who == Who::Safe && ticks[2];
2494 }
2495 match time {
2496 Time::Work => ticks[0],
2497 Time::Admin => ticks[1],
2498 Time::Leisure => ticks[2],
2499 Time::Meals => ticks[3],
2500 Time::Sleep => ticks[4],
2501 Time::Several(_) | Time::Any => ticks[0] || ticks[1],
2502 }
2503 }
2504
2505 fn told_before(who: Who, listed: bool, m: &Mode, slot: bool, dnd: bool) -> bool {
2507 let cell_now = !(m.sleeps() || m.paused() || m.free()) && !slot;
2508 let grid = shown_before(who, m.time, m.paused(), m.free());
2509 if !cell_now {
2510 return false;
2511 }
2512 if dnd { grid && listed } else { grid }
2513 }
2514
2515 #[test]
2516 fn the_usual_matrix_decides_as_the_code_did_but_where_decided() {
2517 let a = Attention::usual();
2518 for m in modes() {
2519 for (slot, dnd) in [(false, false), (true, false), (false, true), (true, true)] {
2520 let now = Now::of(&m).layers(slot, dnd);
2521 let said = format!("{:?} {:?} slot {slot} dnd {dnd}", m.time, m.reason);
2522 for who in [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger] {
2523 let mail = a.decide(&Event::of(Source::Mail { who, always: false, lane: Lane::Usual }), &now);
2525 assert_eq!(mail.shown, shown_before(who, m.time, m.paused(), m.free()), "shown {who:?} {said}");
2526 assert_eq!(mail.told, told_before(who, false, &m, slot, dnd), "told {who:?} {said}");
2527 let listed = a.decide(&Event::of(Source::Mail { who, always: true, lane: Lane::Usual }), &now);
2530 assert!(!told_before(who, true, &m, slot, dnd) || listed.told, "nothing told before is held now: {who:?} {said}");
2531 assert_eq!(listed.told, !(m.sleeps() || m.paused()), "listed {who:?} {said}");
2532 assert_eq!(listed.pierce, listed.told, "Always through mail passes the system's do-not-disturb {said}");
2533 }
2534 let comes = |kind: Kind| a.decide(&Event::own(kind), &now).told;
2536 assert!(comes(Kind::Codes), "codes {said}");
2537 assert!(comes(Kind::Doses) && comes(Kind::Wake) && comes(Kind::Time), "{said}");
2538 let quiet_ish = m.sleeps() || m.free() || m.paused();
2539 assert_eq!(comes(Kind::Move), !quiet_ish && !slot && !dnd, "move {said}");
2540 assert_eq!(comes(Kind::Needs), !quiet_ish, "needs {said}");
2541 assert_eq!(comes(Kind::SitesLive), !quiet_ish && !slot && !dnd, "sites live {said}");
2542 assert_eq!(comes(Kind::SiteCalls), !quiet_ish && !dnd, "site calls {said}");
2543 }
2544 }
2545 }
2546
2547 #[test]
2548 fn messages_calls_and_the_decided_changes() {
2549 let a = Attention::usual();
2550 let night = Now::of(&mode(Time::Sleep, Reason::Sleep));
2551 let paused = Now::of(&mode(Time::Sleep, Reason::Paused));
2552 let free = Now::of(&mode(Time::Leisure, Reason::FreeTime));
2553 let message = |who: Who, always: bool, now: &Now| a.decide(&Event::of(Source::Message { via: Channel::Messages, who, group: false, always }), now);
2554 assert!(!message(Who::Safe, false, &night).told && !message(Who::Safe, false, &paused).told);
2556 assert!(message(Who::Safe, false, &free).told);
2557 assert!(message(Who::Neutral, true, &night).told && message(Who::Neutral, true, &paused).told);
2559 assert_eq!(message(Who::Blocked, true, &night).step, Step::Blocked);
2561 let call = |who: Option<Who>, always: bool, now: &Now| a.decide(&Event::of(Source::Call { who, always, emergency: false, repeat: false }), now);
2562 assert!(call(Some(Who::Blocked), true, &Now::time(Column::Work)).level == Level::Never);
2563 assert!(call(Some(Who::Stranger), true, &night).told, "on the list, at any time");
2564 let dnd = Now::time(Column::Leisure).layers(false, true);
2566 assert!(!call(Some(Who::Safe), false, &dnd).told && call(Some(Who::Safe), true, &dnd).told);
2567 let mut through = night.clone();
2569 through.through = true;
2570 assert!(call(Some(Who::Stranger), false, &through).told && !call(Some(Who::Blocked), false, &through).told);
2571 let repeat = a.decide(&Event::of(Source::Call { who: None, always: false, emergency: false, repeat: true }), &night);
2572 assert!(!repeat.told, "hidden numbers cannot be told apart: no second call");
2573 assert!(a.decide(&Event::of(Source::Call { who: Some(Who::Blocked), always: false, emergency: true, repeat: false }), &night).told, "an emergency number rings, even blocked");
2574 let mail_app = a.decide(&Event::of(Source::Message { via: Channel::Mail, who: Who::Safe, group: false, always: false }), &night);
2576 assert_eq!((mail_app.level, mail_app.told), (Level::Later, false));
2577 let mut groups = Attention::usual();
2579 groups.set(row("messages.groups"), Column::Work, Level::Later).unwrap();
2580 let work = Now::time(Column::Work);
2581 let group = groups.decide(&Event::of(Source::Message { via: Channel::Messages, who: Who::Safe, group: true, always: false }), &work);
2582 let stranger = groups.decide(&Event::of(Source::Message { via: Channel::Messages, who: Who::Stranger, group: false, always: false }), &work);
2583 assert!(!group.told && stranger.told);
2584 let mut cells = Attention::usual();
2586 cells.set(row("mail.neutral"), Column::Free, Level::Now).unwrap();
2587 assert!(cells.decide(&Event::of(Source::Mail { who: Who::Neutral, always: false, lane: Lane::Usual }), &free).told);
2588 let mut held = Attention::usual();
2590 held.set(row("mail.neutral"), Column::Work, Level::Later).unwrap();
2591 held.set(row("mail.neutral"), Column::Admin, Level::Later).unwrap();
2592 assert!(!held.decide(&Event::of(Source::Mail { who: Who::Neutral, always: false, lane: Lane::Usual }), &Now::of(&mode(Time::Any, Reason::NoHours))).shown);
2593 let site = |named: Option<Who>, now: &Now| a.decide(&Event::of(Source::Site { call: false, live: true, named }), now);
2595 assert!(site(None, &Now::time(Column::Leisure)).told);
2596 assert!(site(Some(Who::Neutral), &Now::time(Column::Leisure)).told, "someone of your address books comes in your leisure");
2597 assert!(!site(Some(Who::Neutral), &Now::time(Column::Sleep)).told, "and waits while you sleep");
2598 assert!(site(Some(Who::Safe), &Now::time(Column::Leisure)).told);
2599 assert_eq!(site(Some(Who::Blocked), &Now::time(Column::Work)).level, Level::Never);
2600 }
2601
2602 fn message(from: &str, headers: &str, subject: &str, senders: &crate::porch::Senders) -> crate::porch::Triaged {
2604 let raw = format!("From: {from}\r\n{headers}Subject: {subject}\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\n{subject}.\r\n");
2605 let known = crate::porch::SenderList::default();
2606 let trusted = ["mx.example.net".to_string()];
2607 let own = ["me@example.net".to_string()];
2608 let ctx = crate::porch::Context { projects: None, known: &known, senders, trusted_ids: &trusted, now: None, priority: Default::default(), own_domains: &[], shielded: false, assessments: None, words: None, own_addresses: &own, spam: None };
2609 crate::porch::triage(crate::card::Card::from_bytes(raw.as_bytes()).unwrap(), &ctx)
2610 }
2611
2612 #[test]
2614 fn mail_shows_by_who_wrote_and_where_it_came() {
2615 use crate::porch::{SenderList, Senders};
2616 let usual = Attention::usual();
2617 let senders = Senders { safe: SenderList::parse("jane@example.org"), restricted: SenderList::parse("*@company.example"), ..Senders::default() };
2618 let set = Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
2619 let nobody = crate::everywhere::People::default();
2620 let at = |time: Time, week: Week| Now::of(&Mode { quiet: false, time, week, reason: if time == Time::Sleep { Reason::Sleep } else { Reason::Working }, until: None, back: None, label: String::new() });
2621 let comes_in = |t: &crate::porch::Triaged, matrix: &Attention, account: Area, time: Time, week: Week| matrix.mail(t, &senders, &nobody, account, &at(time, week)).shown;
2622 let comes = |t: &crate::porch::Triaged, matrix: &Attention, account: Area, time: Time| comes_in(t, matrix, account, time, set);
2623 let at_times = |t: &crate::porch::Triaged, matrix: &Attention, account: Area| Time::STATES.into_iter().filter(|time| comes(t, matrix, account, *time)).map(Time::id).collect::<Vec<_>>();
2624 let jane = message("Jane <jane@example.org>", "", "Hello", &senders);
2626 assert!(Time::STATES.into_iter().all(|time| comes(&jane, &usual, Area::WORK, time)));
2627 let mut no_sleep = Attention::usual();
2628 no_sleep.set(row("mail.safe"), Column::Sleep, Level::Later).unwrap();
2629 assert!(!comes(&jane, &no_sleep, Area::WORK, Time::Sleep) && comes(&jane, &no_sleep, Area::WORK, Time::Meals));
2630 let stranger = message("Someone <someone@elsewhere.example>", "", "A question", &senders);
2632 assert_eq!(senders.who_of(&stranger.card), Who::Stranger);
2633 assert_eq!(at_times(&stranger, &usual, Area::WORK), ["work"]);
2634 assert_eq!(at_times(&stranger, &usual, Area::PERSONAL), ["admin"]);
2635 assert_eq!(at_times(&stranger, &usual, Area::LEISURE), ["work", "admin"]);
2637 let unproven = message("Jane <jane@example.org>", "Authentication-Results: mx.example.net; spf=fail smtp.mailfrom=example.org; dkim=fail header.d=example.org\r\n", "Hello", &senders);
2639 assert_eq!(at_times(&unproven, &usual, Area::WORK), ["work"]);
2640 let mut no_strangers = Attention::usual();
2642 for column in [Column::Work, Column::Admin] {
2643 no_strangers.set(row("mail.stranger"), column, Level::Later).unwrap();
2644 }
2645 assert_eq!(at_times(&stranger, &no_strangers, Area::WORK), Vec::<&str>::new());
2646 let boss = message("Boss <boss@company.example>", "", "Monday", &senders);
2647 assert_eq!(at_times(&boss, &usual, Area::PERSONAL), ["work"], "restricted to work, writing to a personal address");
2648 assert_eq!(at_times(&boss, &usual, Area::WORK), ["work"]);
2649 let mut admin_only = Attention::usual();
2651 admin_only.set(row("mail.stranger"), Column::Work, Level::Later).unwrap();
2652 assert!(comes_in(&stranger, &admin_only, Area::MIXED, Time::Work, Week { admin_hours: false, ..set }));
2653 assert!(!comes_in(&stranger, &admin_only, Area::MIXED, Time::Work, set));
2654 assert!(comes_in(&boss, &usual, Area::WORK, Time::Admin, Week { work_hours: false, ..set }));
2655 let code = message("Bank <codes@bank.example>", "", "Your verification code: 482913", &senders);
2657 assert_eq!(code.lane, crate::porch::Lane::RightNow);
2658 assert!(comes(&code, &no_strangers, Area::WORK, Time::Sleep));
2659 let mine = message("Me <me@example.net>", "Authentication-Results: mx.example.net; dmarc=pass header.from=example.net\r\n", "A file", &senders);
2660 assert!(mine.reasons.contains(&crate::porch::Reason::FromYourself), "{:?}", mine.reasons);
2661 assert!(comes(&mine, &no_strangers, Area::WORK, Time::Sleep));
2662 assert!(comes(&stranger, &usual, Area::LEISURE, Time::Any));
2663 let forged = message("Jane <jane@example.org>", "Authentication-Results: mx.example.net; dmarc=fail (p=reject) header.from=example.org\r\n", "Hello", &senders);
2665 assert_eq!(forged.lane, crate::porch::Lane::SetAside);
2666 assert!(!comes(&forged, &usual, Area::PERSONAL, Time::Leisure) && comes(&forged, &usual, Area::PERSONAL, Time::Admin));
2667 assert!(!comes(&forged, &no_strangers, Area::PERSONAL, Time::Admin), "on the strangers' row");
2668 let mut list = crate::everywhere::People::default();
2670 list.add(crate::everywhere::Person { name: "Someone".into(), emails: vec!["someone@elsewhere.example".into(), "jane@example.org".into()], ..Default::default() }, None);
2671 let leisure = at(Time::Leisure, set);
2672 let listed = usual.mail(&stranger, &senders, &list, Area::WORK, &leisure);
2673 assert!(listed.shown && listed.told && listed.pierce);
2674 assert!(!usual.mail(&forged, &senders, &list, Area::PERSONAL, &leisure).shown);
2675 }
2676
2677 #[test]
2679 fn declined_calls_are_listed_by_their_calls_row() {
2680 let a = Attention::usual();
2681 let (work, admin, leisure) = (Now::time(Column::Work), Now::time(Column::Admin), Now::time(Column::Leisure));
2682 assert!(a.listed(Person::Neutral, false, &work) && a.listed(Person::Neutral, false, &admin) && a.listed(Person::Neutral, false, &leisure));
2684 assert!(!a.listed(Person::Neutral, false, &Now::time(Column::Sleep)));
2685 assert!(a.listed(Person::Safe, false, &leisure) && a.listed(Person::Stranger, true, &Now::time(Column::Sleep)));
2687 assert!(!a.listed(Person::Blocked, true, &work));
2688 assert!(a.listed(Person::Stranger, false, &work) && a.listed(Person::Stranger, false, &admin) && !a.listed(Person::Stranger, false, &leisure));
2690 assert!(!a.listed(Person::Stranger, false, &work.clone().layers(false, true)));
2691 assert!(!a.listed(Person::Safe, false, &Now::time(Column::Sleep)));
2693 assert!(a.listed(Person::Restricted, false, &work) && !a.listed(Person::Restricted, false, &admin));
2694 let mut ringing = Attention::usual();
2696 ringing.set(row("calls.stranger"), Column::Leisure, Level::Now).unwrap();
2697 assert!(ringing.listed(Person::Stranger, false, &leisure) && !ringing.listed(Person::Stranger, false, &work));
2698 }
2699
2700 #[test]
2701 fn areas_and_holds() {
2702 let a = Attention::usual();
2703 let evening = Now::of(&mode(Time::Leisure, Reason::Evening));
2704 let work_mail = |who: Who| a.decide(&Event::of(Source::Mail { who, always: false, lane: Lane::Usual }).for_area(Some(Area::WORK)), &Now::of(&mode(Time::Admin, Reason::AdminTime)));
2705 assert_eq!((work_mail(Who::Stranger).step, work_mail(Who::Safe).told), (Step::Area, true));
2707 assert_eq!(a.decide(&Event::own(Kind::Dates).for_area(Some(Area::WORK)), &evening).step, Step::Area);
2709 assert!(a.decide(&Event::own(Kind::Dates), &evening).told);
2710 let mut night = Now::of(&mode(Time::Sleep, Reason::Sleep));
2712 night.span = (1_000, 2_000);
2713 assert!(a.decide(&Event::own(Kind::Alarms).starting(Some(1_500)), &night).told);
2714 assert!(!a.decide(&Event::own(Kind::Alarms).starting(Some(2_500)), &night).told);
2715 let mut rests = Now::time(Column::Work);
2717 rests.holds.porch_rests = true;
2718 let resting = a.decide(&Event::of(Source::Mail { who: Who::Safe, always: false, lane: Lane::Usual }), &rests);
2719 assert_eq!((resting.shown, resting.told, resting.step), (true, false, Step::Hold));
2720 let mut meeting = Now::time(Column::Work);
2722 meeting.holds.meeting = true;
2723 assert_eq!(a.decide(&Event::own(Kind::Needs), &meeting).step, Step::Hold);
2724 let code = a.decide(&Event::of(Source::Mail { who: Who::Stranger, always: false, lane: Lane::Code }), &Now::of(&mode(Time::Sleep, Reason::Sleep)));
2726 assert!(code.shown && code.told && code.pierce);
2727 let own = a.decide(&Event::of(Source::Mail { who: Who::Safe, always: false, lane: Lane::Own }), &Now::time(Column::Work));
2728 assert!(own.shown && !own.told);
2729 }
2730
2731 #[test]
2732 fn what_each_mode_lets_through() {
2733 let a = Attention::usual();
2734 let phone = Phone { screens: true };
2735 let pause = a.silence(&[Column::Pause], false, phone);
2737 assert_eq!((pause.calls, pause.messages, pause.repeat, pause.conversations, pause.doses, pause.events), (Senders::Contacts, Senders::Starred, true, true, true, true));
2738 assert_eq!(a.silence(&[Column::Work], false, phone).messages, Senders::Starred);
2741 let free = a.silence(&[Column::Free], false, phone);
2743 assert_eq!(free.calls, Senders::Contacts);
2744 assert_eq!(a.silence(&[Column::Free], false, Phone::default()).calls, Senders::Starred, "without screening, starred only, as before");
2745 let nothing = a.silence(&[Column::Free], true, Phone::default());
2747 assert_eq!(nothing.calls, Senders::Starred);
2748 assert!(a.silence(&[Column::Pause], false, phone).events);
2750 let mut none = Attention::usual();
2752 for channel in Channel::ALL {
2753 for column in Column::ALL {
2754 none.set(Row::People(channel, Person::Always), column, Level::As).unwrap();
2755 }
2756 }
2757 let shut = none.silence(&[Column::Pause], false, phone);
2758 assert_eq!((shut.calls, shut.repeat, shut.conversations), (Senders::None, false, false));
2759 for (columns, nothing) in [(&[Column::Pause][..], false), (&[Column::Free][..], true), (&[Column::Leisure, Column::Dnd][..], false)] {
2762 let mut now = Now::time(Column::Leisure);
2763 now.times = columns.iter().copied().filter(|c| c.is_time()).collect();
2764 now.dnd = columns.contains(&Column::Dnd);
2765 now.nothing = nothing;
2766 assert_eq!(none.silence_at(&now, phone).calls, Senders::None, "{columns:?}");
2767 now.through = true;
2768 for phone in [phone, Phone::default()] {
2769 let through = none.silence_at(&now, phone);
2770 assert_eq!((through.calls, through.repeat, through.messages), (Senders::Anyone, true, Senders::None), "{columns:?}");
2771 }
2772 }
2773 let mut loose = Attention::usual();
2776 loose.set(row("calls.stranger"), Column::Dnd, Level::Now).unwrap();
2777 assert_eq!(loose.silence(&[Column::Dnd], false, phone).calls, Senders::Anyone, "the layer by itself");
2778 assert_eq!(loose.silence(&[Column::Leisure, Column::Dnd], false, phone).calls, Senders::Contacts, "over leisure, where strangers wait");
2779 let mut hidden = Attention::usual();
2781 hidden.set(row("calls.hidden"), Column::Admin, Level::Later).unwrap();
2782 assert_eq!(hidden.silence(&[Column::Admin], false, phone).calls, Senders::Contacts);
2783 assert_eq!(hidden.silence(&[Column::Work, Column::Admin], false, phone).calls, Senders::Anyone);
2784 assert_eq!(hidden.silence(&[Column::Work, Column::Admin, Column::Dnd], false, phone).calls, Senders::Contacts);
2785 let mut lent = Attention::usual();
2787 lent.set(row("calls.hidden"), Column::Work, Level::Later).unwrap();
2788 let mut work = Now::time(Column::Work);
2789 assert_eq!(lent.silence_at(&work, phone).calls, Senders::Contacts);
2790 work.week.admin_hours = false;
2791 assert_eq!(lent.silence_at(&work, phone).calls, Senders::Anyone);
2792 }
2793
2794 #[test]
2795 fn older_keys_seed_what_was_changed_and_nothing_else() {
2796 assert_eq!(seeded(&Config::default()), Attention::usual());
2798 let config: Config = toml::from_str(
2801 "[reach]\nsafe = [\"work\", \"admin\", \"leisure\", \"meals\", \"sleep\", \"pause\"]\nneutral = [\"work\", \"admin\", \"leisure\"]\nrestricted = [\"work\"]\nstranger = [\"work\", \"admin\"]\n\
2802 [reach.calls]\nstranger = [\"work\"]\n\
2803 [notify]\nmail = [\"dnd:list-any\"]\ncodes = [\"pause:never\"]\nsites = [\"work:later\"]\n\
2804 [reminders]\ndoses_in_sleep = false\n",
2805 )
2806 .unwrap();
2807 let a = Attention::of(&config);
2808 assert_eq!(a.cell(row("mail.neutral"), Column::Leisure), Level::Now);
2809 assert_eq!(a.cell(row("mail.always"), Column::Dnd), Level::Now);
2810 assert_eq!(a.cell(row("doses"), Column::Sleep), Level::Later);
2811 assert_eq!(a.cell(row("codes"), Column::Pause), Level::Now, "fixed now");
2812 assert_eq!(a.cell(row("calls.stranger"), Column::Work), Level::Now);
2813 assert_eq!(a.cell(row("sites"), Column::Work), Level::Later);
2814 assert_eq!(a.cell(row("messages.safe"), Column::Sleep), Level::Later);
2816 let older: Config = toml::from_str("[reach]\nneutral = [\"work\", \"admin\", \"meals\"]\n").unwrap();
2818 let a = Attention::of(&older);
2819 assert_eq!(a.cell(row("mail.stranger"), Column::Meals), Level::Now);
2820 let nobody: Config = toml::from_str("[dnd]\npeople = false\n").unwrap();
2822 let a = Attention::of(&nobody);
2823 assert_eq!((a.cell(row("calls.always"), Column::Sleep), a.cell(row("mail.always"), Column::Free)), (Level::As, Level::As));
2824 let written: Config = toml::from_str("[dnd]\npeople = false\n[attention]\n").unwrap();
2826 assert_eq!(Attention::of(&written), Attention::usual());
2827 }
2828
2829 #[test]
2830 fn written_whole_seeded_once_and_the_older_keys_taken_out() {
2831 let dir = std::env::temp_dir().join(format!("sioul-attention-{}", std::process::id()));
2832 let _ = std::fs::remove_dir_all(&dir);
2833 std::fs::create_dir_all(&dir).unwrap();
2834 let path = dir.join("config.toml");
2835 std::fs::write(&path, "# Mine.\nlanguage = \"fr\"\n[reach]\nneutral = [\"work\", \"admin\", \"leisure\"]\nstranger = [\"work\", \"admin\"]\n[notify]\nsites = [\"work:later\"]\n[dnd]\nfocus = true\npeople = true\n").unwrap();
2836 let config = Config::load(&path).unwrap();
2837 apply(&path, &config, "attention.codes", &SettingValue::Texts(vec!["sleep:never".into()])).unwrap();
2839 let text = std::fs::read_to_string(&path).unwrap();
2840 assert!(text.contains("# Mine.") && text.contains("[attention]") && !text.contains("[reach]") && !text.contains("[notify]") && !text.contains("people"), "{text}");
2841 assert!(text.contains("focus = true"), "the rest of [dnd] kept: {text}");
2842 let config = Config::load(&path).unwrap();
2843 let a = Attention::of(&config);
2844 assert_eq!((a.cell(row("codes"), Column::Sleep), a.cell(row("mail.neutral"), Column::Leisure), a.cell(row("sites"), Column::Work)), (Level::Never, Level::Now, Level::Later));
2845 assert!(apply(&path, &config, "attention.doses", &SettingValue::Texts(vec!["work:later".into()])).is_err());
2847 assert!(apply(&path, &config, "attention.sites", &SettingValue::Texts(vec!["work".into()])).is_err());
2848 assert!(apply(&path, &config, "attention.mail.safe", &SettingValue::Texts(vec!["teatime".into()])).is_err());
2849 assert!(apply(&path, &config, "attention.mail.hidden", &SettingValue::Texts(vec!["work".into()])).is_err());
2850 apply(&path, &config, "notify.codes", &SettingValue::Texts(vec!["sleep".into()])).unwrap();
2852 let config = Config::load(&path).unwrap();
2853 assert!(config.attention.as_ref().unwrap().words("codes").is_none());
2854 apply(&path, &config, "reach.safe", &SettingValue::Texts(vec!["work".into(), "admin".into(), "sleep".into()])).unwrap();
2856 let a = Attention::of(&Config::load(&path).unwrap());
2857 assert_eq!((a.cell(row("mail.safe"), Column::Leisure), a.cell(row("mail.safe"), Column::Sleep)), (Level::Later, Level::Quiet));
2858 apply(&path, &config, "reach.calls.stranger", &SettingValue::Texts(vec!["work".into()])).unwrap();
2859 assert_eq!(Attention::of(&Config::load(&path).unwrap()).cell(row("calls.stranger"), Column::Work), Level::Now);
2860 apply_preset(&path, &Config::load(&path).unwrap(), Preset::Quieter).unwrap();
2862 assert_eq!(Attention::of(&Config::load(&path).unwrap()), Preset::Quieter.matrix());
2863 let _ = std::fs::remove_dir_all(&dir);
2864 }
2865
2866 #[test]
2868 fn always_through_takes_them_off_the_blocked_list() {
2869 let dir = std::env::temp_dir().join(format!("sioul-unblock-{}", std::process::id()));
2870 let _ = std::fs::remove_dir_all(&dir);
2871 std::fs::create_dir_all(&dir).unwrap();
2872 let blocked = dir.join("blocked.txt");
2873 std::fs::write(&blocked, "alice@example.org\ntel:+33199000001\ncontact:uid-bob\nmallory@example.org\n").unwrap();
2874 let config: Config = toml::from_str(&format!("blocked_senders = \"{}\"\n[contacts]\nregion = \"FR\"\n", blocked.display().to_string().replace('\\', "/"))).unwrap();
2875 assert!(crate::porch::unblock_person(&config, &["Alice <alice@example.org>".into()], &["01 99 00 00 01".into()], "uid-bob").unwrap());
2876 assert_eq!(std::fs::read_to_string(&blocked).unwrap().lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>(), ["mallory@example.org"]);
2877 assert!(!crate::porch::unblock_person(&config, &["carol@example.org".into()], &[], "").unwrap(), "nothing of theirs there");
2878 let _ = std::fs::remove_dir_all(&dir);
2879 }
2880
2881 #[test]
2882 fn slots_are_read_by_every_process() {
2883 let dir = std::env::temp_dir().join(format!("sioul-slots-{}", std::process::id()));
2884 let _ = std::fs::remove_dir_all(&dir);
2885 let path = dir.join(SLOTS_FILE);
2886 let today: jiff::Zoned = "2026-10-07T10:00[Europe/Paris]".parse().unwrap();
2887 let start = today.timestamp().as_second() + 3600;
2888 let slots = Slots { day: Some(today.date()), slots: vec![(start, start + 1800)] };
2889 assert!(slots.save(&path).unwrap());
2890 assert!(!slots.save(&path).unwrap(), "written only when it changed");
2891 let read = Slots::load(&path);
2892 let at = |offset: i64| jiff::Timestamp::from_second(start + offset).unwrap().to_zoned(today.time_zone().clone());
2893 assert!(read.at(&at(60)) && !read.at(&at(-60)) && !read.at(&at(1800)));
2894 assert_eq!(read.edges(), vec![start, start + 1800]);
2895 let old = Slots { day: Some(today.date().yesterday().unwrap()), slots: vec![(start, start + 1800)] };
2897 assert!(!old.at(&at(60)));
2898 assert_eq!(Slots::load(&dir.join("none.toml")), Slots::default());
2899 let _ = std::fs::remove_dir_all(&dir);
2900 }
2901
2902 #[test]
2903 fn presets_never_touch_a_fixed_cell() {
2904 for preset in Preset::ALL {
2905 let matrix = preset.matrix();
2906 for r in Row::ALL {
2907 for column in Column::ALL {
2908 if lock(r, column).is_some() {
2909 assert_eq!(matrix.cell(r, column), usual(r, column), "{preset:?} {r:?} {column:?}");
2910 }
2911 }
2912 }
2913 }
2914 assert!(Preset::Quieter.matrix().changes_from(&Attention::usual()) > 0 && Preset::Reachable.matrix().changes_from(&Attention::usual()) > 0);
2915 }
2916
2917 #[test]
2918 fn the_grid_in_both_languages() {
2919 for language in ["en", "fr"] {
2920 let tr = Translator::new(language);
2921 let mut a = Attention::usual();
2922 a.set(row("mail.neutral"), Column::Leisure, Level::Now).unwrap();
2923 let grid = grid(&a, &tr, &Row::ALL, Preset::Usual);
2924 assert_eq!((grid.columns.len(), grid.rows.len(), grid.marks.len(), grid.presets.len()), (9, 36, 8, 3));
2925 assert!(grid.rows.iter().find(|r| r.id == "mail.neutral").unwrap().changed && !grid.presets[0].current && grid.presets[0].changes == 1);
2926 assert!(!grid.changed.is_empty());
2927 let words = grid
2928 .columns
2929 .iter()
2930 .map(|c| c.label.clone())
2931 .chain(grid.marks.iter().map(|m| m.label.clone()))
2932 .chain(grid.presets.iter().map(|p| p.label.clone()))
2933 .chain([grid.changed.clone()])
2934 .chain(grid.rows.iter().flat_map(|r| [r.label.clone(), r.group.clone(), r.help.clone()].into_iter().chain(r.choices.iter().map(|c| c.label.clone())).chain(r.cells.iter().flat_map(|c| [c.locked.clone(), c.said.clone()]))));
2935 for said in words {
2936 assert!(!said.starts_with("attention-") && !said.contains('{') && !said.contains("sorte"), "{language}: {said}");
2937 if language == "fr" {
2938 for mark in [" :", " ;", " !", " ?", "« ", " »", "'"] {
2939 assert!(!said.contains(mark), "{language}: typography in {said:?}");
2940 }
2941 }
2942 }
2943 let codes = grid.rows.iter().find(|r| r.id == "codes").unwrap();
2944 assert!(codes.cells[4].locked.is_empty() && !codes.cells[5].locked.is_empty(), "sleep free, the pause fixed");
2945 let always = grid.rows.iter().find(|r| r.id == "mail.always").unwrap();
2946 assert_eq!(always.choices.iter().find(|c| c.id == "now").unwrap().mark, "★");
2947 assert!(!list_choice(&tr, Who::Neutral, &a, true).contains('{'));
2948 }
2949 assert_eq!(Translator::new("fr").text("attention-column-free", None), "Temps libre");
2950 assert_eq!(list_choice(&Translator::new("en"), Who::Neutral, &Attention::usual(), false), "Neutral: work, admin");
2951 assert_eq!(list_choice(&Translator::new("en"), Who::Safe, &Attention::usual(), false), "Safe: any time");
2952 }
2953
2954 #[test]
2959 fn a_source_s_row_is_read_over_its_usual_row() {
2960 let app = "app.com.example.chat".to_string();
2961 let talk = "conversation.0123456789abcdef".to_string();
2962 let both = vec![talk.clone(), app.clone()];
2963 let message = |who: Who, group: bool| Event::of(Source::Message { via: Channel::Messages, who, group, always: false });
2964 let at = |column: Column| Now::time(column);
2965 let usual = Attention::usual();
2966 for who in [Who::Safe, Who::Neutral, Who::Restricted, Who::Stranger, Who::Blocked] {
2968 for group in [false, true] {
2969 for column in Column::TIMES {
2970 for (slot, dnd) in [(false, false), (true, false), (false, true)] {
2971 let now = at(column).layers(slot, dnd);
2972 assert_eq!(usual.decide(&message(who, group).from_sources(both.clone()), &now), usual.decide(&message(who, group), &now), "{who:?} {group} {column:?}");
2973 }
2974 }
2975 }
2976 }
2977 let mut a = Attention::usual();
2979 for column in Column::ALL {
2980 a.set_source(&app, column, if column == Column::Work { Level::Now } else { Level::Later }).unwrap();
2981 }
2982 let stranger = message(Who::Stranger, false).from_sources(both.clone());
2983 assert_eq!(a.decide(&stranger, &at(Column::Work)), Output { level: Level::Now, shown: true, told: true, pierce: false, step: Step::Chosen });
2984 for column in [Column::Admin, Column::Leisure, Column::Meals, Column::Sleep, Column::Pause, Column::Free] {
2985 assert_eq!(a.decide(&stranger, &at(column)).level, Level::Later, "{column:?}");
2986 }
2987 assert_eq!(a.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Leisure)).level, Level::Later);
2989 assert_eq!(a.decide(&message(Who::Blocked, false).from_sources(both.clone()), &at(Column::Work)).step, Step::Blocked);
2990 assert_eq!(a.decide(&stranger, &at(Column::Work).layers(false, true)).level, Level::Later);
2992 let mut b = Attention::usual();
2994 b.set_source(&app, Column::Work, Level::Later).unwrap();
2995 assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Leisure)), usual.decide(&message(Who::Safe, false), &at(Column::Leisure)));
2996 assert_eq!(b.decide(&message(Who::Stranger, false).from_sources(both.clone()), &at(Column::Leisure)).step, Step::Matrix);
2997 assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), &at(Column::Work)).level, Level::Later);
2998 let mut c = Attention::usual();
3000 c.set_source(&app, Column::Leisure, Level::Now).unwrap();
3001 assert_eq!(c.decide(&stranger, &at(Column::Leisure)).level, Level::Now);
3002 assert_eq!(c.decide(&stranger, &at(Column::Leisure).layers(false, true)).level, Level::Later);
3003 c.set_source(&app, Column::Dnd, Level::Now).unwrap();
3004 assert_eq!(c.decide(&stranger, &at(Column::Leisure).layers(false, true)).level, Level::Now);
3005 let listed = Event::of(Source::Message { via: Channel::Messages, who: Who::Neutral, group: false, always: true }).from_sources(both.clone());
3007 assert_eq!(a.decide(&listed, &at(Column::Sleep)).level, Level::Now);
3008 a.set_source(&talk, Column::Leisure, Level::Now).unwrap();
3010 assert_eq!(a.decide(&stranger, &at(Column::Leisure)).level, Level::Now);
3011 assert_eq!(a.decide(&message(Who::Stranger, false).from_sources(vec![app.clone()]), &at(Column::Leisure)).level, Level::Later);
3012 let mut d = Attention::usual();
3014 d.set_source(&app, Column::Work, Level::Gathered).unwrap();
3015 assert_eq!(d.decide(&Event::own(Kind::AppAutomatons).from_sources(both.clone()), &at(Column::Work)).level, Level::Gathered);
3016 assert_eq!(d.decide(&stranger, &at(Column::Work)).level, Level::Gathered);
3017 assert_eq!(d.decide(&Event::own(Kind::AppAutomatons).from_sources(both.clone()), &at(Column::Leisure)), usual.decide(&Event::own(Kind::AppAutomatons), &at(Column::Leisure)));
3018 let leisure_app = Event::own(Kind::AppAtOnce).for_area(Some(Area::WORK)).from_sources(both.clone());
3020 let mut e = Attention::usual();
3021 e.set_source(&app, Column::Leisure, Level::Now).unwrap();
3022 assert_eq!(e.decide(&leisure_app, &at(Column::Leisure)).step, Step::Chosen);
3023 assert_eq!(usual.decide(&leisure_app, &at(Column::Leisure)).step, Step::Area);
3024 let mut nothing = at(Column::Free);
3026 nothing.nothing = true;
3027 assert_eq!(b.decide(&message(Who::Safe, false).from_sources(both.clone()), ¬hing).level, Level::Later);
3028 e.set_source(&app, Column::Free, Level::Now).unwrap();
3029 assert_eq!(e.decide(&message(Who::Safe, false).from_sources(both.clone()), ¬hing).level, Level::Now);
3030 assert!(a.set_source(&app, Column::Work, Level::Quiet).is_err() && a.set_source("app.", Column::Work, Level::Now).is_err() && a.set_source("mail.safe", Column::Work, Level::Now).is_err());
3032 let mut f = Attention::usual();
3033 f.set_source(&app, Column::Work, Level::Now).unwrap();
3034 f.set_source(&app, Column::Work, Level::As).unwrap();
3035 assert!(f.source(&app).is_none() && f.sources().is_empty() && !f.has_sources(&both));
3036 assert_eq!(source_rows("com.example.chat", "0123456789abcdef"), both);
3037 assert_eq!(source_rows("com.example.chat", " "), vec![app.clone()]);
3038 for language in ["en", "fr"] {
3040 let tr = Translator::new(language);
3041 let lines = vec![SourceLine { id: app.clone(), label: "Chat".into(), group: "Apps".into(), group_id: "app-rows".into() }];
3042 let grid = source_grid(&a, &tr, &lines);
3043 assert_eq!((grid.columns.len(), grid.rows.len(), grid.marks.len(), grid.rows[0].cells.len()), (9, 1, 4, 9));
3044 assert!(grid.rows[0].changed && grid.rows[0].cells.iter().all(|c| c.choices.len() == 4 && c.locked.is_empty()));
3045 let said = source_sentence(&a, &tr, &app);
3046 let usual_said = source_sentence(&Attention::usual(), &tr, &app);
3047 let words = grid.marks.iter().map(|m| m.label.clone()).chain(grid.rows[0].cells.iter().map(|c| c.said.clone())).chain([grid.rows[0].help.clone(), said.clone(), usual_said, source_sentence(&b, &tr, &app)]);
3048 for text in words {
3049 assert!(!text.starts_with("attention-") && !text.contains('{') && !text.contains("sorte"), "{language}: {text}");
3050 if language == "fr" {
3051 for mark in [" :", " ;", " !", " ?", "« ", " »", "'"] {
3052 assert!(!text.contains(mark), "{language}: typography in {text:?}");
3053 }
3054 }
3055 }
3056 }
3057 assert_eq!(source_sentence(&a, &Translator::new("en"), &app), "At once: Work; held: Admin, Leisure, Meals, Sleep, Pause, Free time, Time for you and Do not disturb.");
3058 assert_eq!(source_sentence(&b, &Translator::new("en"), &app), "Held: Work; as usual the rest of the time.");
3059 assert_eq!(source_sentence(&b, &Translator::new("fr"), &app), "Retenues\u{202f}: Travail\u{202f}; comme d’habitude le reste du temps.");
3060 }
3061
3062 #[test]
3065 fn source_rows_written_and_read() {
3066 let dir = std::env::temp_dir().join(format!("sioul-attention-sources-{}", std::process::id()));
3067 let _ = std::fs::remove_dir_all(&dir);
3068 std::fs::create_dir_all(&dir).unwrap();
3069 let path = dir.join("config.toml");
3070 std::fs::write(&path, "[notify]\nsites = [\"work:later\"]\n").unwrap();
3071 let config = Config::load(&path).unwrap();
3072 let words = |w: &[&str]| SettingValue::Texts(w.iter().map(|s| s.to_string()).collect());
3073 apply(&path, &config, "attention.app.com.example.chat", &words(&["work", "admin:later", "leisure:as", "dnd:gathered", "name=Example Chat"])).unwrap();
3075 let text = std::fs::read_to_string(&path).unwrap();
3076 assert!(text.contains("\"app.com.example.chat\" = [\"work\", \"admin:later\", \"dnd:gathered\", \"name=Example Chat\"]") && !text.contains("[notify]"), "{text}");
3077 let a = Attention::of(&Config::load(&path).unwrap());
3078 assert_eq!(a.cell(row("sites"), Column::Work), Level::Later, "seeded from [notify]");
3079 let cells = a.source("app.com.example.chat").unwrap();
3080 assert_eq!((cells[0], cells[1], cells[2], cells[8]), (Level::Now, Level::Later, Level::As, Level::Gathered));
3081 assert_eq!(a.source_name("app.com.example.chat"), "Example Chat");
3082 apply(&path, &Config::load(&path).unwrap(), "attention.codes", &words(&["sleep:never"])).unwrap();
3084 assert!(Attention::of(&Config::load(&path).unwrap()).source("app.com.example.chat").is_some());
3085 assert!(apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["work:quiet"])).is_err());
3087 assert!(apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["teatime"])).is_err());
3088 apply(&path, &Config::load(&path).unwrap(), "attention.app.com.example.chat", &words(&["work:as", "admin:as", "dnd:as"])).unwrap();
3090 let text = std::fs::read_to_string(&path).unwrap();
3091 assert!(!text.contains("example.chat") && Attention::of(&Config::load(&path).unwrap()).sources().is_empty(), "{text}");
3092 assert!(Row::read("app.com.example.chat").is_none() && Row::read("conversation.0123").is_none());
3094 let _ = std::fs::remove_dir_all(&dir);
3095 }
3096}