Skip to main content

sioul_app/
reaches.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Settings ▸ What reaches you (« Ce qui vous joint », ReachesTab.qml), as
5//! the window reads it: the moment now in sentences, where you started from
6//! (the presets), a card per time and per layer saying in words what comes
7//! at once, what is shown without a notification, what waits and until
8//! when, the matrix's grid, the exceptions; and a person's sheet
9//! (PersonSheet.qml): their list and why, Always through, and what each
10//! channel does with them at each time. The matrix is
11//! `sioul_core::attention` (docs/attention.md); this file says it in words,
12//! and writes what is changed here.
13
14use 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// ---------------------------------------------------------------- what applies here
24
25/// Which rows reach you through this device and your phones.
26#[derive(Debug, Clone, Copy, Default)]
27struct Reach {
28    /// This device is a phone.
29    phone: bool,
30    /// A phone of yours screens calls: the calls' rows apply.
31    calls: bool,
32    /// A phone of yours holds other apps' notifications: the messages' rows apply.
33    messages: bool,
34    /// This phone screens calls: what its own do-not-disturb lets ring follows.
35    screens: bool,
36    /// Your phones, as the sharing knows them (on a computer).
37    phones: Phones,
38}
39
40/// Your phones in the sharing, as their entries say (`devices::Entry::notifications`).
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42enum Phones {
43    /// None known.
44    #[default]
45    None,
46    /// Known, each saying it does not give Sioul notification access: none holds a message.
47    WithoutAccess,
48    /// One says it gives it, or says nothing (an older Sioul, which does not
49    /// tell: counted as holding messages, as before phones said it).
50    Holding,
51}
52
53impl Phones {
54    /// What the devices' entries say: a phone that left the sharing counts no more.
55    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
75/// Your phones in the sharing: a phone's messages' rows apply where it holds them.
76fn 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
81/// The channels whose rows apply, mail first.
82fn 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
93/// Sioul's own kinds that act on this device: sites on a computer, other
94/// apps and the alarm at waking on a phone; the time running, always there, left unsaid.
95fn 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
110/// The rows the tab shows here, by id: every channel's (By person says when
111/// one does not apply yet), and the kinds that act on this device.
112fn shown_rows(phone: bool) -> Vec<Row> {
113    let mut rows: Vec<Row> = Channel::ALL.iter().flat_map(|c| attention::channel_rows(*c)).collect();
114    let mut own = kinds(phone);
115    own.push(Kind::Time);
116    rows.extend(Kind::ALL.iter().filter(|k| own.contains(k)).map(|k| Row::Own(*k)));
117    rows
118}
119
120// ---------------------------------------------------------------- words
121
122thread_local! {
123    /// The language a test speaks; the window's otherwise.
124    static SPOKEN: std::cell::Cell<Option<&'static Translator>> = const { std::cell::Cell::new(None) };
125}
126
127/// This file's words: the window's language, or a test's.
128fn tr() -> &'static Translator {
129    SPOKEN.with(|spoken| spoken.get()).unwrap_or_else(crate::backend::tr)
130}
131
132/// A message with its arguments, as `backend::say` has it, in this file's language.
133fn say(id: &str, pairs: &[(&str, String)]) -> String {
134    let mut args = sioul_core::i18n::args();
135    for (key, value) in pairs {
136        args.set(key.to_string(), value.clone());
137    }
138    tr().text(id, Some(&args))
139}
140
141fn text(id: &str) -> String {
142    tr().text(id, None)
143}
144
145/// A sentence about `what`, its verb agreeing: `plural` or not.
146fn text_with(id: &str, what: &str, plural: bool, more: &[(&'static str, String)]) -> String {
147    let mut args = sioul_core::i18n::args();
148    args.set("what", what.to_string());
149    args.set("n", if plural { 2 } else { 1 });
150    for (key, value) in more {
151        args.set(*key, value.clone());
152    }
153    capital(&tr().text(id, Some(&args)))
154}
155
156/// The first letter in capitals.
157fn capital(text: &str) -> String {
158    let mut chars = text.chars();
159    chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
160}
161
162/// The first letter in small letters: a time named inside a sentence.
163fn small(text: &str) -> String {
164    let mut chars = text.chars();
165    chars.next().map(|first| first.to_lowercase().chain(chars).collect()).unwrap_or_default()
166}
167
168/// "a, b and c"; with `or`, "a, b or c".
169fn listed(items: &[String], or: bool) -> String {
170    match items {
171        [] => String::new(),
172        [one] => one.clone(),
173        [rest @ .., last] => format!("{} {} {last}", rest.join(", "), text(if or { "attention-or" } else { "word-and" })),
174    }
175}
176
177/// "09:00, 13:00 and 18:00": the gathered times.
178fn gathered_times(config: &Config) -> String {
179    let times: Vec<String> = sioul_core::appnotes::times_of(&config.reminders.gathered_times()).iter().map(|t| t.strftime("%H:%M").to_string()).collect();
180    listed(&times, false)
181}
182
183/// A subject of a sentence: some channels of some people, or one of Sioul's own kinds.
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185enum Subject {
186    People(Channel, Person),
187    Own(Kind),
188}
189
190/// Who, in words: "your safe and neutral senders and strangers"; everyone;
191/// everyone else (`rest`: the others were said before in the same card).
192fn who_words(persons: &[Person], everyone: bool, rest: bool) -> String {
193    if everyone {
194        return text(if rest { "attention-who-everyone-else" } else { "attention-who-everyone" });
195    }
196    let known: Vec<String> = persons.iter().filter(|p| matches!(p, Person::Safe | Person::Neutral | Person::Restricted)).map(|p| text(&format!("attention-who-kind-{}", p.id()))).collect();
197    let mut items = Vec::new();
198    if !known.is_empty() {
199        let mut args = sioul_core::i18n::args();
200        args.set("kinds", listed(&known, false));
201        items.push(tr().text("attention-who-senders", Some(&args)));
202    }
203    for person in persons.iter().filter(|p| matches!(p, Person::Stranger | Person::Hidden | Person::Groups | Person::Always)) {
204        items.push(text(&format!("attention-who-{}", person.id())));
205    }
206    listed(&items, false)
207}
208
209/// "mail, calls and messages", in the language's own words (French: "le courrier…").
210fn channels_words(channels: &[Channel]) -> String {
211    listed(&channels.iter().map(|c| text(&format!("attention-of-{}", c.id()))).collect::<Vec<_>>(), false)
212}
213
214/// Some channels from some people: "mail and messages from strangers".
215fn from_words(channels: &[Channel], who: &str) -> String {
216    let mut args = sioul_core::i18n::args();
217    args.set("channels", channels_words(channels));
218    args.set("who", who.to_string());
219    tr().text("attention-from", Some(&args))
220}
221
222/// The people subjects of one sentence, said: per channel the people it
223/// names, then channels naming the same people together. `said` keeps, per
224/// channel, who was named already in the card, for "everyone else".
225/// Returns the words and whether they are plural.
226fn people_words(subjects: &[(Channel, Person)], said: &mut Vec<(Channel, Person)>) -> Option<(String, bool)> {
227    let mut by_channel: Vec<(Channel, Vec<Person>)> = Vec::new();
228    for (channel, person) in subjects {
229        match by_channel.iter_mut().find(|(c, _)| c == channel) {
230            Some((_, persons)) => persons.push(*person),
231            None => by_channel.push((*channel, vec![*person])),
232        }
233    }
234    if by_channel.is_empty() {
235        return None;
236    }
237    // Each channel's people in words: everyone, everyone else, or by name.
238    let mut worded: Vec<(Vec<Channel>, String)> = Vec::new();
239    for (channel, persons) in &by_channel {
240        let all: Vec<Person> = attention::persons(*channel).iter().copied().filter(|p| p.state()).collect();
241        let before: Vec<Person> = said.iter().filter(|(c, _)| c == channel).map(|(_, p)| *p).collect();
242        let fresh = persons.iter().all(|p| !before.contains(p));
243        let everyone = persons.iter().all(|p| *p != Person::Always) && all.iter().all(|p| persons.contains(p) || before.contains(p)) && fresh;
244        let who = who_words(persons, everyone, everyone && !before.is_empty());
245        match worded.iter_mut().find(|(_, w)| *w == who) {
246            Some((channels, _)) => channels.push(*channel),
247            None => worded.push((vec![*channel], who)),
248        }
249    }
250    for (channel, persons) in &by_channel {
251        said.extend(persons.iter().map(|p| (*channel, *p)));
252    }
253    let parts: Vec<String> = worded.iter().map(|(channels, who)| from_words(channels, who)).collect();
254    let plural = parts.len() > 1 || worded.iter().any(|(channels, _)| channels.len() > 1 || channels[0] != Channel::Mail);
255    Some((listed(&parts, false), plural))
256}
257
258/// Sioul's own kinds in words, the four reminders together when all are
259/// there: "your reminders, doses and the codes you ask for".
260fn kinds_words(subjects: &[Kind]) -> Option<(String, bool)> {
261    let reminders = [Kind::Alarms, Kind::Before, Kind::DayBefore, Kind::Dates];
262    let together = reminders.iter().all(|k| subjects.contains(k));
263    let mut items: Vec<(String, bool)> = Vec::new();
264    for kind in subjects {
265        if together && reminders.contains(kind) {
266            if *kind == Kind::Alarms {
267                items.push((text("attention-what-reminders"), true));
268            }
269            continue;
270        }
271        let singular = matches!(kind, Kind::Wake | Kind::DayBefore | Kind::Move | Kind::WorkOver);
272        items.push((text(&format!("attention-what-{}", kind.id())), !singular));
273    }
274    if items.is_empty() {
275        return None;
276    }
277    let plural = items.len() > 1 || items[0].1;
278    Some((listed(&items.into_iter().map(|(w, _)| w).collect::<Vec<_>>(), false), plural))
279}
280
281/// A group of subjects in words: people first, then Sioul's own.
282/// A group of subjects in words, a sentence's subject each: the people,
283/// then Sioul's own, apart, so that each sentence stays short.
284fn subjects_words(subjects: &[Subject], said: &mut Vec<(Channel, Person)>) -> Vec<(String, bool)> {
285    let people: Vec<(Channel, Person)> = subjects.iter().filter_map(|s| if let Subject::People(c, p) = s { Some((*c, *p)) } else { None }).collect();
286    let own: Vec<Kind> = subjects.iter().filter_map(|s| if let Subject::Own(k) = s { Some(*k) } else { None }).collect();
287    [people_words(&people, said), kinds_words(&own)].into_iter().flatten().collect()
288}
289
290// ---------------------------------------------------------------- a card per time
291
292/// The times a row comes in (not held): its own, for "waits until".
293fn comes_in(attention: &Attention, row: Row) -> Vec<Column> {
294    Column::TIMES.iter().copied().filter(|c| matches!(attention.cell(row, *c), Level::Now | Level::Quiet | Level::Gathered | Level::Event)).collect()
295}
296
297/// Until when something held at `column` waits, in words: "until you wake",
298/// "for a work or admin time".
299fn until_words(column: Column, comes: &[Column]) -> String {
300    let waking = [Column::Work, Column::Admin, Column::Leisure, Column::Meals].iter().all(|c| comes.contains(c));
301    match column {
302        Column::Sleep if waking => return text("attention-until-wake"),
303        Column::Pause if waking => return text("attention-until-back"),
304        Column::Free if waking => return text("attention-until-free"),
305        Column::Meals if [Column::Work, Column::Admin, Column::Leisure].iter().all(|c| comes.contains(c)) => return text("attention-until-meal"),
306        Column::Slot => return text("attention-until-slot"),
307        Column::Dnd => return text("attention-until-dnd"),
308        _ => {}
309    }
310    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();
311    if times.is_empty() {
312        return text("attention-until-none");
313    }
314    let mut args = sioul_core::i18n::args();
315    args.set("times", listed(&times, true));
316    tr().text("attention-until-times", Some(&args))
317}
318
319/// What one time, or one layer, lets through, in sentences: at once, at the
320/// gathered times, when its event falls then, shown without a notification,
321/// waiting until…, not at all; Always through first when it says more than
322/// their own lists.
323fn card_lines(attention: &Attention, config: &Config, column: Column, reach: Reach) -> Vec<String> {
324    let layer = !column.is_time();
325    let mut lines = Vec::new();
326    let mut said: Vec<(Channel, Person)> = Vec::new();
327    let chans = channels(reach);
328    // Always through, as their own row says unless it says more.
329    let mut always: Vec<(Level, Vec<Channel>)> = Vec::new();
330    for channel in &chans {
331        let level = attention.cell(Row::People(*channel, Person::Always), column);
332        // Said only when it gives them more than every list does then.
333        let lists = attention::persons(*channel).iter().filter(|p| p.state()).map(|p| attention.cell(Row::People(*channel, *p), column)).max().unwrap_or(Level::Now);
334        if level == Level::As || (level != Level::Through && level >= lists) {
335            continue;
336        }
337        match always.iter_mut().find(|(l, _)| *l == level) {
338            Some((_, channels)) => channels.push(*channel),
339            None => always.push((level, vec![*channel])),
340        }
341    }
342    for (level, channels) in &always {
343        let what = from_words(channels, &text("attention-who-always"));
344        let plural = channels.len() > 1 || channels[0] != Channel::Mail;
345        let line = match level {
346            Level::Through => text_with("attention-card-always-through", "", true, &[]),
347            Level::Now => text_with("attention-card-always-now", &what, plural, &[]),
348            Level::Quiet if layer => text_with("attention-card-layer-quiet", &what, plural, &[]),
349            Level::Quiet => text_with("attention-card-quiet", &what, plural, &[]),
350            _ => continue,
351        };
352        lines.push(line);
353    }
354    // Everyone else's rows, and Sioul's own, grouped by what they do here.
355    type Key = (u8, Vec<Column>);
356    let mut groups: Vec<(Key, Vec<Subject>)> = Vec::new();
357    let mut add = |key: Key, subject: Subject| match groups.iter_mut().find(|(k, _)| *k == key) {
358        Some((_, subjects)) => subjects.push(subject),
359        None => groups.push((key, vec![subject])),
360    };
361    // The order said: at once, the event's, gathered, shown, waiting (calls to voicemail last), not at all.
362    let rank = |level: Level, calls: bool| -> u8 {
363        match level {
364            Level::Now => 0,
365            Level::Event => 1,
366            Level::Gathered => 2,
367            Level::Quiet => 3,
368            Level::Later if calls => 5,
369            Level::Later => 4,
370            _ => 6,
371        }
372    };
373    for channel in &chans {
374        for person in attention::persons(*channel).iter().filter(|p| p.state()) {
375            let row = Row::People(*channel, *person);
376            let level = attention.cell(row, column);
377            let calls = *channel == Channel::Calls;
378            let until = if level == Level::Later && !calls { comes_in(attention, row) } else { Vec::new() };
379            add((rank(level, calls), until), Subject::People(*channel, *person));
380        }
381    }
382    for kind in kinds(reach.phone) {
383        let row = Row::Own(kind);
384        let level = attention.cell(row, column);
385        let until = if level == Level::Later { comes_in(attention, row) } else { Vec::new() };
386        // Codes not told stay on the Porch: a sentence of their own.
387        let key = if kind == Kind::Codes && level == Level::Never { 7 } else { rank(level, false) };
388        add((key, until), Subject::Own(kind));
389    }
390    groups.sort_by(|a, b| a.0.cmp(&b.0));
391    for ((rank, until), subjects) in groups {
392        // Mail not at all stays in its lane: said apart from the kinds not at all.
393        let (mail_never, rest): (Vec<Subject>, Vec<Subject>) = subjects.into_iter().partition(|s| rank == 6 && matches!(s, Subject::People(..)));
394        for (what, plural) in subjects_words(&mail_never, &mut said) {
395            lines.push(text_with("attention-card-never-mail", &what, plural, &[]));
396        }
397        for (what, plural) in subjects_words(&rest, &mut said) {
398            lines.push(match rank {
399                0 if layer => text_with("attention-card-as-usual", &what, plural, &[]),
400                0 => text_with("attention-card-now", &what, plural, &[]),
401                1 => text_with("attention-card-event", &what, plural, &[]),
402                2 => text_with("attention-card-gathered", &what, plural, &[("times", gathered_times(config))]),
403                3 if layer => text_with("attention-card-layer-quiet", &what, plural, &[]),
404                3 => text_with("attention-card-quiet", &what, plural, &[]),
405                4 => text_with("attention-card-wait", &what, plural, &[("until", until_words(column, &until))]),
406                5 => text_with("attention-card-voicemail", &what, plural, &[]),
407                7 => text_with("attention-card-never-codes", &what, plural, &[]),
408                _ => text_with("attention-card-never", &what, plural, &[]),
409            });
410        }
411    }
412    lines
413}
414
415/// Whether the system's do-not-disturb holds during a column (docs/attention.md, §3.3).
416fn silenced(config: &Config, column: Column) -> bool {
417    match column {
418        Column::Pause | Column::Free => config.dnd.pauses,
419        Column::Sleep => config.dnd.sleep,
420        Column::Dnd => true,
421        _ => false,
422    }
423}
424
425/// What the device's own do-not-disturb does then: not silenced; or, on a
426/// phone, whom Sioul's mode lets through.
427fn system_line(attention: &Attention, config: &Config, column: Column, reach: Reach) -> String {
428    if !silenced(config, column) {
429        return text(if reach.phone { "attention-system-calm-phone" } else { "attention-system-calm-computer" });
430    }
431    if !reach.phone {
432        return text("attention-system-computer");
433    }
434    let silence = attention.silence(&[column], false, Phone { screens: reach.screens });
435    let senders = |senders: Senders| match senders {
436        Senders::None => None,
437        Senders::Starred => Some(text("attention-senders-starred")),
438        Senders::Contacts => Some(text("attention-senders-contacts")),
439        Senders::Anyone => Some(text("attention-senders-anyone")),
440    };
441    let mut items = Vec::new();
442    match (senders(silence.calls), senders(silence.messages)) {
443        (Some(calls), Some(messages)) if calls == messages => items.push(from_words(&[Channel::Calls, Channel::Messages], &calls)),
444        (calls, messages) => {
445            items.extend(calls.map(|who| from_words(&[Channel::Calls], &who)));
446            items.extend(messages.map(|who| from_words(&[Channel::Messages], &who)));
447        }
448    }
449    if silence.repeat {
450        items.push(text("attention-system-repeat"));
451    }
452    if silence.conversations {
453        items.push(text("attention-system-conversations"));
454    }
455    items.push(text("attention-system-alarms"));
456    if silence.events {
457        items.push(text("attention-system-events"));
458    }
459    if silence.doses {
460        items.push(text("attention-system-doses"));
461    }
462    let mut args = sioul_core::i18n::args();
463    args.set("what", listed(&items, false));
464    tr().text("attention-system-phone", Some(&args))
465}
466
467/// A set of hours in words, days with the same hours together: "Monday to
468/// Friday, 09:00 to 17:00"; none when none is set.
469fn hours_words(config: &Config, kind: &str) -> Option<String> {
470    let windows: Vec<&sioul_core::window::AdminWindow> = config.windows.iter().filter(|w| w.kind() == kind).collect();
471    let mut days: Vec<Vec<(String, String)>> = vec![Vec::new(); 7];
472    for w in &windows {
473        if let Some(day) = w.weekday() {
474            let range = (w.start.trim().to_string(), w.end_text());
475            let slot = &mut days[day.to_monday_zero_offset() as usize];
476            if !slot.contains(&range) {
477                slot.push(range);
478            }
479        }
480    }
481    for slot in &mut days {
482        slot.sort();
483    }
484    let day = |i: usize| text(&format!("weekday-{}", i + 1));
485    let mut parts = Vec::new();
486    let mut at = 0;
487    while at < 7 {
488        if days[at].is_empty() {
489            at += 1;
490            continue;
491        }
492        let mut last = at;
493        while last + 1 < 7 && days[last + 1] == days[at] {
494            last += 1;
495        }
496        let when = match last - at {
497            0 => day(at),
498            1 => listed(&[day(at), day(last)], false),
499            _ => {
500                let mut args = sioul_core::i18n::args();
501                args.set("from", day(at));
502                args.set("to", day(last));
503                tr().text("attention-when-days", Some(&args))
504            }
505        };
506        let ranges: Vec<String> = days[at]
507            .iter()
508            .map(|(from, to)| {
509                let mut args = sioul_core::i18n::args();
510                args.set("from", from.clone());
511                args.set("to", to.clone());
512                tr().text("attention-when-range", Some(&args))
513            })
514            .collect();
515        parts.push(format!("{when}, {}", listed(&ranges, false)));
516        at = last + 1;
517    }
518    // Days with other hours after a semicolon, as each language writes it.
519    let joined = parts.into_iter().reduce(|one, other| say("attention-when-list", &[("one", one), ("other", other)]))?;
520    Some(capital(&joined))
521}
522
523/// When a time holds, in words.
524fn when_words(config: &Config, column: Column) -> String {
525    match column {
526        // A sentence, its full stop said by each language.
527        Column::Work => hours_words(config, "work").map(|hours| say("attention-when-hours", &[("hours", hours)])).unwrap_or_else(|| text("attention-when-work-none")),
528        Column::Admin => hours_words(config, "admin").map(|hours| say("attention-when-hours", &[("hours", hours)])).unwrap_or_else(|| text("attention-when-admin-none")),
529        Column::Dnd if config.dnd.focus => text("attention-when-dnd-focus"),
530        other => text(&format!("attention-when-{}", other.id())),
531    }
532}
533
534/// The named holds outside the matrix, said in the time they act in (Q25).
535/// Free time's, Nothing at all, is its card's switch, its help under it.
536fn also_lines(column: Column) -> Vec<String> {
537    let ids: &[&str] = match column {
538        Column::Work => &["attention-also-meeting"],
539        Column::Admin => &["attention-also-areas", "attention-also-meeting"],
540        Column::Leisure | Column::Meals => &["attention-also-areas"],
541        Column::Pause => &["attention-also-porch-rests"],
542        _ => &[],
543    };
544    ids.iter().map(|id| text(id)).collect()
545}
546
547/// The time now as a column: the pause, sleep, Free time, then the hours; work when both open.
548fn column_now(now: &Now) -> Column {
549    now.times.first().copied().unwrap_or(Column::Work)
550}
551
552// ---------------------------------------------------------------- the tab
553
554/// The tab, as JSON (ReachesTab.qml): {now: sentences, column: the time
555/// now, presets [{id, label, current, changes}], changed, cards [{id,
556/// label, layer, now, when, lines, system, also}], switches \[lines\],
557/// channels [{id, label, note}], rows [ids shown here], grid (the
558/// matrix's, `attention::grid`), mail: {spam, areas}, gathered, exceptions:
559/// {always \[lines\], sites \[lines\], events, health}, phone}.
560pub(crate) fn view(realtime: bool) -> String {
561    let config = load_config();
562    let attention = crate::hours::attention();
563    let reach = reach();
564    let now = crate::hours::attention_now();
565    let current = column_now(&now);
566    let usual = Attention::usual();
567    let presets: Vec<Value> = Preset::ALL
568        .iter()
569        .map(|p| {
570            let changes = attention.changes_from(&p.matrix());
571            json!({ "id": p.id(), "label": text(&format!("attention-preset-{}", p.id())), "current": changes == 0, "changes": changes })
572        })
573        .collect();
574    let from_usual = attention.changes_from(&usual);
575    let changed = if from_usual == 0 { String::new() } else { tr().text("attention-changes", Some(&tr().counted(from_usual))) };
576    // The time now first, then the others in their order, then the layers.
577    let mut order: Vec<Column> = vec![current];
578    order.extend(Column::TIMES.iter().copied().filter(|c| *c != current));
579    order.extend([Column::Slot, Column::Dnd].iter().filter(|c| **c != current));
580    let cards: Vec<Value> = order
581        .iter()
582        .map(|column| {
583            json!({
584                "id": column.id(),
585                "label": attention::column_label(tr(), *column),
586                "layer": !column.is_time(),
587                "now": *column == current || (column == &Column::Dnd && now.dnd) || (column == &Column::Slot && now.slot),
588                "when": when_words(&config, *column),
589                "lines": card_lines(&attention, &config, *column, reach),
590                "system": system_line(&attention, &config, *column, reach),
591                "also": also_lines(*column),
592            })
593        })
594        .collect();
595    let mut switches = Vec::new();
596    if reach.calls {
597        let moment = crate::calls::moment();
598        let through = moment["through"] == true;
599        let line = moment["line"].as_str().unwrap_or_default().to_string();
600        switches.push(if through && !line.is_empty() { line } else { text("attention-switch-through-off") });
601    }
602    switches.push(text(if realtime { "attention-switch-realtime-on" } else { "attention-switch-realtime-off" }));
603    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();
604    let rows = shown_rows(reach.phone);
605    let grid = attention::grid(&attention, tr(), &rows, Preset::Usual);
606    json!({
607        "now": now_sentences(&attention, &now, reach),
608        "column": current.id(),
609        "presets": presets,
610        "changed": changed,
611        "cards": cards,
612        "switches": switches,
613        "channels": channels,
614        "rows": rows.iter().map(|r| r.id()).collect::<Vec<_>>(),
615        "grid": grid,
616        "gathered": say("attention-gathered-at", &[("times", gathered_times(&config))]),
617        "mail": { "spam": spam_line(&config), "areas": text("attention-mail-areas") },
618        "exceptions": exceptions(&attention, &config, reach),
619        "phone": reach.phone,
620        // Android lets Sioul set exact alarms (This phone says when it does not).
621        "exact": !reach.phone || crate::alarms::exact(),
622    })
623    .to_string()
624}
625
626/// What Sioul's own spam filter does with strangers' mail, in one sentence
627/// (its table: Mail ⚙ ▸ Your own spam filter).
628fn spam_line(config: &Config) -> String {
629    use sioul_core::spam::{Action, Class};
630    let actions = config.spam.actions();
631    let held = [Class::Spam, Class::Unsure].iter().any(|c| actions.of(*c) != Action::Nothing);
632    text(if held { "attention-spam-held" } else { "attention-spam-told" })
633}
634
635/// The moment now, in sentences: the time and until when, the layers on top
636/// of it, then what comes at once, what is shown without a word, what waits
637/// (`Attention::person` and `level`: what the matrix decides now).
638fn now_sentences(attention: &Attention, now: &Now, reach: Reach) -> Vec<String> {
639    let mode = crate::hours::mode_now();
640    let column = column_now(now);
641    let time = small(&attention::column_label(tr(), column));
642    let at = Zoned::now();
643    let mut out = vec![match mode.until.as_ref().filter(|_| !mode.paused()) {
644        Some(until) => say("attention-now-time", &[("time", time), ("until", sioul_core::quiet::until_text(tr(), until, &at))]),
645        None => say("attention-now-time-open", &[("time", time)]),
646    }];
647    for (on, layer) in [(now.slot, Column::Slot), (now.dnd, Column::Dnd)] {
648        if on {
649            out.push(say("attention-now-layer", &[("layer", attention::column_label(tr(), layer))]));
650        }
651    }
652    if now.nothing {
653        out.push(text("attention-now-nothing"));
654    }
655    let mut groups: Vec<(u8, Vec<Subject>)> = Vec::new();
656    let mut add = |rank: u8, subject: Subject| match groups.iter_mut().find(|(r, _)| *r == rank) {
657        Some((_, subjects)) => subjects.push(subject),
658        None => groups.push((rank, vec![subject])),
659    };
660    for channel in channels(reach) {
661        for person in attention::persons(channel).iter().filter(|p| p.state()) {
662            match attention.person(channel, *person, false, now) {
663                Level::Now => add(0, Subject::People(channel, *person)),
664                Level::Quiet => add(2, Subject::People(channel, *person)),
665                Level::Never => {}
666                _ => add(3, Subject::People(channel, *person)),
667            }
668        }
669    }
670    // Of Sioul's own, what matters most: codes, doses, reminders.
671    for kind in [Kind::Codes, Kind::Doses, Kind::Alarms, Kind::Before, Kind::DayBefore, Kind::Dates] {
672        match attention.level(Row::Own(kind), now) {
673            Level::Now | Level::Event => add(0, Subject::Own(kind)),
674            Level::Gathered => add(1, Subject::Own(kind)),
675            Level::Never => {}
676            _ => add(3, Subject::Own(kind)),
677        }
678    }
679    groups.sort_by_key(|(rank, _)| *rank);
680    let mut said = Vec::new();
681    let config = load_config();
682    for (rank, subjects) in groups {
683        for (what, plural) in subjects_words(&subjects, &mut said) {
684            out.push(match rank {
685                0 => text_with("attention-card-now", &what, plural, &[]),
686                1 => text_with("attention-card-gathered", &what, plural, &[("times", gathered_times(&config))]),
687                2 => text_with("attention-card-quiet", &what, plural, &[]),
688                _ => text_with("attention-now-wait", &what, plural, &[]),
689            });
690        }
691    }
692    out
693}
694
695/// What a channel's rows say of where they apply: calls once a phone screens
696/// them; messages on a phone that holds other apps' notifications, as far
697/// as the sharing knows (`Phones`); "" where they apply here.
698fn channel_note(channel: Channel, reach: Reach) -> String {
699    match channel {
700        Channel::Calls if !reach.calls => text("attention-channel-calls-none"),
701        Channel::Messages if !reach.messages && reach.phones == Phones::WithoutAccess => text("attention-channel-messages-no-access"),
702        Channel::Messages if !reach.messages => text("attention-channel-messages-none"),
703        Channel::Messages if !reach.phone => text("attention-channel-messages-phone"),
704        _ => String::new(),
705    }
706}
707
708/// The exceptions in words: what Always through does on each channel, your
709/// sites' own choices (a computer), where events and Health's days say theirs.
710fn exceptions(attention: &Attention, config: &Config, reach: Reach) -> Value {
711    let always: Vec<String> = channels(reach).iter().filter_map(|c| channel_line(attention, *c, Person::Always, true, "attention-always-line")).collect();
712    let sites: Vec<String> = config
713        .accounts
714        .iter()
715        .filter_map(sioul_core::sites::Site::of)
716        .map(|site| {
717            let how = if site.muted {
718                text("attention-site-muted")
719            } else if site.realtime {
720                text("attention-site-live")
721            } else {
722                text("attention-site-gathered")
723            };
724            say("attention-site-line", &[("name", site.name.clone()), ("how", how)])
725        })
726        .collect();
727    json!({ "always": always, "sites": if reach.phone { Vec::new() } else { sites } })
728}
729
730// ---------------------------------------------------------------- a channel, at each time
731
732/// What a channel does with someone at each time, in sentences: "Their mail
733/// comes at once during work, admin, leisure and meals. It is shown without
734/// a notification during sleep, a pause and free time." Then the layers,
735/// when they hold more. `id`: the sentence's key, "attention-sheet-line"
736/// for someone, "attention-always-line" for Always through.
737fn channel_line(attention: &Attention, channel: Channel, person: Person, always: bool, id: &str) -> Option<String> {
738    if person == Person::Blocked {
739        return Some(text(&format!("{id}-blocked-{}", channel.id())));
740    }
741    let at = |column: Column| -> Level {
742        // Always through's own row: "as their list" said as such, not as one person's.
743        let level = if person == Person::Always { attention.cell(Row::People(channel, Person::Always), column) } else { attention.person(channel, person, always, &Now::time(column)) };
744        // A phone cannot show what it holds: shown, not told, is held.
745        if channel != Channel::Mail && level == Level::Quiet { Level::Later } else { level }
746    };
747    let mut parts: Vec<(Level, Vec<Column>)> = Vec::new();
748    for column in Column::TIMES {
749        let level = at(column);
750        match parts.iter_mut().find(|(l, _)| *l == level) {
751            Some((_, columns)) => columns.push(column),
752            None => parts.push((level, vec![column])),
753        }
754    }
755    let during = |columns: &[Column]| -> String {
756        if columns.len() == Column::TIMES.len() {
757            return text("attention-during-any");
758        }
759        let mut args = sioul_core::i18n::args();
760        args.set("times", listed(&columns.iter().map(|c| text(&format!("attention-during-{}", c.id()))).collect::<Vec<_>>(), false));
761        tr().text("attention-during", Some(&args))
762    };
763    let mut sentences = Vec::new();
764    for (index, (level, columns)) in parts.iter().enumerate() {
765        let verb = format!("{id}-{}-{}", channel.id(), level_word(*level));
766        let mut args = sioul_core::i18n::args();
767        args.set("during", during(columns));
768        args.set("first", if index == 0 { "yes" } else { "no" });
769        sentences.push(tr().text(&verb, Some(&args)));
770    }
771    // The layers, when they hold more than the time does; said once when both hold the same.
772    let row = Row::People(channel, if always || person == Person::Always { Person::Always } else { person });
773    let held = |layer: Column| match attention.cell(row, layer) {
774        Level::Through | Level::Now | Level::As => None,
775        Level::Quiet if channel != Channel::Mail => Some(Level::Later),
776        other => Some(other),
777    };
778    let layers: Vec<(&str, Level)> = match (held(Column::Slot), held(Column::Dnd)) {
779        (Some(slot), Some(dnd)) if slot == dnd => vec![("both", slot)],
780        (slot, dnd) => [("slot", slot), ("dnd", dnd)].into_iter().filter_map(|(id, level)| level.map(|l| (id, l))).collect(),
781    };
782    for (layer, level) in layers {
783        let mut args = sioul_core::i18n::args();
784        args.set("layer", layer);
785        sentences.push(tr().text(&format!("{id}-layer-{}-{}", channel.id(), level_word(level)), Some(&args)));
786    }
787    Some(sentences.join(" "))
788}
789
790/// A level's word in the keys of the sentences.
791fn level_word(level: Level) -> &'static str {
792    match level {
793        Level::Now | Level::Through => "now",
794        Level::Quiet => "quiet",
795        Level::Never => "never",
796        Level::As => "as",
797        _ => "later",
798    }
799}
800
801// ---------------------------------------------------------------- writing
802
803/// A row changed from the tab: its words whole (`words`, a JSON list), as
804/// `attention::apply` reads them. "" when kept, else why not.
805pub(crate) fn set_row(row: &str, words: &str, shown: &str) -> String {
806    let words: Vec<String> = serde_json::from_str(words).unwrap_or_default();
807    // What the row showed: only the cells changed from it are set, over the row as it is now.
808    let shown: Option<Vec<String>> = serde_json::from_str(shown).ok();
809    match attention::apply_change(&config_path(), &format!("attention.{row}"), shown.map(SettingValue::Texts).as_ref(), &SettingValue::Texts(words)) {
810        Ok(()) => String::new(),
811        Err(e) => e,
812    }
813}
814
815/// A preset chosen ("usual", "quieter", "reachable"): every row as it says,
816/// the fixed cells untouched. "" when kept, else why not.
817pub(crate) fn preset(id: &str) -> String {
818    let Some(preset) = Preset::read(id) else { return format!("{id}: usual, quieter or reachable") };
819    match attention::apply_preset(&config_path(), &load_config(), preset) {
820        Ok(()) => String::new(),
821        Err(e) => e,
822    }
823}
824
825// ---------------------------------------------------------------- a person's sheet
826
827/// The card with this file or UID, from every address book.
828fn card(key: &str) -> Option<sioul_core::contacts::Contact> {
829    let key = key.trim();
830    (!key.is_empty()).then(|| sioul_core::contacts::all().into_iter().find(|c| c.key == key || c.uid.trim() == key)).flatten()
831}
832
833/// A JSON array of addresses, or one address.
834fn addresses_of(text: &str) -> Vec<String> {
835    let text = text.trim();
836    let list: Vec<String> = if text.starts_with('[') { serde_json::from_str(text).unwrap_or_default() } else { vec![text.to_string()] };
837    list.into_iter().map(|a| a.trim().to_ascii_lowercase()).filter(|a| !a.is_empty()).collect()
838}
839
840/// Who the sheet is about: a card (from Contacts, or a sender's card), else
841/// a sender's addresses alone, or a caller's number alone (`tel:…`, from a
842/// call's line on the Porch).
843struct Someone {
844    card: Option<sioul_core::contacts::Contact>,
845    addresses: Vec<String>,
846}
847
848/// How numbers are written here: the contacts' country, else the language's.
849fn region() -> Option<&'static sioul_core::phones::Region> {
850    sioul_core::phones::chosen(load_config().contacts.region.as_deref(), &tr().text("qt-locale", None))
851}
852
853impl Someone {
854    fn of(key: &str, addresses: &str) -> Someone {
855        let addresses = addresses_of(addresses);
856        let card = card(key).or_else(|| {
857            let all = sioul_core::contacts::all();
858            addresses.iter().find_map(|a| match a.strip_prefix(sioul_core::porch::TEL) {
859                // A caller's number: the card holding it, as the number's key.
860                Some(number) => {
861                    let region = region();
862                    let wanted = sioul_core::phones::key(number, region);
863                    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()
864                }
865                None => sioul_core::contacts::by_address(&all, a).cloned(),
866            })
867        });
868        Someone { card, addresses }
869    }
870
871    fn name(&self) -> String {
872        let first = || {
873            self.addresses.first().map(|a| match a.strip_prefix(sioul_core::porch::TEL) {
874                Some(number) => {
875                    let region = region();
876                    sioul_core::calls::shown_number(&sioul_core::phones::key(number, region), region)
877                }
878                None => a.clone(),
879            })
880        };
881        self.card.as_ref().map(|c| c.name.trim().to_string()).filter(|n| !n.is_empty()).or_else(first).unwrap_or_default()
882    }
883
884    fn emails(&self) -> Vec<String> {
885        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();
886        for address in self.addresses.iter().filter(|a| !a.starts_with(sioul_core::porch::TEL)) {
887            if !out.contains(address) {
888                out.push(address.clone());
889            }
890        }
891        out
892    }
893
894    fn phones(&self) -> Vec<String> {
895        let mut out: Vec<String> = self.card.iter().flat_map(|c| c.phones.iter().map(|p| p.value.clone())).collect();
896        out.extend(self.addresses.iter().filter_map(|a| a.strip_prefix(sioul_core::porch::TEL)).map(str::to_string));
897        out
898    }
899
900    /// Their entry on the Always through list, when they are on it.
901    fn always(&self, config: &Config) -> Option<String> {
902        let people = People::load(&People::default_path());
903        let region = sioul_core::phones::chosen(config.contacts.region.as_deref(), &tr().text("qt-locale", None));
904        let uid = self.card.as_ref().map(|c| c.uid.trim().to_string()).unwrap_or_default();
905        let emails = self.emails();
906        let phones = self.phones();
907        people
908            .people
909            .iter()
910            .find(|p| {
911                (!uid.is_empty() && p.contact == uid)
912                    || emails.iter().any(|e| p.emails.iter().any(|x| x.trim().eq_ignore_ascii_case(e)))
913                    || phones.iter().any(|n| {
914                        let key = sioul_core::phones::key(n, region);
915                        sioul_core::phones::is_whole(&key) && p.phones.iter().any(|x| sioul_core::phones::key(x, region) == key)
916                    })
917            })
918            .map(|p| p.id.clone())
919    }
920
921    /// Their list, as the card's ("Their list") or the address's ("Their mail").
922    fn list(&self) -> Value {
923        match &self.card {
924            Some(card) => serde_json::from_str(&crate::senders::person_json(&card.key)).unwrap_or(Value::Null),
925            None => {
926                let mut said: Value = serde_json::from_str(&crate::senders::standing_json(&json!(self.addresses).to_string())).unwrap_or(Value::Null);
927                said["who"] = said["standing"].clone();
928                said["own"] = json!([]);
929                said
930            }
931        }
932    }
933}
934
935/// A person's sheet, as JSON (PersonSheet.qml): {title, name, who, said,
936/// choice, choices [{value, label}], own \[lines\], always, lines [one per
937/// channel], note, problem, calls_title, calls [their calls of the month,
938/// newest first]}. `key`: a card's file or UID; `addresses`: a sender's, a
939/// JSON array or one, or a caller's number (`tel:…`) (their card found when
940/// one has them).
941pub(crate) fn person(key: &str, addresses: &str) -> String {
942    person_with(&Someone::of(key, addresses), String::new())
943}
944
945fn person_with(someone: &Someone, note: String) -> String {
946    let config = load_config();
947    let attention = crate::hours::attention();
948    let list = someone.list();
949    let who = list["who"].as_str().and_then(Who::read).unwrap_or(Who::Stranger);
950    let always = someone.always(&config).is_some() && who != Who::Blocked;
951    let reach = reach();
952    let lines: Vec<String> = Channel::ALL
953        .iter()
954        .filter_map(|channel| {
955            let line = channel_line(&attention, *channel, Person::of(who), always, "attention-sheet-line")?;
956            let missing = match channel {
957                Channel::Calls if !reach.calls => Some(text("attention-sheet-calls-none")),
958                Channel::Calls if someone.phones().is_empty() => Some(text("attention-sheet-no-number")),
959                Channel::Mail if someone.emails().is_empty() => Some(text("attention-sheet-no-address")),
960                _ => None,
961            };
962            Some(match missing {
963                Some(missing) => format!("{line} {missing}"),
964                None => line,
965            })
966        })
967        .collect();
968    json!({
969        "title": say("attention-sheet-title", &[("name", someone.name())]),
970        "name": someone.name(),
971        "card": someone.card.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
972        "addresses": someone.addresses,
973        "who": who.id(),
974        "said": list["said"],
975        "choice": list["choice"],
976        "choices": list["choices"],
977        "own": list["own"],
978        "always": always,
979        "always_help": text(if who == Who::Blocked { "attention-sheet-always-blocked" } else if always { "attention-sheet-always-help" } else { "attention-sheet-always-off-help" }),
980        "lines": lines,
981        "note": note,
982        // Their calls of the month, from every phone sharing its own, rang or declined.
983        "calls_title": text("calls-history-title"),
984        "calls": if who == Who::Blocked { Vec::new() } else { crate::calls::history(&someone.phones()) },
985        // Their messages of the week, from the phones that share them.
986        "texts_title": text("phonemsgs-history-title"),
987        "texts": if who == Who::Blocked { Vec::new() } else { crate::phonemsgs::history(&someone.phones()) },
988        // texts: their conversation on the Texts page, when texts are read here ("" otherwise).
989        "texts_id": crate::texts::conversation_of(&someone.phones()),
990    })
991    .to_string()
992}
993
994/// A change made on a person's sheet (`verb`: "list", `value` a list's name
995/// or "" for their categories; "always", `value` "on" or "off"). Blocked and
996/// Always through exclude each other (Q3): choosing one takes them off the
997/// other, said. Returns {sheet: the sheet again, line: what the status line says}.
998pub(crate) fn person_change(key: &str, addresses: &str, verb: &str, value: &str) -> String {
999    let someone = Someone::of(key, addresses);
1000    let config = load_config();
1001    let mut said: Vec<String> = Vec::new();
1002    match verb {
1003        "list" => {
1004            let was_always = someone.always(&config).is_some();
1005            let line = match &someone.card {
1006                Some(card) => crate::senders::set_person(&card.key, value),
1007                None => crate::senders::set_standing_of(&json!(someone.addresses).to_string(), value),
1008            };
1009            said.push(line);
1010            // Blocking takes them off Always through (`porch::set_standing`); someone written
1011            // on it by hand, with none of the card's lines, is taken off here.
1012            if value == "blocked" && was_always {
1013                if let Some(id) = someone.always(&load_config()) {
1014                    let _ = crate::everywhere::change("remove", &json!({ "id": id }).to_string());
1015                }
1016                said.push(say("attention-sheet-off-always", &[("name", someone.name())]));
1017            }
1018        }
1019        "always" if value == "on" => {
1020            // Off the blocked list first: their own list as their categories say, neutral when these still block them.
1021            let blocked = |someone: &Someone| Who::read(someone.list()["who"].as_str().unwrap_or_default()) == Some(Who::Blocked);
1022            let was_blocked = blocked(&someone);
1023            if was_blocked {
1024                let choose = |choice: &str| match &someone.card {
1025                    Some(card) => crate::senders::set_person(&card.key, choice),
1026                    None => crate::senders::set_standing_of(&json!(someone.addresses).to_string(), choice),
1027                };
1028                choose("");
1029                if blocked(&someone) {
1030                    choose("neutral");
1031                }
1032            }
1033            let (answer, _) = match &someone.card {
1034                Some(card) => crate::everywhere::change("add-contact", &json!({ "uid": card.uid }).to_string()),
1035                // A sender's addresses, or a caller's number (a call's line on the Porch).
1036                None => crate::everywhere::change("add", &json!({ "id": "", "name": "", "phones": someone.phones().join("\n"), "emails": someone.emails().join("\n") }).to_string()),
1037            };
1038            said.push(say("attention-sheet-on-always", &[("name", someone.name())]));
1039            if was_blocked {
1040                said.push(say("attention-sheet-off-blocked", &[("name", someone.name())]));
1041            }
1042            // What the list said: why it could not be written, or that they came off the blocked list.
1043            said.push(serde_json::from_str::<Value>(&answer).ok().and_then(|a| a["said"].as_str().map(str::to_string)).unwrap_or_default());
1044        }
1045        "always" => {
1046            if let Some(id) = someone.always(&config) {
1047                let _ = crate::everywhere::change("remove", &json!({ "id": id }).to_string());
1048                said.push(say("attention-sheet-off-always", &[("name", someone.name())]));
1049            }
1050        }
1051        _ => {}
1052    }
1053    let line: Vec<String> = said.into_iter().filter(|s| !s.is_empty()).collect();
1054    json!({ "sheet": serde_json::from_str::<Value>(&person_with(&Someone::of(key, addresses), line.join(" "))).unwrap_or(Value::Null), "line": line.join(" ") }).to_string()
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    /// This thread speaks `language` from now on.
1062    fn speak(language: &str) {
1063        let translator: &'static Translator = Box::leak(Box::new(Translator::new(language)));
1064        SPOKEN.with(|spoken| spoken.set(Some(translator)));
1065    }
1066
1067    /// A sentence said whole: no key, no placeable, a capital, a full stop; French typography, « type ».
1068    fn said_well(line: &str, language: &str) {
1069        assert!(!line.trim().is_empty(), "{language}: an empty line");
1070        assert!(!line.contains("attention-") && !line.contains('{') && !line.contains('$'), "{language}: {line}");
1071        assert!(line.chars().next().is_some_and(|c| !c.is_lowercase()), "{language}: no capital: {line}");
1072        assert!(line.ends_with('.') || line.ends_with(".)"), "{language}: no full stop: {line}");
1073        assert!(!line.contains("sorte"), "{language}: {line}");
1074        if language == "fr" {
1075            for mark in [" :", " ;", " !", " ?", "« ", " »", "'"] {
1076                assert!(!line.contains(mark), "fr: typography ({mark:?}) in {line}");
1077            }
1078        }
1079    }
1080
1081    /// Every cell at the last value it may take, Always through's layers at ☆: each value said somewhere.
1082    fn changed_everywhere() -> Attention {
1083        let mut a = Attention::usual();
1084        for row in Row::ALL {
1085            for column in Column::ALL {
1086                if let Some(level) = attention::choices(row, column).last() {
1087                    let _ = a.set(row, column, *level);
1088                }
1089                if row.always() && !column.is_time() {
1090                    let _ = a.set(row, column, Level::Through);
1091                }
1092            }
1093        }
1094        a
1095    }
1096
1097    #[test]
1098    fn every_card_and_line_is_said_whole_in_both_languages() {
1099        let mut config = Config::default();
1100        config.dnd.sleep = true;
1101        config.dnd.focus = true;
1102        config.windows = sioul_core::window::parse_ranges("mo-fr 09:00-12:00, 14:00-17:00; sa 10:00-12:00");
1103        for language in ["en", "fr"] {
1104            speak(language);
1105            for attention in [Attention::usual(), Preset::Quieter.matrix(), Preset::Reachable.matrix(), changed_everywhere()] {
1106                for phone in [false, true] {
1107                    for (calls, messages) in [(false, false), (true, true)] {
1108                        let reach = Reach { phone, calls, messages, screens: phone && calls, ..Reach::default() };
1109                        for column in Column::ALL {
1110                            let mut lines = card_lines(&attention, &config, column, reach);
1111                            assert!(!lines.is_empty(), "{language} {column:?}: an empty card");
1112                            lines.push(system_line(&attention, &config, column, reach));
1113                            lines.push(when_words(&config, column));
1114                            lines.extend(also_lines(column));
1115                            for line in &lines {
1116                                said_well(line, language);
1117                            }
1118                        }
1119                    }
1120                }
1121                for channel in Channel::ALL {
1122                    for person in attention::persons(channel).iter().filter(|p| **p != Person::Always) {
1123                        for always in [false, true] {
1124                            said_well(&channel_line(&attention, channel, *person, always, "attention-sheet-line").unwrap(), language);
1125                        }
1126                    }
1127                    said_well(&channel_line(&attention, channel, Person::Always, true, "attention-always-line").unwrap(), language);
1128                }
1129            }
1130            said_well(&text("attention-mail-areas"), language);
1131            for key in ["attention-spam-held", "attention-spam-told", "attention-switch-through-off", "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"] {
1132                said_well(&text(key), language);
1133            }
1134        }
1135    }
1136
1137    /// On a computer, a phone in the sharing holds messages unless its entry
1138    /// says it does not give Sioul notification access (an older Sioul's,
1139    /// which says nothing, holds them); when every phone says no, the
1140    /// messages' rows say why they do not apply, and the moment leaves messages out.
1141    #[test]
1142    fn a_phone_holds_messages_as_far_as_the_sharing_knows() {
1143        use sioul_sync::devices::{COMPUTER, Entry, PHONE};
1144        let entry = |kind: &str, notifications: Option<bool>, left: bool| Entry { id: "x".into(), kind: kind.into(), notifications, left, working: true, ..Entry::default() };
1145        assert_eq!(Phones::of(&[]), Phones::None);
1146        assert_eq!(Phones::of(&[entry(COMPUTER, None, false)]), Phones::None);
1147        assert_eq!(Phones::of(&[entry(PHONE, Some(true), false)]), Phones::Holding);
1148        assert_eq!(Phones::of(&[entry(PHONE, None, false)]), Phones::Holding, "an older Sioul says nothing: holding, as before phones said it");
1149        assert_eq!(Phones::of(&[entry(PHONE, Some(false), false)]), Phones::WithoutAccess, "it said no");
1150        assert_eq!(Phones::of(&[entry(PHONE, Some(false), false), entry(PHONE, Some(true), false)]), Phones::Holding);
1151        assert_eq!(Phones::of(&[entry(PHONE, Some(false), false), entry(PHONE, None, false)]), Phones::Holding);
1152        assert_eq!(Phones::of(&[entry(PHONE, Some(false), false), entry(PHONE, None, true)]), Phones::WithoutAccess, "one that left counts no more");
1153        assert_eq!(Phones::of(&[entry(PHONE, Some(true), true)]), Phones::None);
1154        speak("en");
1155        let computer = |phones: Phones| Reach { messages: phones == Phones::Holding, phones, ..Reach::default() };
1156        assert_eq!(channel_note(Channel::Messages, computer(Phones::None)), text("attention-channel-messages-none"));
1157        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).");
1158        assert_eq!(channel_note(Channel::Messages, computer(Phones::Holding)), text("attention-channel-messages-phone"));
1159        assert_eq!(channel_note(Channel::Mail, computer(Phones::WithoutAccess)), "");
1160        // The moment and the channels said: messages only from a phone that holds them.
1161        assert_eq!(channels(computer(Phones::WithoutAccess)), [Channel::Mail]);
1162        assert_eq!(channels(computer(Phones::Holding)), [Channel::Mail, Channel::Messages]);
1163    }
1164
1165    #[test]
1166    fn the_usual_cards_say_what_sioul_does() {
1167        let config = Config::default();
1168        let usual = Attention::usual();
1169        let computer = Reach::default();
1170        speak("en");
1171        let has = |lines: &[String], line: &str| assert!(lines.iter().any(|l| l == line), "{line:?} not in {lines:#?}");
1172        let leisure = card_lines(&usual, &config, Column::Leisure, computer);
1173        has(&leisure, "Mail from your Always through people comes at once, whatever their list.");
1174        // Where every list comes at once already, Always through gives nothing more: not said.
1175        assert!(!card_lines(&usual, &config, Column::Work, computer).iter().any(|l| l.contains("Always through")));
1176        has(&leisure, "Mail from your safe senders comes at once.");
1177        has(&leisure, "Mail from your restricted senders waits for a work time.");
1178        has(&leisure, "Mail from everyone else waits for a work or admin time.");
1179        has(&leisure, "Your sites' notifications come at the gathered times: 09:00, 13:00 and 18:00.");
1180        let sleep = card_lines(&usual, &config, Column::Sleep, computer);
1181        has(&sleep, "Mail from your Always through people is shown in Sioul, without a notification.");
1182        has(&sleep, "Mail from your safe senders is shown in Sioul, without a notification.");
1183        has(&sleep, "The codes and links you ask for and your doses come at once.");
1184        let work = card_lines(&usual, &config, Column::Work, computer);
1185        has(&work, "Mail from everyone comes at once.");
1186        has(&work, "“Work hours are over” does not come at this time.");
1187        // Under do-not-disturb: at most shown; the layers never let more through.
1188        let dnd = card_lines(&usual, &config, Column::Dnd, computer);
1189        has(&dnd, "Mail from everyone is shown without a notification, when its time lets it come.");
1190        // A phone screening calls: the calls' rows, voicemail, and the system's line.
1191        let phone = Reach { phone: true, calls: true, messages: true, screens: true, ..Reach::default() };
1192        let leisure = card_lines(&usual, &config, Column::Leisure, phone);
1193        has(&leisure, "Mail from your safe senders and calls and messages from your safe and neutral senders come at once.");
1194        assert!(leisure.iter().any(|l| l.starts_with("Calls from") && l.contains("voicemail")), "{leisure:#?}");
1195        assert_eq!(system_line(&usual, &config, Column::Leisure, phone), "The phone is not silenced.");
1196        let pause = system_line(&usual, &config, Column::Pause, phone);
1197        assert!(pause.starts_with("The phone is silenced; it lets through calls") && pause.contains("from your contacts") && pause.contains("alarms"), "{pause}");
1198        // A person's lines: a neutral contact, and the same on Always through.
1199        assert_eq!(
1200            channel_line(&usual, Channel::Mail, Person::Neutral, false, "attention-sheet-line").unwrap(),
1201            "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."
1202        );
1203        assert!(channel_line(&usual, Channel::Calls, Person::Neutral, true, "attention-sheet-line").unwrap().starts_with("Their calls ring at any time."));
1204        speak("fr");
1205        has(&card_lines(&usual, &config, Column::Work, computer), "Le courrier de tout le monde arrive tout de suite.");
1206        has(&card_lines(&usual, &config, Column::Leisure, computer), "Le courrier de vos expéditeurs sûrs arrive tout de suite.");
1207    }
1208
1209    #[test]
1210    fn hours_in_words() {
1211        let mut config = Config::default();
1212        config.windows = sioul_core::window::parse_ranges("mo-fr 09:00-17:00; sa 10:00-12:00");
1213        speak("en");
1214        assert_eq!(hours_words(&config, "work").as_deref(), Some("Monday to Friday, 09:00 to 17:00; Saturday, 10:00 to 12:00"));
1215        assert_eq!(when_words(&config, Column::Admin), text("attention-when-admin-none"));
1216        speak("fr");
1217        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"));
1218        assert_eq!(capital("le courrier"), "Le courrier");
1219        assert_eq!(small("Temps libre"), "temps libre");
1220    }
1221}