1use crate::backend::{config_path, load_config};
15use jiff::Zoned;
16use serde_json::{Value, json};
17use sioul_core::attention::{self, Attention, Column, Kind, Level, Now, Person, Phone, Preset, Row, Senders};
18use sioul_core::config::{Config, SettingValue};
19use sioul_core::everywhere::People;
20use sioul_core::i18n::Translator;
21use sioul_core::reach::{Channel, Who};
22
23#[derive(Debug, Clone, Copy, Default)]
27struct Reach {
28 phone: bool,
30 calls: bool,
32 messages: bool,
34 screens: bool,
36 phones: Phones,
38}
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42enum Phones {
43 #[default]
45 None,
46 WithoutAccess,
48 Holding,
51}
52
53impl Phones {
54 fn of(entries: &[sioul_sync::devices::Entry]) -> Phones {
56 let phones: Vec<_> = entries.iter().filter(|d| d.kind == sioul_sync::devices::PHONE && !d.left).collect();
57 if phones.iter().any(|d| d.notifications != Some(false)) {
58 Phones::Holding
59 } else if phones.is_empty() {
60 Phones::None
61 } else {
62 Phones::WithoutAccess
63 }
64 }
65}
66
67fn reach() -> Reach {
68 let phone = cfg!(target_os = "android");
69 let screens = crate::calls::screens_here();
70 let calls = screens || crate::calls::moment()["shown"] == true;
71 let phones = if phone { Phones::None } else { phones_known() };
72 Reach { phone, calls, messages: phone || phones == Phones::Holding, screens, phones }
73}
74
75fn phones_known() -> Phones {
77 let Some((_, Some((folder, key)))) = crate::share::vault() else { return Phones::None };
78 Phones::of(&sioul_sync::devices::all(&folder, &key).0)
79}
80
81fn channels(reach: Reach) -> Vec<Channel> {
83 let mut out = vec![Channel::Mail];
84 if reach.calls {
85 out.push(Channel::Calls);
86 }
87 if reach.messages {
88 out.push(Channel::Messages);
89 }
90 out
91}
92
93fn kinds(phone: bool) -> Vec<Kind> {
96 use Kind::*;
97 let mut out = vec![Codes, Doses];
98 if phone {
99 out.push(Wake);
100 }
101 out.extend([Alarms, Before, DayBefore, Dates, Needs, Move, WorkOver]);
102 if phone {
103 out.extend([AppAutomatons, AppAtOnce]);
104 } else {
105 out.extend([Sites, SitesLive, SiteCalls]);
106 }
107 out
108}
109
110fn shown_rows(phone: bool) -> Vec<Row> {
114 let mut rows: Vec<Row> = Channel::ALL.iter().flat_map(|c| attention::channel_rows(*c)).filter(|r| !attention::screened(*r)).collect();
115 let mut own = kinds(phone);
116 own.push(Kind::Time);
117 rows.extend(Kind::ALL.iter().filter(|k| own.contains(k)).map(|k| Row::Own(*k)));
118 rows
119}
120
121thread_local! {
124 static SPOKEN: std::cell::Cell<Option<&'static Translator>> = const { std::cell::Cell::new(None) };
126}
127
128fn tr() -> &'static Translator {
130 SPOKEN.with(|spoken| spoken.get()).unwrap_or_else(crate::backend::tr)
131}
132
133fn say(id: &str, pairs: &[(&str, String)]) -> String {
135 let mut args = sioul_core::i18n::args();
136 for (key, value) in pairs {
137 args.set(key.to_string(), value.clone());
138 }
139 tr().text(id, Some(&args))
140}
141
142fn text(id: &str) -> String {
143 tr().text(id, None)
144}
145
146fn text_with(id: &str, what: &str, plural: bool, more: &[(&'static str, String)]) -> String {
148 let mut args = sioul_core::i18n::args();
149 args.set("what", what.to_string());
150 args.set("n", if plural { 2 } else { 1 });
151 for (key, value) in more {
152 args.set(*key, value.clone());
153 }
154 capital(&tr().text(id, Some(&args)))
155}
156
157fn capital(text: &str) -> String {
159 let mut chars = text.chars();
160 chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
161}
162
163fn small(text: &str) -> String {
165 let mut chars = text.chars();
166 chars.next().map(|first| first.to_lowercase().chain(chars).collect()).unwrap_or_default()
167}
168
169fn listed(items: &[String], or: bool) -> String {
171 match items {
172 [] => String::new(),
173 [one] => one.clone(),
174 [rest @ .., last] => format!("{} {} {last}", rest.join(", "), text(if or { "attention-or" } else { "word-and" })),
175 }
176}
177
178fn gathered_times(config: &Config) -> String {
180 let times: Vec<String> = sioul_core::appnotes::times_of(&config.reminders.gathered_times()).iter().map(|t| t.strftime("%H:%M").to_string()).collect();
181 listed(×, false)
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum Subject {
187 People(Channel, Person),
188 Own(Kind),
189}
190
191fn who_words(persons: &[Person], everyone: bool, rest: bool) -> String {
194 if everyone {
195 return text(if rest { "attention-who-everyone-else" } else { "attention-who-everyone" });
196 }
197 let known: Vec<String> = persons.iter().filter(|p| matches!(p, Person::Safe | Person::Restricted)).map(|p| text(&format!("attention-who-kind-{}", p.id()))).collect();
199 let mut items = Vec::new();
200 if !known.is_empty() {
201 let mut args = sioul_core::i18n::args();
202 args.set("kinds", listed(&known, false));
203 items.push(tr().text("attention-who-senders", Some(&args)));
204 }
205 for person in persons.iter().filter(|p| matches!(p, Person::Neutral | Person::Stranger | Person::Hidden | Person::Groups | Person::Always)) {
206 items.push(text(&format!("attention-who-{}", person.id())));
207 }
208 listed(&items, false)
209}
210
211fn mail_plural() -> bool {
214 tr().attribute("attention-of-mail", "plural").is_some_and(|p| p == "yes")
215}
216
217fn channels_words(channels: &[Channel]) -> String {
219 listed(&channels.iter().map(|c| text(&format!("attention-of-{}", c.id()))).collect::<Vec<_>>(), false)
220}
221
222fn from_words(channels: &[Channel], who: &str) -> String {
224 let mut args = sioul_core::i18n::args();
225 args.set("channels", channels_words(channels));
226 args.set("who", who.to_string());
227 tr().text("attention-from", Some(&args))
228}
229
230fn people_words(subjects: &[(Channel, Person)], said: &mut Vec<(Channel, Person)>) -> Option<(String, bool)> {
235 let mut by_channel: Vec<(Channel, Vec<Person>)> = Vec::new();
236 for (channel, person) in subjects {
237 match by_channel.iter_mut().find(|(c, _)| c == channel) {
238 Some((_, persons)) => persons.push(*person),
239 None => by_channel.push((*channel, vec![*person])),
240 }
241 }
242 if by_channel.is_empty() {
243 return None;
244 }
245 let mut worded: Vec<(Vec<Channel>, String)> = Vec::new();
247 for (channel, persons) in &by_channel {
248 let all: Vec<Person> = matrix_persons(*channel);
249 let before: Vec<Person> = said.iter().filter(|(c, _)| c == channel).map(|(_, p)| *p).collect();
250 let fresh = persons.iter().all(|p| !before.contains(p));
251 let everyone = persons.iter().all(|p| *p != Person::Always) && all.iter().all(|p| persons.contains(p) || before.contains(p)) && fresh;
252 let who = who_words(persons, everyone, everyone && !before.is_empty());
253 match worded.iter_mut().find(|(_, w)| *w == who) {
254 Some((channels, _)) => channels.push(*channel),
255 None => worded.push((vec![*channel], who)),
256 }
257 }
258 for (channel, persons) in &by_channel {
259 said.extend(persons.iter().map(|p| (*channel, *p)));
260 }
261 let parts: Vec<String> = worded.iter().map(|(channels, who)| from_words(channels, who)).collect();
262 let plural = parts.len() > 1 || worded.iter().any(|(channels, _)| channels.len() > 1 || channels[0] != Channel::Mail || mail_plural());
263 Some((listed(&parts, false), plural))
264}
265
266fn matrix_persons(channel: Channel) -> Vec<Person> {
269 attention::persons(channel).iter().copied().filter(|p| p.state() && !attention::screened(Row::People(channel, *p))).collect()
270}
271
272fn kinds_words(subjects: &[Kind]) -> Option<(String, bool)> {
275 let reminders = [Kind::Alarms, Kind::Before, Kind::DayBefore, Kind::Dates];
276 let together = reminders.iter().all(|k| subjects.contains(k));
277 let mut items: Vec<(String, bool)> = Vec::new();
278 for kind in subjects {
279 if together && reminders.contains(kind) {
280 if *kind == Kind::Alarms {
281 items.push((text("attention-what-reminders"), true));
282 }
283 continue;
284 }
285 let singular = matches!(kind, Kind::Wake | Kind::DayBefore | Kind::Move | Kind::WorkOver);
286 items.push((text(&format!("attention-what-{}", kind.id())), !singular));
287 }
288 if items.is_empty() {
289 return None;
290 }
291 let plural = items.len() > 1 || items[0].1;
292 Some((listed(&items.into_iter().map(|(w, _)| w).collect::<Vec<_>>(), false), plural))
293}
294
295fn subjects_words(subjects: &[Subject], said: &mut Vec<(Channel, Person)>) -> Vec<(String, bool)> {
299 let people: Vec<(Channel, Person)> = subjects.iter().filter_map(|s| if let Subject::People(c, p) = s { Some((*c, *p)) } else { None }).collect();
300 let own: Vec<Kind> = subjects.iter().filter_map(|s| if let Subject::Own(k) = s { Some(*k) } else { None }).collect();
301 [people_words(&people, said), kinds_words(&own)].into_iter().flatten().collect()
302}
303
304fn comes_in(attention: &Attention, row: Row) -> Vec<Column> {
308 Column::TIMES.iter().copied().filter(|c| matches!(attention.cell(row, *c), Level::Now | Level::Quiet | Level::Gathered | Level::Event)).collect()
309}
310
311fn until_words(column: Column, comes: &[Column]) -> String {
314 let waking = [Column::Work, Column::Admin, Column::Leisure, Column::Meals].iter().all(|c| comes.contains(c));
315 match column {
316 Column::Sleep if waking => return text("attention-until-wake"),
317 Column::Pause if waking => return text("attention-until-back"),
318 Column::Free if waking => return text("attention-until-free"),
319 Column::Meals if [Column::Work, Column::Admin, Column::Leisure].iter().all(|c| comes.contains(c)) => return text("attention-until-meal"),
320 Column::Slot => return text("attention-until-slot"),
321 Column::Dnd => return text("attention-until-dnd"),
322 _ => {}
323 }
324 let times: Vec<String> = comes.iter().filter(|c| matches!(c, Column::Work | Column::Admin | Column::Leisure | Column::Meals)).map(|c| text(&format!("attention-until-{}", c.id()))).collect();
325 if times.is_empty() {
326 return text("attention-until-none");
327 }
328 let mut args = sioul_core::i18n::args();
329 args.set("times", listed(×, true));
330 tr().text("attention-until-times", Some(&args))
331}
332
333fn card_lines(attention: &Attention, config: &Config, column: Column, reach: Reach) -> Vec<String> {
338 let layer = !column.is_time();
339 let mut lines = Vec::new();
340 let mut said: Vec<(Channel, Person)> = Vec::new();
341 let chans = channels(reach);
342 let mut always: Vec<(Level, Vec<Channel>)> = Vec::new();
344 for channel in &chans {
345 let level = attention.cell(Row::People(*channel, Person::Always), column);
346 let lists = matrix_persons(*channel).iter().map(|p| attention.cell(Row::People(*channel, *p), column)).max().unwrap_or(Level::Now);
348 if level == Level::As || (level != Level::Through && level >= lists) {
349 continue;
350 }
351 match always.iter_mut().find(|(l, _)| *l == level) {
352 Some((_, channels)) => channels.push(*channel),
353 None => always.push((level, vec![*channel])),
354 }
355 }
356 for (level, channels) in &always {
357 let what = from_words(channels, &text("attention-who-always"));
358 let plural = channels.len() > 1 || channels[0] != Channel::Mail || mail_plural();
359 let line = match level {
360 Level::Through => text_with("attention-card-always-through", "", true, &[]),
361 Level::Now => text_with("attention-card-always-now", &what, plural, &[]),
362 Level::Quiet if layer => text_with("attention-card-layer-quiet", &what, plural, &[]),
363 Level::Quiet => text_with("attention-card-quiet", &what, plural, &[]),
364 _ => continue,
365 };
366 lines.push(line);
367 }
368 type Key = (u8, Vec<Column>);
370 let mut groups: Vec<(Key, Vec<Subject>)> = Vec::new();
371 let mut add = |key: Key, subject: Subject| match groups.iter_mut().find(|(k, _)| *k == key) {
372 Some((_, subjects)) => subjects.push(subject),
373 None => groups.push((key, vec![subject])),
374 };
375 let rank = |level: Level, calls: bool| -> u8 {
377 match level {
378 Level::Now => 0,
379 Level::Event => 1,
380 Level::Gathered => 2,
381 Level::Quiet => 3,
382 Level::Later if calls => 5,
383 Level::Later => 4,
384 _ => 6,
385 }
386 };
387 for channel in &chans {
388 for person in matrix_persons(*channel) {
389 let row = Row::People(*channel, person);
390 let level = attention.cell(row, column);
391 let calls = *channel == Channel::Calls;
392 let until = if level == Level::Later && !calls { comes_in(attention, row) } else { Vec::new() };
393 add((rank(level, calls), until), Subject::People(*channel, person));
394 }
395 }
396 for kind in kinds(reach.phone) {
397 let row = Row::Own(kind);
398 let level = attention.cell(row, column);
399 let until = if level == Level::Later { comes_in(attention, row) } else { Vec::new() };
400 let key = if kind == Kind::Codes && level == Level::Never { 7 } else { rank(level, false) };
402 add((key, until), Subject::Own(kind));
403 }
404 groups.sort_by(|a, b| a.0.cmp(&b.0));
405 for ((rank, until), subjects) in groups {
406 let (mail_never, rest): (Vec<Subject>, Vec<Subject>) = subjects.into_iter().partition(|s| rank == 6 && matches!(s, Subject::People(..)));
408 for (what, plural) in subjects_words(&mail_never, &mut said) {
409 lines.push(text_with("attention-card-never-mail", &what, plural, &[]));
410 }
411 let (shade, rest): (Vec<Subject>, Vec<Subject>) = rest.into_iter().partition(|s| rank == 3 && matches!(s, Subject::People(Channel::Messages, _)));
413 for (what, plural) in subjects_words(&shade, &mut said) {
414 lines.push(text_with("attention-card-shade", &what, plural, &[]));
415 }
416 for (what, plural) in subjects_words(&rest, &mut said) {
417 lines.push(match rank {
418 0 if layer => text_with("attention-card-as-usual", &what, plural, &[]),
419 0 => text_with("attention-card-now", &what, plural, &[]),
420 1 => text_with("attention-card-event", &what, plural, &[]),
421 2 => text_with("attention-card-gathered", &what, plural, &[("times", gathered_times(config))]),
422 3 if layer => text_with("attention-card-layer-quiet", &what, plural, &[]),
423 3 => text_with("attention-card-quiet", &what, plural, &[]),
424 4 => text_with("attention-card-wait", &what, plural, &[("until", until_words(column, &until))]),
425 5 => text_with("attention-card-voicemail", &what, plural, &[]),
426 7 => text_with("attention-card-never-codes", &what, plural, &[]),
427 _ => text_with("attention-card-never", &what, plural, &[]),
428 });
429 }
430 }
431 if chans.contains(&Channel::Calls) {
435 let contacts_wait = attention.cell(Row::People(Channel::Calls, Person::Neutral), column) != Level::Now;
436 lines.push(text(if contacts_wait { "attention-card-screening-held" } else { "attention-card-screening" }));
437 }
438 lines
439}
440
441fn silenced(config: &Config, column: Column) -> bool {
443 match column {
444 Column::Pause | Column::Free => config.dnd.pauses,
445 Column::Sleep => config.dnd.sleep,
446 Column::Dnd => true,
447 _ => false,
448 }
449}
450
451fn system_line(attention: &Attention, config: &Config, column: Column, reach: Reach) -> String {
454 if !silenced(config, column) {
455 return text(if reach.phone { "attention-system-calm-phone" } else { "attention-system-calm-computer" });
456 }
457 if !reach.phone {
458 return text("attention-system-computer");
459 }
460 let hours = matches!(column, Column::Work | Column::Admin);
461 let silence = attention.silence(&[column], false, Phone { screens: reach.screens, unknown: reach.screens && crate::calls::unknown_rings(config, hours) });
462 let senders = |senders: Senders| match senders {
463 Senders::None => None,
464 Senders::Starred => Some(text("attention-senders-starred")),
465 Senders::Contacts => Some(text("attention-senders-contacts")),
466 Senders::Anyone => Some(text("attention-senders-anyone")),
467 };
468 let mut items = Vec::new();
469 match (senders(silence.calls), senders(silence.messages)) {
470 (Some(calls), Some(messages)) if calls == messages => items.push(from_words(&[Channel::Calls, Channel::Messages], &calls)),
471 (calls, messages) => {
472 items.extend(calls.map(|who| from_words(&[Channel::Calls], &who)));
473 items.extend(messages.map(|who| from_words(&[Channel::Messages], &who)));
474 }
475 }
476 if silence.repeat {
477 items.push(text("attention-system-repeat"));
478 }
479 if silence.conversations {
480 items.push(text("attention-system-conversations"));
481 }
482 items.push(text("attention-system-alarms"));
483 if silence.events {
484 items.push(text("attention-system-events"));
485 }
486 if silence.doses {
487 items.push(text("attention-system-doses"));
488 }
489 let mut args = sioul_core::i18n::args();
490 args.set("what", listed(&items, false));
491 tr().text("attention-system-phone", Some(&args))
492}
493
494fn hours_words(config: &Config, kind: &str) -> Option<String> {
497 let windows: Vec<&sioul_core::window::AdminWindow> = config.windows.iter().filter(|w| w.kind() == kind).collect();
498 let mut days: Vec<Vec<(String, String)>> = vec![Vec::new(); 7];
499 for w in &windows {
500 if let Some(day) = w.weekday() {
501 let range = (w.start.trim().to_string(), w.end_text());
502 let slot = &mut days[day.to_monday_zero_offset() as usize];
503 if !slot.contains(&range) {
504 slot.push(range);
505 }
506 }
507 }
508 for slot in &mut days {
509 slot.sort();
510 }
511 let day = |i: usize| text(&format!("weekday-{}", i + 1));
512 let mut parts = Vec::new();
513 let mut at = 0;
514 while at < 7 {
515 if days[at].is_empty() {
516 at += 1;
517 continue;
518 }
519 let mut last = at;
520 while last + 1 < 7 && days[last + 1] == days[at] {
521 last += 1;
522 }
523 let when = match last - at {
524 0 => day(at),
525 1 => listed(&[day(at), day(last)], false),
526 _ => {
527 let mut args = sioul_core::i18n::args();
528 args.set("from", day(at));
529 args.set("to", day(last));
530 tr().text("attention-when-days", Some(&args))
531 }
532 };
533 let ranges: Vec<String> = days[at]
534 .iter()
535 .map(|(from, to)| {
536 let mut args = sioul_core::i18n::args();
537 args.set("from", from.clone());
538 args.set("to", to.clone());
539 tr().text("attention-when-range", Some(&args))
540 })
541 .collect();
542 parts.push(format!("{when}, {}", listed(&ranges, false)));
543 at = last + 1;
544 }
545 let joined = parts.into_iter().reduce(|one, other| say("attention-when-list", &[("one", one), ("other", other)]))?;
547 Some(capital(&joined))
548}
549
550fn when_words(config: &Config, column: Column) -> String {
552 match column {
553 Column::Work => hours_words(config, "work").map(|hours| say("attention-when-hours", &[("hours", hours)])).unwrap_or_else(|| text("attention-when-work-none")),
555 Column::Admin => hours_words(config, "admin").map(|hours| say("attention-when-hours", &[("hours", hours)])).unwrap_or_else(|| text("attention-when-admin-none")),
556 Column::Dnd if config.dnd.focus => text("attention-when-dnd-focus"),
557 other => text(&format!("attention-when-{}", other.id())),
558 }
559}
560
561fn also_lines(column: Column) -> Vec<String> {
564 let ids: &[&str] = match column {
565 Column::Work => &["attention-also-meeting"],
566 Column::Admin => &["attention-also-areas", "attention-also-meeting"],
567 Column::Leisure | Column::Meals => &["attention-also-areas"],
568 Column::Pause => &["attention-also-porch-rests"],
569 _ => &[],
570 };
571 ids.iter().map(|id| text(id)).collect()
572}
573
574fn column_now(now: &Now) -> Column {
576 now.times.first().copied().unwrap_or(Column::Work)
577}
578
579pub(crate) fn view(realtime: bool) -> String {
588 let config = load_config();
589 let attention = crate::hours::attention();
590 let reach = reach();
591 let now = crate::hours::attention_now();
592 let current = column_now(&now);
593 let usual = Attention::usual();
594 let presets: Vec<Value> = Preset::ALL
595 .iter()
596 .map(|p| {
597 let changes = attention.changes_from(&p.matrix());
598 json!({ "id": p.id(), "label": text(&format!("attention-preset-{}", p.id())), "current": changes == 0, "changes": changes })
599 })
600 .collect();
601 let from_usual = attention.changes_from(&usual);
602 let changed = if from_usual == 0 { String::new() } else { tr().text("attention-changes", Some(&tr().counted(from_usual))) };
603 let mut order: Vec<Column> = vec![current];
605 order.extend(Column::TIMES.iter().copied().filter(|c| *c != current));
606 order.extend([Column::Slot, Column::Dnd].iter().filter(|c| **c != current));
607 let cards: Vec<Value> = order
608 .iter()
609 .map(|column| {
610 json!({
611 "id": column.id(),
612 "label": attention::column_label(tr(), *column),
613 "layer": !column.is_time(),
614 "now": *column == current || (column == &Column::Dnd && now.dnd) || (column == &Column::Slot && now.slot),
615 "when": when_words(&config, *column),
616 "lines": card_lines(&attention, &config, *column, reach),
617 "system": system_line(&attention, &config, *column, reach),
618 "also": also_lines(*column),
619 })
620 })
621 .collect();
622 let mut switches = Vec::new();
623 if reach.calls {
624 let moment = crate::calls::moment();
625 let through = moment["through"] == true;
626 let line = moment["line"].as_str().unwrap_or_default().to_string();
627 switches.push(if through && !line.is_empty() { line } else { text("attention-switch-through-off") });
628 }
629 switches.push(text(if realtime { "attention-switch-realtime-on" } else { "attention-switch-realtime-off" }));
630 let channels: Vec<Value> = Channel::ALL.iter().map(|c| json!({ "id": c.id(), "label": text(&format!("attention-group-{}", c.id())), "note": channel_note(*c, reach) })).collect();
631 let rows = shown_rows(reach.phone);
632 let grid = attention::grid(&attention, tr(), &rows, Preset::Usual);
633 json!({
634 "now": now_sentences(&attention, &now, reach),
635 "column": current.id(),
636 "presets": presets,
637 "changed": changed,
638 "cards": cards,
639 "switches": switches,
640 "channels": channels,
641 "rows": rows.iter().map(|r| r.id()).collect::<Vec<_>>(),
642 "grid": grid,
643 "gathered": say("attention-gathered-at", &[("times", gathered_times(&config))]),
644 "mail": { "spam": spam_line(&config), "areas": text("attention-mail-areas") },
645 "exceptions": exceptions(&attention, &config, reach),
646 "phone": reach.phone,
647 "exact": !reach.phone || crate::alarms::exact(),
649 })
650 .to_string()
651}
652
653fn spam_line(config: &Config) -> String {
656 use sioul_core::spam::{Action, Class};
657 let actions = config.spam.actions();
658 let held = [Class::Spam, Class::Unsure].iter().any(|c| actions.of(*c) != Action::Nothing);
659 text(if held { "attention-spam-held" } else { "attention-spam-told" })
660}
661
662pub(crate) fn now_card(realtime: bool) -> String {
671 let attention = crate::hours::attention();
672 let now = crate::hours::attention_now();
673 let mut said = now_sentences(&attention, &now, reach());
674 said.truncate(2);
675 let held = if cfg!(target_os = "android") { crate::appnotes::porch_line() } else { String::new() };
676 let calls = crate::calls::moment();
677 let screening = if calls["shown"] == true { screening_line(&load_config()) } else { String::new() };
678 let talks = crate::appnotes::held_talks();
680 json!({ "now": said, "held": held, "talks": talks, "realtime": realtime, "screening": screening, "calls": calls }).to_string()
681}
682
683fn screening_line(config: &Config) -> String {
685 use sioul_core::screening::{Hours, Rule, Screening};
686 let screening = Screening::of(config);
687 if !screening.on {
688 return text("reach-card-screening-off");
689 }
690 let when = text(match screening.hours {
691 Hours::Always => "reach-card-screening-always",
692 Hours::OutsideWork => "reach-card-screening-outside-work",
693 });
694 let id = match screening.unknown {
695 Rule::Voicemail => "reach-card-screening-voicemail",
696 Rule::Work => "reach-card-screening-work",
697 Rule::Ring => "reach-card-screening-ring",
698 };
699 say(id, &[("when", when)])
700}
701
702fn now_sentences(attention: &Attention, now: &Now, reach: Reach) -> Vec<String> {
703 let mode = crate::hours::mode_now();
704 let column = column_now(now);
705 let time = small(&attention::column_label(tr(), column));
706 let at = Zoned::now();
707 let mut out = vec![match mode.until.as_ref().filter(|_| !mode.paused()) {
708 Some(until) => say("attention-now-time", &[("time", time), ("until", sioul_core::quiet::until_text(tr(), until, &at))]),
709 None => say("attention-now-time-open", &[("time", time)]),
710 }];
711 for (on, layer) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
712 if on {
713 out.push(say("attention-now-layer", &[("layer", attention::column_label(tr(), layer))]));
714 }
715 }
716 if now.nothing {
717 out.push(text("attention-now-nothing"));
718 }
719 let mut groups: Vec<(u8, Vec<Subject>)> = Vec::new();
720 let mut add = |rank: u8, subject: Subject| match groups.iter_mut().find(|(r, _)| *r == rank) {
721 Some((_, subjects)) => subjects.push(subject),
722 None => groups.push((rank, vec![subject])),
723 };
724 for channel in channels(reach) {
725 for person in matrix_persons(channel) {
726 match attention.person(channel, person, false, now) {
727 Level::Now => add(0, Subject::People(channel, person)),
728 Level::Quiet if channel == Channel::Messages => add(3, Subject::People(channel, person)),
730 Level::Quiet => add(2, Subject::People(channel, person)),
731 Level::Never => {}
732 _ => add(5, Subject::People(channel, person)),
733 }
734 }
735 }
736 for kind in [Kind::Codes, Kind::Doses, Kind::Alarms, Kind::Before, Kind::DayBefore, Kind::Dates] {
738 match attention.level(Row::Own(kind), now) {
739 Level::Now | Level::Event => add(0, Subject::Own(kind)),
740 Level::Gathered => add(1, Subject::Own(kind)),
741 Level::Never => {}
742 _ => add(5, Subject::Own(kind)),
743 }
744 }
745 groups.sort_by_key(|(rank, _)| *rank);
746 let mut said = Vec::new();
747 let config = load_config();
748 for (rank, subjects) in groups {
749 for (what, plural) in subjects_words(&subjects, &mut said) {
750 out.push(match rank {
751 0 => text_with("attention-card-now", &what, plural, &[]),
752 1 => text_with("attention-card-gathered", &what, plural, &[("times", gathered_times(&config))]),
753 2 => text_with("attention-card-quiet", &what, plural, &[]),
754 3 => text_with("attention-card-shade", &what, plural, &[]),
755 _ => text_with("attention-now-wait", &what, plural, &[]),
756 });
757 }
758 }
759 out
760}
761
762fn channel_note(channel: Channel, reach: Reach) -> String {
766 match channel {
767 Channel::Calls if !reach.calls => text("attention-channel-calls-none"),
768 Channel::Messages if !reach.messages && reach.phones == Phones::WithoutAccess => text("attention-channel-messages-no-access"),
769 Channel::Messages if !reach.messages => text("attention-channel-messages-none"),
770 Channel::Messages if !reach.phone => text("attention-channel-messages-phone"),
771 _ => String::new(),
772 }
773}
774
775fn exceptions(attention: &Attention, config: &Config, reach: Reach) -> Value {
778 let always: Vec<String> = channels(reach).iter().filter_map(|c| channel_line(attention, *c, Person::Always, true, "attention-always-line")).collect();
779 let sites: Vec<String> = config
780 .accounts
781 .iter()
782 .filter_map(sioul_core::sites::Site::of)
783 .map(|site| {
784 let how = if site.muted {
785 text("attention-site-muted")
786 } else if site.realtime {
787 text("attention-site-live")
788 } else {
789 text("attention-site-gathered")
790 };
791 say("attention-site-line", &[("name", site.name.clone()), ("how", how)])
792 })
793 .collect();
794 json!({ "always": always, "sites": if reach.phone { Vec::new() } else { sites } })
795}
796
797fn channel_line(attention: &Attention, channel: Channel, person: Person, always: bool, id: &str) -> Option<String> {
805 if person == Person::Blocked {
806 return Some(text(&format!("{id}-blocked-{}", channel.id())));
807 }
808 let at = |column: Column| -> Level {
809 let level = if person == Person::Always { attention.cell(Row::People(channel, Person::Always), column) } else { attention.person(channel, person, always, &Now::time(column)) };
811 if channel != Channel::Mail && level == Level::Quiet { Level::Later } else { level }
813 };
814 let mut parts: Vec<(Level, Vec<Column>)> = Vec::new();
815 for column in Column::TIMES {
816 let level = at(column);
817 match parts.iter_mut().find(|(l, _)| *l == level) {
818 Some((_, columns)) => columns.push(column),
819 None => parts.push((level, vec![column])),
820 }
821 }
822 let during = |columns: &[Column]| -> String {
823 if columns.len() == Column::TIMES.len() {
824 return text("attention-during-any");
825 }
826 let mut args = sioul_core::i18n::args();
827 args.set("times", listed(&columns.iter().map(|c| text(&format!("attention-during-{}", c.id()))).collect::<Vec<_>>(), false));
828 tr().text("attention-during", Some(&args))
829 };
830 let mut sentences = Vec::new();
831 for (index, (level, columns)) in parts.iter().enumerate() {
832 let verb = format!("{id}-{}-{}", channel.id(), level_word(*level));
833 let mut args = sioul_core::i18n::args();
834 args.set("during", during(columns));
835 args.set("first", if index == 0 { "yes" } else { "no" });
836 sentences.push(tr().text(&verb, Some(&args)));
837 }
838 let row = Row::People(channel, if always || person == Person::Always { Person::Always } else { person });
840 let held = |layer: Column| match attention.cell(row, layer) {
841 Level::Through | Level::Now | Level::As => None,
842 Level::Quiet if channel == Channel::Calls => Some(Level::Later),
843 other => Some(other),
844 };
845 let layers: Vec<(&str, Level)> = match (held(Column::Slot), held(Column::Dnd)) {
846 (Some(slot), Some(dnd)) if slot == dnd => vec![("both", slot)],
847 (slot, dnd) => [("slot", slot), ("dnd", dnd)].into_iter().filter_map(|(id, level)| level.map(|l| (id, l))).collect(),
848 };
849 for (layer, level) in layers {
850 let mut args = sioul_core::i18n::args();
851 args.set("layer", layer);
852 sentences.push(tr().text(&format!("{id}-layer-{}-{}", channel.id(), level_word(level)), Some(&args)));
853 }
854 Some(sentences.join(" "))
855}
856
857fn level_word(level: Level) -> &'static str {
859 match level {
860 Level::Now | Level::Through => "now",
861 Level::Quiet => "quiet",
862 Level::Never => "never",
863 Level::As => "as",
864 _ => "later",
865 }
866}
867
868pub(crate) fn set_row(row: &str, words: &str, shown: &str) -> String {
873 let words: Vec<String> = serde_json::from_str(words).unwrap_or_default();
874 let shown: Option<Vec<String>> = serde_json::from_str(shown).ok();
876 match attention::apply_change(&config_path(), &format!("attention.{row}"), shown.map(SettingValue::Texts).as_ref(), &SettingValue::Texts(words)) {
877 Ok(()) => String::new(),
878 Err(e) => e,
879 }
880}
881
882pub(crate) fn preset(id: &str) -> String {
885 let Some(preset) = Preset::read(id) else { return format!("{id}: usual, quieter or reachable") };
886 match attention::apply_preset(&config_path(), &load_config(), preset) {
887 Ok(()) => String::new(),
888 Err(e) => e,
889 }
890}
891
892fn card(key: &str) -> Option<sioul_core::contacts::Contact> {
896 let key = key.trim();
897 (!key.is_empty()).then(|| sioul_core::contacts::all().into_iter().find(|c| c.key == key || c.uid.trim() == key)).flatten()
898}
899
900fn addresses_of(text: &str) -> Vec<String> {
902 let text = text.trim();
903 let list: Vec<String> = if text.starts_with('[') { serde_json::from_str(text).unwrap_or_default() } else { vec![text.to_string()] };
904 list.into_iter().map(|a| a.trim().to_ascii_lowercase()).filter(|a| !a.is_empty()).collect()
905}
906
907struct Someone {
911 card: Option<sioul_core::contacts::Contact>,
912 addresses: Vec<String>,
913}
914
915fn region() -> Option<&'static sioul_core::phones::Region> {
917 sioul_core::phones::chosen(load_config().contacts.region.as_deref(), &tr().text("qt-locale", None))
918}
919
920impl Someone {
921 fn of(key: &str, addresses: &str) -> Someone {
922 let addresses = addresses_of(addresses);
923 let card = card(key).or_else(|| {
924 let all = sioul_core::contacts::all();
925 addresses.iter().find_map(|a| match a.strip_prefix(sioul_core::porch::TEL) {
926 Some(number) => {
928 let region = region();
929 let wanted = sioul_core::phones::key(number, region);
930 sioul_core::phones::is_whole(&wanted).then(|| all.iter().find(|c| c.phones.iter().any(|p| sioul_core::phones::key(&p.value, region) == wanted)).cloned()).flatten()
931 }
932 None => sioul_core::contacts::by_address(&all, a).cloned(),
933 })
934 });
935 Someone { card, addresses }
936 }
937
938 fn name(&self) -> String {
939 let first = || {
940 self.addresses.first().map(|a| match a.strip_prefix(sioul_core::porch::TEL) {
941 Some(number) => {
942 let region = region();
943 sioul_core::calls::shown_number(&sioul_core::phones::key(number, region), region)
944 }
945 None => a.clone(),
946 })
947 };
948 self.card.as_ref().map(|c| c.name.trim().to_string()).filter(|n| !n.is_empty()).or_else(first).unwrap_or_default()
949 }
950
951 fn emails(&self) -> Vec<String> {
952 let mut out: Vec<String> = self.card.iter().flat_map(|c| c.emails.iter().map(|e| e.value.trim().trim_start_matches("mailto:").to_ascii_lowercase())).collect();
953 for address in self.addresses.iter().filter(|a| !a.starts_with(sioul_core::porch::TEL)) {
954 if !out.contains(address) {
955 out.push(address.clone());
956 }
957 }
958 out
959 }
960
961 fn phones(&self) -> Vec<String> {
962 let mut out: Vec<String> = self.card.iter().flat_map(|c| c.phones.iter().map(|p| p.value.clone())).collect();
963 out.extend(self.addresses.iter().filter_map(|a| a.strip_prefix(sioul_core::porch::TEL)).map(str::to_string));
964 out
965 }
966
967 fn row_id(&self) -> Option<String> {
971 if let Some(uid) = self.card.as_ref().map(|c| c.uid.trim().to_string()).filter(|u| !u.is_empty()) {
972 let id = attention::person_row(&format!("contact:{uid}"));
973 if attention::is_person(&id) {
974 return Some(id);
975 }
976 let number = self.card.as_ref().and_then(|c| c.phones.iter().map(|p| sioul_core::phones::key(&p.value, region())).find(|k| sioul_core::phones::is_whole(k)));
978 return number.map(|key| attention::person_row(&format!("tel:{key}"))).filter(|id| attention::is_person(id));
979 }
980 let first = self.addresses.first()?;
981 let who = match first.strip_prefix(sioul_core::porch::TEL) {
982 Some(number) => format!("tel:{}", sioul_core::phones::key(number, region())),
983 None => first.clone(),
984 };
985 Some(attention::person_row(&who)).filter(|id| attention::is_person(id) && id != attention::PERSON_ROW && !id.ends_with("tel:"))
986 }
987
988 fn always(&self, config: &Config) -> Option<String> {
990 let people = People::load(&People::default_path());
991 let region = sioul_core::phones::chosen(config.contacts.region.as_deref(), &tr().text("qt-locale", None));
992 let uid = self.card.as_ref().map(|c| c.uid.trim().to_string()).unwrap_or_default();
993 let emails = self.emails();
994 let phones = self.phones();
995 people
996 .people
997 .iter()
998 .find(|p| {
999 (!uid.is_empty() && p.contact == uid)
1000 || emails.iter().any(|e| p.emails.iter().any(|x| x.trim().eq_ignore_ascii_case(e)))
1001 || phones.iter().any(|n| {
1002 let key = sioul_core::phones::key(n, region);
1003 sioul_core::phones::is_whole(&key) && p.phones.iter().any(|x| sioul_core::phones::key(x, region) == key)
1004 })
1005 })
1006 .map(|p| p.id.clone())
1007 }
1008
1009 fn list(&self) -> Value {
1011 match &self.card {
1012 Some(card) => serde_json::from_str(&crate::senders::person_json(&card.key)).unwrap_or(Value::Null),
1013 None => {
1014 let mut said: Value = serde_json::from_str(&crate::senders::standing_json(&json!(self.addresses).to_string())).unwrap_or(Value::Null);
1015 said["who"] = said["standing"].clone();
1016 said["own"] = json!([]);
1017 said
1018 }
1019 }
1020 }
1021}
1022
1023fn own_times(attention: &Attention, someone: &Someone, always: bool, who: Who) -> Value {
1027 let id = someone.row_id();
1028 let note = if always { text("attention-sheet-times-always") } else if id.is_none() { text("attention-sheet-times-none") } else { String::new() };
1029 let set = id.as_deref().and_then(|id| attention.source(id));
1030 let label = |level: Level| match level {
1031 Level::Now => text("attention-level-now"),
1032 Level::Later => text("attention-person-held"),
1033 Level::Quiet => text("attention-level-quiet-messages"),
1034 _ => text("attention-level-as"),
1035 };
1036 let cells: Vec<Value> = if always || id.is_none() || who == Who::Blocked {
1037 Vec::new()
1038 } else {
1039 Column::ALL
1040 .iter()
1041 .enumerate()
1042 .map(|(index, column)| {
1043 let level = set.map_or(Level::As, |cells| cells[index]);
1044 let choices: Vec<Value> = attention::person_choices(*column).iter().map(|l| json!({ "value": l.id(), "label": label(*l) })).collect();
1045 json!({ "column": column.id(), "label": attention::column_label(tr(), *column), "level": level.id(), "choices": choices })
1046 })
1047 .collect()
1048 };
1049 json!({ "title": text("attention-sheet-times"), "help": text("attention-sheet-times-help"), "shown": !cells.is_empty(), "note": note, "cells": cells })
1050}
1051
1052pub(crate) fn person(key: &str, addresses: &str) -> String {
1059 person_with(&Someone::of(key, addresses), String::new())
1060}
1061
1062fn person_with(someone: &Someone, note: String) -> String {
1063 let config = load_config();
1064 let attention = crate::hours::attention();
1065 let list = someone.list();
1066 let who = list["who"].as_str().and_then(Who::read).unwrap_or(Who::Stranger);
1067 let always = someone.always(&config).is_some() && who != Who::Blocked;
1068 let reach = reach();
1069 let lines: Vec<String> = Channel::ALL
1070 .iter()
1071 .filter_map(|channel| {
1072 let line = if *channel == Channel::Calls && !always && attention::screened(Row::People(Channel::Calls, Person::of(who))) { text("attention-sheet-line-calls-screening") } else { channel_line(&attention, *channel, Person::of(who), always, "attention-sheet-line")? };
1074 let missing = match channel {
1075 Channel::Calls if !reach.calls => Some(text("attention-sheet-calls-none")),
1076 Channel::Calls if someone.phones().is_empty() => Some(text("attention-sheet-no-number")),
1077 Channel::Mail if someone.emails().is_empty() => Some(text("attention-sheet-no-address")),
1078 _ => None,
1079 };
1080 Some(match missing {
1081 Some(missing) => format!("{line} {missing}"),
1082 None => line,
1083 })
1084 })
1085 .collect();
1086 json!({
1087 "title": say("attention-sheet-title", &[("name", someone.name())]),
1088 "name": someone.name(),
1089 "card": someone.card.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
1090 "addresses": someone.addresses,
1091 "who": who.id(),
1092 "said": list["said"],
1093 "choice": list["choice"],
1094 "choices": list["choices"],
1095 "own": list["own"],
1096 "always": always,
1097 "always_help": text(if who == Who::Blocked { "attention-sheet-always-blocked" } else if always { "attention-sheet-always-help" } else { "attention-sheet-always-off-help" }),
1098 "lines": lines,
1099 "times": own_times(&attention, someone, always, who),
1100 "note": note,
1101 "calls_title": text("calls-history-title"),
1103 "calls": if who == Who::Blocked { Vec::new() } else { crate::calls::history(&someone.phones()) },
1104 "texts_title": text("phonemsgs-history-title"),
1106 "texts": if who == Who::Blocked { Vec::new() } else { crate::phonemsgs::history(&someone.phones()) },
1107 "texts_id": crate::texts::conversation_of(&someone.phones()),
1109 })
1110 .to_string()
1111}
1112
1113pub(crate) fn person_change(key: &str, addresses: &str, verb: &str, value: &str) -> String {
1118 let someone = Someone::of(key, addresses);
1119 let config = load_config();
1120 let mut said: Vec<String> = Vec::new();
1121 match verb {
1122 "list" => {
1123 let was_always = someone.always(&config).is_some();
1124 let line = match &someone.card {
1125 Some(card) => crate::senders::set_person(&card.key, value),
1126 None => crate::senders::set_standing_of(&json!(someone.addresses).to_string(), value),
1127 };
1128 said.push(line);
1129 if value == "blocked" && was_always {
1132 if let Some(id) = someone.always(&load_config()) {
1133 let _ = crate::everywhere::change("remove", &json!({ "id": id }).to_string());
1134 }
1135 said.push(say("attention-sheet-off-always", &[("name", someone.name())]));
1136 }
1137 }
1138 "always" if value == "on" => {
1139 let blocked = |someone: &Someone| Who::read(someone.list()["who"].as_str().unwrap_or_default()) == Some(Who::Blocked);
1141 let was_blocked = blocked(&someone);
1142 if was_blocked {
1143 let choose = |choice: &str| match &someone.card {
1144 Some(card) => crate::senders::set_person(&card.key, choice),
1145 None => crate::senders::set_standing_of(&json!(someone.addresses).to_string(), choice),
1146 };
1147 choose("");
1148 if blocked(&someone) {
1149 choose("neutral");
1150 }
1151 }
1152 let (answer, _) = match &someone.card {
1153 Some(card) => crate::everywhere::change("add-contact", &json!({ "uid": card.uid }).to_string()),
1154 None => crate::everywhere::change("add", &json!({ "id": "", "name": "", "phones": someone.phones().join("\n"), "emails": someone.emails().join("\n") }).to_string()),
1156 };
1157 said.push(say("attention-sheet-on-always", &[("name", someone.name())]));
1158 if was_blocked {
1159 said.push(say("attention-sheet-off-blocked", &[("name", someone.name())]));
1160 }
1161 said.push(serde_json::from_str::<Value>(&answer).ok().and_then(|a| a["said"].as_str().map(str::to_string)).unwrap_or_default());
1163 }
1164 "always" => {
1165 if let Some(id) = someone.always(&config) {
1166 let _ = crate::everywhere::change("remove", &json!({ "id": id }).to_string());
1167 said.push(say("attention-sheet-off-always", &[("name", someone.name())]));
1168 }
1169 }
1170 "times" => match (someone.row_id(), someone.always(&config).is_some()) {
1172 (Some(id), false) => {
1173 let asked: Value = serde_json::from_str(value).unwrap_or(Value::Null);
1174 let word = format!("{}:{}", asked["column"].as_str().unwrap_or_default(), asked["level"].as_str().unwrap_or_default());
1175 if let Err(e) = attention::apply_change(&config_path(), &format!("attention.{id}"), None, &SettingValue::Texts(vec![word])) {
1176 said.push(e);
1177 }
1178 }
1179 (_, true) => said.push(text("attention-sheet-times-always")),
1180 (None, _) => said.push(text("attention-sheet-times-none")),
1181 },
1182 _ => {}
1183 }
1184 let line: Vec<String> = said.into_iter().filter(|s| !s.is_empty()).collect();
1185 json!({ "sheet": serde_json::from_str::<Value>(&person_with(&Someone::of(key, addresses), line.join(" "))).unwrap_or(Value::Null), "line": line.join(" ") }).to_string()
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191
1192 #[test]
1197 fn a_sheet_sets_their_own_times_with_no_name() {
1198 speak("en");
1199 let card = sioul_core::contacts::Contact { uid: "card-9".into(), key: "/books/card-9.vcf".into(), name: "Noa Example".into(), ..Default::default() };
1200 let someone = Someone { card: Some(card), addresses: Vec::new() };
1201 assert_eq!(someone.row_id().as_deref(), Some("person.contact:card-9"));
1202 assert_eq!(Someone { card: None, addresses: vec!["tel:+33 1 99 00 12 34".into()] }.row_id().as_deref(), Some("person.tel:+33199001234"));
1204 assert_eq!(Someone { card: None, addresses: vec!["noa@example.org".into()] }.row_id().as_deref(), Some("person.noa@example.org"));
1205 let spaced = sioul_core::contacts::Contact { uid: "card 10".into(), phones: vec![sioul_core::contacts::Labeled { label: String::new(), value: "+33 1 99 00 12 35".into() }], ..Default::default() };
1207 assert_eq!(Someone { card: Some(spaced), addresses: Vec::new() }.row_id().as_deref(), Some("person.tel:+33199001235"));
1208 let attention = Attention::usual();
1209 let times = own_times(&attention, &someone, false, Who::Neutral);
1210 let cells = times["cells"].as_array().unwrap();
1211 assert_eq!(cells.len(), 9);
1212 assert!(cells.iter().all(|c| c["level"] == "as"), "{cells:?}");
1213 let offered = |column: &str| cells.iter().find(|c| c["column"] == column).unwrap()["choices"].as_array().unwrap().iter().map(|c| c["value"].as_str().unwrap().to_string()).collect::<Vec<_>>();
1214 assert_eq!(offered("work"), ["now", "later", "as"]);
1215 assert_eq!(offered("dnd"), ["now", "quiet", "later", "as"]);
1216 assert_eq!(own_times(&attention, &someone, true, Who::Neutral)["shown"], false, "Always through: no row of their own");
1217 let id = someone.row_id().unwrap();
1219 let dir = std::env::temp_dir().join(format!("sioul-own-times-{}", std::process::id()));
1220 std::fs::create_dir_all(&dir).unwrap();
1221 let path = dir.join("config.toml");
1222 attention::apply_change(&path, &format!("attention.{id}"), None, &SettingValue::Texts(vec!["work:later".into()])).unwrap();
1223 let written = std::fs::read_to_string(&path).unwrap();
1224 assert!(written.contains("person.contact:card-9") && written.contains("work:later") && !written.contains("Noa"), "{written}");
1225 let read = Attention::of(&Config::load(&path).unwrap());
1226 assert_eq!(own_times(&read, &someone, false, Who::Neutral)["cells"][0]["level"], "later");
1227 let _ = std::fs::remove_dir_all(&dir);
1228 }
1229
1230 #[test]
1232 fn the_card_says_the_call_screening() {
1233 for language in ["en", "fr"] {
1234 speak(language);
1235 for (words, has) in [
1236 ("", "voicemail"),
1237 ("[screening]\nunknown = \"work\"\n", "work"),
1238 ("[screening]\nunknown = \"ring\"\nhours = \"outside-work\"\n", "ring"),
1239 ("[screening]\non = false\n", "off"),
1240 ] {
1241 let config: Config = toml::from_str(words).unwrap();
1242 let line = screening_line(&config);
1243 said_well(&line, language);
1244 assert!(!line.contains("reach-card"), "{language} {has}: {line}");
1245 }
1246 let at_all_hours: Config = toml::from_str("").unwrap();
1247 let outside: Config = toml::from_str("[screening]\nhours = \"outside-work\"\n").unwrap();
1248 assert_ne!(screening_line(&at_all_hours), screening_line(&outside), "{language}: the hours said");
1249 }
1250 }
1251
1252 fn speak(language: &str) {
1254 let translator: &'static Translator = Box::leak(Box::new(Translator::new(language)));
1255 SPOKEN.with(|spoken| spoken.set(Some(translator)));
1256 }
1257
1258 fn said_well(line: &str, language: &str) {
1260 assert!(!line.trim().is_empty(), "{language}: an empty line");
1261 assert!(!line.contains("attention-") && !line.contains('{') && !line.contains('$'), "{language}: {line}");
1262 assert!(line.chars().next().is_some_and(|c| !c.is_lowercase()), "{language}: no capital: {line}");
1263 assert!(line.ends_with('.') || line.ends_with(".)"), "{language}: no full stop: {line}");
1264 assert!(!line.contains("sorte"), "{language}: {line}");
1265 if language == "fr" {
1266 for mark in [" :", " ;", " !", " ?", "« ", " »", "'"] {
1267 assert!(!line.contains(mark), "fr: typography ({mark:?}) in {line}");
1268 }
1269 }
1270 }
1271
1272 fn changed_everywhere() -> Attention {
1274 let mut a = Attention::usual();
1275 for row in Row::ALL {
1276 for column in Column::ALL {
1277 if let Some(level) = attention::choices(row, column).last() {
1278 let _ = a.set(row, column, *level);
1279 }
1280 if row.always() && !column.is_time() {
1281 let _ = a.set(row, column, Level::Through);
1282 }
1283 }
1284 }
1285 a
1286 }
1287
1288 #[test]
1289 fn every_card_and_line_is_said_whole_in_both_languages() {
1290 let mut config = Config::default();
1291 config.dnd.sleep = true;
1292 config.dnd.focus = true;
1293 config.windows = sioul_core::window::parse_ranges("mo-fr 09:00-12:00, 14:00-17:00; sa 10:00-12:00");
1294 for language in ["en", "fr"] {
1295 speak(language);
1296 for attention in [Attention::usual(), Preset::Quieter.matrix(), Preset::Reachable.matrix(), changed_everywhere()] {
1297 for phone in [false, true] {
1298 for (calls, messages) in [(false, false), (true, true)] {
1299 let reach = Reach { phone, calls, messages, screens: phone && calls, ..Reach::default() };
1300 for column in Column::ALL {
1301 let mut lines = card_lines(&attention, &config, column, reach);
1302 assert!(!lines.is_empty(), "{language} {column:?}: an empty card");
1303 lines.push(system_line(&attention, &config, column, reach));
1304 lines.push(when_words(&config, column));
1305 lines.extend(also_lines(column));
1306 for line in &lines {
1307 said_well(line, language);
1308 }
1309 }
1310 }
1311 }
1312 for channel in Channel::ALL {
1313 for person in attention::persons(channel).iter().filter(|p| **p != Person::Always) {
1314 for always in [false, true] {
1315 said_well(&channel_line(&attention, channel, *person, always, "attention-sheet-line").unwrap(), language);
1316 }
1317 }
1318 said_well(&channel_line(&attention, channel, Person::Always, true, "attention-always-line").unwrap(), language);
1319 }
1320 }
1321 said_well(&text("attention-mail-areas"), language);
1322 for key in ["attention-spam-held", "attention-spam-told", "attention-switch-through-off", "attention-card-screening", "attention-card-screening-held", "attention-sheet-line-calls-screening", "attention-switch-realtime-on", "attention-switch-realtime-off", "attention-channel-calls-none", "attention-channel-messages-none", "attention-channel-messages-no-access", "attention-channel-messages-phone"] {
1323 said_well(&text(key), language);
1324 }
1325 }
1326 }
1327
1328 #[test]
1333 fn a_phone_holds_messages_as_far_as_the_sharing_knows() {
1334 use sioul_sync::devices::{COMPUTER, Entry, PHONE};
1335 let entry = |kind: &str, notifications: Option<bool>, left: bool| Entry { id: "x".into(), kind: kind.into(), notifications, left, working: true, ..Entry::default() };
1336 assert_eq!(Phones::of(&[]), Phones::None);
1337 assert_eq!(Phones::of(&[entry(COMPUTER, None, false)]), Phones::None);
1338 assert_eq!(Phones::of(&[entry(PHONE, Some(true), false)]), Phones::Holding);
1339 assert_eq!(Phones::of(&[entry(PHONE, None, false)]), Phones::Holding, "an older Sioul says nothing: holding, as before phones said it");
1340 assert_eq!(Phones::of(&[entry(PHONE, Some(false), false)]), Phones::WithoutAccess, "it said no");
1341 assert_eq!(Phones::of(&[entry(PHONE, Some(false), false), entry(PHONE, Some(true), false)]), Phones::Holding);
1342 assert_eq!(Phones::of(&[entry(PHONE, Some(false), false), entry(PHONE, None, false)]), Phones::Holding);
1343 assert_eq!(Phones::of(&[entry(PHONE, Some(false), false), entry(PHONE, None, true)]), Phones::WithoutAccess, "one that left counts no more");
1344 assert_eq!(Phones::of(&[entry(PHONE, Some(true), true)]), Phones::None);
1345 speak("en");
1346 let computer = |phones: Phones| Reach { messages: phones == Phones::Holding, phones, ..Reach::default() };
1347 assert_eq!(channel_note(Channel::Messages, computer(Phones::None)), text("attention-channel-messages-none"));
1348 assert_eq!(channel_note(Channel::Messages, computer(Phones::WithoutAccess)), "None of your phones gives Sioul notification access, as each last said: these rows apply once one does (Settings ▸ This phone, on the phone).");
1349 assert_eq!(channel_note(Channel::Messages, computer(Phones::Holding)), text("attention-channel-messages-phone"));
1350 assert_eq!(channel_note(Channel::Mail, computer(Phones::WithoutAccess)), "");
1351 assert_eq!(channels(computer(Phones::WithoutAccess)), [Channel::Mail]);
1353 assert_eq!(channels(computer(Phones::Holding)), [Channel::Mail, Channel::Messages]);
1354 }
1355
1356 #[test]
1357 fn the_usual_cards_say_what_sioul_does() {
1358 let config = Config::default();
1359 let usual = Attention::usual();
1360 let computer = Reach::default();
1361 speak("en");
1362 let has = |lines: &[String], line: &str| assert!(lines.iter().any(|l| l == line), "{line:?} not in {lines:#?}");
1363 let leisure = card_lines(&usual, &config, Column::Leisure, computer);
1364 has(&leisure, "Mail from your Always through people comes at once, whatever their list.");
1365 assert!(!card_lines(&usual, &config, Column::Work, computer).iter().any(|l| l.contains("Always through")));
1367 has(&leisure, "Mail from your safe senders comes at once.");
1368 has(&leisure, "Mail from your restricted senders waits for a work time.");
1369 has(&leisure, "Mail from everyone else waits for a work or admin time.");
1370 has(&leisure, "Your sites' notifications come at the gathered times: 09:00, 13:00 and 18:00.");
1371 let sleep = card_lines(&usual, &config, Column::Sleep, computer);
1372 has(&sleep, "Mail from your Always through people is shown in Sioul, without a notification.");
1373 has(&sleep, "Mail from your safe senders is shown in Sioul, without a notification.");
1374 has(&sleep, "The codes and links you ask for and your doses come at once.");
1375 let work = card_lines(&usual, &config, Column::Work, computer);
1376 has(&work, "Mail from everyone comes at once.");
1377 has(&work, "“Work hours are over” does not come at this time.");
1378 let dnd = card_lines(&usual, &config, Column::Dnd, computer);
1380 has(&dnd, "Mail from everyone is shown without a notification, when its time lets it come.");
1381 let phone = Reach { phone: true, calls: true, messages: true, screens: true, ..Reach::default() };
1383 let leisure = card_lines(&usual, &config, Column::Leisure, phone);
1384 has(&leisure, "Mail from your safe senders, calls from your safe senders and your contacts and messages from your safe senders, your contacts and groups come at once.");
1386 let dnd = card_lines(&usual, &config, Column::Dnd, phone);
1388 has(&dnd, "Messages from your safe senders, your contacts and groups come without a sound while your phone is silenced, and wait otherwise.");
1389 assert!(leisure.iter().any(|l| l.starts_with("Calls from") && l.contains("voicemail")), "{leisure:#?}");
1390 assert_eq!(system_line(&usual, &config, Column::Leisure, phone), "The phone is not silenced.");
1391 let pause = system_line(&usual, &config, Column::Pause, phone);
1392 assert!(pause.starts_with("The phone is silenced; it lets through calls") && pause.contains("from your contacts") && pause.contains("alarms"), "{pause}");
1393 let screening = "Calls from numbers nobody knows and hidden numbers: as your call screening says, apart (Settings ▸ This phone).";
1397 let held = "Calls from numbers nobody knows and hidden numbers: to voicemail then, as your contacts' are, unless you expect a call (Settings ▸ This phone).";
1398 has(&leisure, screening);
1399 for column in Column::ALL {
1400 let lines = card_lines(&usual, &config, column, phone);
1401 let wait = matches!(column, Column::Sleep | Column::Pause | Column::Free | Column::Dnd);
1403 assert_eq!(lines.iter().filter(|l| l.contains("hidden numbers")).collect::<Vec<_>>(), [if wait { held } else { screening }], "{column:?}");
1404 }
1405 let mut old_cell = Attention::usual();
1407 old_cell.set(Row::People(Channel::Calls, Person::Stranger), Column::Leisure, Level::Now).unwrap();
1408 assert!(!card_lines(&old_cell, &config, Column::Leisure, phone).iter().any(|l| l.contains("strangers")), "{:#?}", card_lines(&old_cell, &config, Column::Leisure, phone));
1409 assert!(!card_lines(&usual, &config, Column::Leisure, computer).iter().any(|l| l == screening), "no calls here");
1410 let rows: Vec<String> = shown_rows(true).iter().map(|r| r.id()).collect();
1411 assert!(rows.contains(&"calls.safe".to_string()) && !rows.contains(&"calls.stranger".to_string()) && !rows.contains(&"calls.hidden".to_string()) && rows.contains(&"messages.stranger".to_string()), "{rows:?}");
1412 let off: Config = toml::from_str("[screening]\non = false\n").unwrap();
1414 assert!(system_line(&usual, &off, Column::Pause, phone).contains("calls from your contacts"), "{}", system_line(&usual, &off, Column::Pause, phone));
1415 let mut free = Attention::usual();
1416 free.set(Row::People(Channel::Calls, Person::Neutral), Column::Free, Level::Now).unwrap();
1417 assert!(system_line(&free, &off, Column::Free, phone).contains("calls from anyone"), "{}", system_line(&free, &off, Column::Free, phone));
1418 assert_eq!(
1420 channel_line(&usual, Channel::Mail, Person::Neutral, false, "attention-sheet-line").unwrap(),
1421 "Their mail comes at once during work and admin. It waits during leisure, meals, sleep, a pause and free time. During time for you and under do-not-disturb, their mail is at most shown, without a notification."
1422 );
1423 assert!(channel_line(&usual, Channel::Calls, Person::Neutral, true, "attention-sheet-line").unwrap().starts_with("Their calls ring at any time."));
1424 speak("fr");
1425 has(&card_lines(&usual, &config, Column::Work, computer), "Les courriels de tout le monde arrivent tout de suite.");
1426 has(&card_lines(&usual, &config, Column::Leisure, computer), "Les courriels de vos expéditeurs sûrs arrivent tout de suite.");
1427 }
1428
1429 #[test]
1430 fn hours_in_words() {
1431 let mut config = Config::default();
1432 config.windows = sioul_core::window::parse_ranges("mo-fr 09:00-17:00; sa 10:00-12:00");
1433 speak("en");
1434 assert_eq!(hours_words(&config, "work").as_deref(), Some("Monday to Friday, 09:00 to 17:00; Saturday, 10:00 to 12:00"));
1435 assert_eq!(when_words(&config, Column::Admin), text("attention-when-admin-none"));
1436 speak("fr");
1437 assert_eq!(hours_words(&config, "work").as_deref(), Some("Du lundi au vendredi, de 09:00 à 17:00\u{202f}; samedi, de 10:00 à 12:00"));
1438 assert_eq!(capital("les courriels"), "Les courriels");
1439 assert_eq!(small("Temps libre"), "temps libre");
1440 }
1441}