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), but the call screening's (numbers nobody knows and
112/// hidden numbers on calls: Settings ▸ This phone), and the kinds that act on this device.
113fn 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
121// ---------------------------------------------------------------- words
122
123thread_local! {
124    /// The language a test speaks; the window's otherwise.
125    static SPOKEN: std::cell::Cell<Option<&'static Translator>> = const { std::cell::Cell::new(None) };
126}
127
128/// This file's words: the window's language, or a test's.
129fn tr() -> &'static Translator {
130    SPOKEN.with(|spoken| spoken.get()).unwrap_or_else(crate::backend::tr)
131}
132
133/// A message with its arguments, as `backend::say` has it, in this file's language.
134fn 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
146/// A sentence about `what`, its verb agreeing: `plural` or not.
147fn 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
157/// The first letter in capitals.
158fn 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
163/// The first letter in small letters: a time named inside a sentence.
164fn 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
169/// "a, b and c"; with `or`, "a, b or c".
170fn 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
178/// "09:00, 13:00 and 18:00": the gathered times.
179fn 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(&times, false)
182}
183
184/// A subject of a sentence: some channels of some people, or one of Sioul's own kinds.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum Subject {
187    People(Channel, Person),
188    Own(Kind),
189}
190
191/// Who, in words: "your safe senders, your contacts and strangers"; everyone;
192/// everyone else (`rest`: the others were said before in the same card).
193fn 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    // "Neutral" said as "your contacts" (decision 17), apart from the kinds of senders.
198    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
211/// Whether the mail channel's words take a plural verb: English "mail" is
212/// one mass noun, French says « les courriels » (`attention-of-mail`'s `.plural`).
213fn mail_plural() -> bool {
214    tr().attribute("attention-of-mail", "plural").is_some_and(|p| p == "yes")
215}
216
217/// "mail, calls and messages", in the language's own words (French: "les courriels…").
218fn channels_words(channels: &[Channel]) -> String {
219    listed(&channels.iter().map(|c| text(&format!("attention-of-{}", c.id()))).collect::<Vec<_>>(), false)
220}
221
222/// Some channels from some people: "mail and messages from strangers".
223fn 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
230/// The people subjects of one sentence, said: per channel the people it
231/// names, then channels naming the same people together. `said` keeps, per
232/// channel, who was named already in the card, for "everyone else".
233/// Returns the words and whether they are plural.
234fn 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    // Each channel's people in words: everyone, everyone else, or by name.
246    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
266/// The people of a channel the matrix decides for (the states), the call
267/// screening's aside: numbers nobody knows and hidden numbers, on calls.
268fn 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
272/// Sioul's own kinds in words, the four reminders together when all are
273/// there: "your reminders, doses and the codes you ask for".
274fn 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
295/// A group of subjects in words: people first, then Sioul's own.
296/// A group of subjects in words, a sentence's subject each: the people,
297/// then Sioul's own, apart, so that each sentence stays short.
298fn 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
304// ---------------------------------------------------------------- a card per time
305
306/// The times a row comes in (not held): its own, for "waits until".
307fn 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
311/// Until when something held at `column` waits, in words: "until you wake",
312/// "for a work or admin time".
313fn 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(&times, true));
330    tr().text("attention-until-times", Some(&args))
331}
332
333/// What one time, or one layer, lets through, in sentences: at once, at the
334/// gathered times, when its event falls then, shown without a notification,
335/// waiting until…, not at all; Always through first when it says more than
336/// their own lists.
337fn 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    // Always through, as their own row says unless it says more.
343    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        // Said only when it gives them more than every list does then.
347        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    // Everyone else's rows, and Sioul's own, grouped by what they do here.
369    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    // The order said: at once, the event's, gathered, shown, waiting (calls to voicemail last), not at all.
376    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        // Codes not told stay on the Porch: a sentence of their own.
401        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        // Mail not at all stays in its lane: said apart from the kinds not at all.
407        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        // Texts in the shade without a sound (◑, decision 13): said apart from mail shown.
412        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    // Numbers nobody knows and hidden numbers: the call screening's, apart, whatever the time or the layer;
432    // where your contacts wait (do-not-disturb, the night, a pause), they wait too, but while you expect a
433    // call (review of part 2, F3).
434    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
441/// Whether the system's do-not-disturb holds during a column (docs/attention.md, §3.3).
442fn 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
451/// What the device's own do-not-disturb does then: not silenced; or, on a
452/// phone, whom Sioul's mode lets through.
453fn 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
494/// A set of hours in words, days with the same hours together: "Monday to
495/// Friday, 09:00 to 17:00"; none when none is set.
496fn 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    // Days with other hours after a semicolon, as each language writes it.
546    let joined = parts.into_iter().reduce(|one, other| say("attention-when-list", &[("one", one), ("other", other)]))?;
547    Some(capital(&joined))
548}
549
550/// When a time holds, in words.
551fn when_words(config: &Config, column: Column) -> String {
552    match column {
553        // A sentence, its full stop said by each language.
554        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
561/// The named holds outside the matrix, said in the time they act in (Q25).
562/// Free time's, Nothing at all, is its card's switch, its help under it.
563fn 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
574/// The time now as a column: the pause, sleep, Free time, then the hours; work when both open.
575fn column_now(now: &Now) -> Column {
576    now.times.first().copied().unwrap_or(Column::Work)
577}
578
579// ---------------------------------------------------------------- the tab
580
581/// The tab, as JSON (ReachesTab.qml): {now: sentences, column: the time
582/// now, presets [{id, label, current, changes}], changed, cards [{id,
583/// label, layer, now, when, lines, system, also}], switches \[lines\],
584/// channels [{id, label, note}], rows [ids shown here], grid (the
585/// matrix's, `attention::grid`), mail: {spam, areas}, gathered, exceptions:
586/// {always \[lines\], sites \[lines\], events, health}, phone}.
587pub(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    // The time now first, then the others in their order, then the layers.
604    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        // Android lets Sioul set exact alarms (This phone says when it does not).
648        "exact": !reach.phone || crate::alarms::exact(),
649    })
650    .to_string()
651}
652
653/// What Sioul's own spam filter does with strangers' mail, in one sentence
654/// (its table: Mail ⚙ ▸ Your own spam filter).
655fn 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
662/// The moment now, in sentences: the time and until when, the layers on top
663/// of it, then what comes at once, what is shown without a word, what waits
664/// (`Attention::person` and `level`: what the matrix decides now).
665/// The card "What reaches you now" (the status line's button, ReachCard.qml;
666/// the proposal's 3.5 and 3.6): what now is and what comes at once, in two
667/// sentences; on a phone, what it holds, by app, never by conversation; the
668/// call screening in a sentence while a phone of yours screens calls, and
669/// Expecting a call (`calls::moment`); Mail every minute (Real time).
670pub(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    // The conversations the phone holds, by their titles: on the phone alone, never sent elsewhere.
679    let talks = crate::appnotes::held_talks();
680    json!({ "now": said, "held": held, "talks": talks, "realtime": realtime, "screening": screening, "calls": calls }).to_string()
681}
682
683/// The call screening in a sentence, as `[screening]` says it (`Screening::of`).
684fn 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                // A text in the shade without a sound (◑, decision 13).
729                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    // Of Sioul's own, what matters most: codes, doses, reminders.
737    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
762/// What a channel's rows say of where they apply: calls once a phone screens
763/// them; messages on a phone that holds other apps' notifications, as far
764/// as the sharing knows (`Phones`); "" where they apply here.
765fn 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
775/// The exceptions in words: what Always through does on each channel, your
776/// sites' own choices (a computer), where events and Health's days say theirs.
777fn 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
797// ---------------------------------------------------------------- a channel, at each time
798
799/// What a channel does with someone at each time, in sentences: "Their mail
800/// comes at once during work, admin, leisure and meals. It is shown without
801/// a notification during sleep, a pause and free time." Then the layers,
802/// when they hold more. `id`: the sentence's key, "attention-sheet-line"
803/// for someone, "attention-always-line" for Always through.
804fn 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        // Always through's own row: "as their list" said as such, not as one person's.
810        let level = if person == Person::Always { attention.cell(Row::People(channel, Person::Always), column) } else { attention.person(channel, person, always, &Now::time(column)) };
811        // A phone cannot show what it holds: shown, not told, is held.
812        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    // The layers, when they hold more than the time does; said once when both hold the same.
839    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
857/// A level's word in the keys of the sentences.
858fn 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
868// ---------------------------------------------------------------- writing
869
870/// A row changed from the tab: its words whole (`words`, a JSON list), as
871/// `attention::apply` reads them. "" when kept, else why not.
872pub(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    // What the row showed: only the cells changed from it are set, over the row as it is now.
875    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
882/// A preset chosen ("usual", "quieter", "reachable"): every row as it says,
883/// the fixed cells untouched. "" when kept, else why not.
884pub(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
892// ---------------------------------------------------------------- a person's sheet
893
894/// The card with this file or UID, from every address book.
895fn 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
900/// A JSON array of addresses, or one address.
901fn 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
907/// Who the sheet is about: a card (from Contacts, or a sender's card), else
908/// a sender's addresses alone, or a caller's number alone (`tel:…`, from a
909/// call's line on the Porch).
910struct Someone {
911    card: Option<sioul_core::contacts::Contact>,
912    addresses: Vec<String>,
913}
914
915/// How numbers are written here: the contacts' country, else the language's.
916fn 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                // A caller's number: the card holding it, as the number's key.
927                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    /// Their own row's id (decision 16): their card's (`person.contact:<UID>`),
968    /// else their number's (`person.tel:<key>`) or their address's; none when
969    /// nothing of theirs is known. Never a name.
970    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            // A UID no row can name (spaces in it): their card's first number (review of part 6, F9).
977            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    /// Their entry on the Always through list, when they are on it.
989    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    /// Their list, as the card's ("Their list") or the address's ("Their mail").
1010    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
1023/// Their own times on their sheet (decision 16): {title, help, shown, note,
1024/// cells [{column, label, level, choices [{value, label}]}]}; none to set for
1025/// someone on Always through, nor for someone known by nothing here.
1026fn 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
1052/// A person's sheet, as JSON (PersonSheet.qml): {title, name, who, said,
1053/// choice, choices [{value, label}], own \[lines\], always, lines [one per
1054/// channel], note, problem, calls_title, calls [their calls of the month,
1055/// newest first]}. `key`: a card's file or UID; `addresses`: a sender's, a
1056/// JSON array or one, or a caller's number (`tel:…`) (their card found when
1057/// one has them).
1058pub(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            // Someone no list nor address book knows: their calls are the call screening's, apart.
1073            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        // Their calls of the month, from every phone sharing its own, rang or declined.
1102        "calls_title": text("calls-history-title"),
1103        "calls": if who == Who::Blocked { Vec::new() } else { crate::calls::history(&someone.phones()) },
1104        // Their messages of the week, from the phones that share them.
1105        "texts_title": text("phonemsgs-history-title"),
1106        "texts": if who == Who::Blocked { Vec::new() } else { crate::phonemsgs::history(&someone.phones()) },
1107        // texts: their conversation on the Texts page, when texts are read here ("" otherwise).
1108        "texts_id": crate::texts::conversation_of(&someone.phones()),
1109    })
1110    .to_string()
1111}
1112
1113/// A change made on a person's sheet (`verb`: "list", `value` a list's name
1114/// or "" for their categories; "always", `value` "on" or "off"). Blocked and
1115/// Always through exclude each other (Q3): choosing one takes them off the
1116/// other, said. Returns {sheet: the sheet again, line: what the status line says}.
1117pub(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            // Blocking takes them off Always through (`porch::set_standing`); someone written
1130            // on it by hand, with none of the card's lines, is taken off here.
1131            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            // Off the blocked list first: their own list as their categories say, neutral when these still block them.
1140            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                // A sender's addresses, or a caller's number (a call's line on the Porch).
1155                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            // What the list said: why it could not be written, or that they came off the blocked list.
1162            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        // A cell of their own row ({"column", "level"}: now, later, quiet under do-not-disturb, as).
1171        "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    /// Decision 16 of 18 October 2026: a person's own times on their sheet,
1193    /// a cell each (in the shade under do-not-disturb alone), their row by
1194    /// their card's UID, else their number; none on Always through; written
1195    /// with no name.
1196    #[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        // Written whole, as every language reads it (a national number reads by the language's country).
1203        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        // A UID with a space: their card's first number (review of part 6, F9).
1206        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        // Written as the sheet writes it: their row by its id, no name.
1218        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    /// The card's call screening in a sentence, for each rule and both hours, in both languages.
1231    #[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    /// This thread speaks `language` from now on.
1253    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    /// A sentence said whole: no key, no placeable, a capital, a full stop; French typography, « type ».
1259    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    /// Every cell at the last value it may take, Always through's layers at ☆: each value said somewhere.
1273    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    /// On a computer, a phone in the sharing holds messages unless its entry
1329    /// says it does not give Sioul notification access (an older Sioul's,
1330    /// which says nothing, holds them); when every phone says no, the
1331    /// messages' rows say why they do not apply, and the moment leaves messages out.
1332    #[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        // The moment and the channels said: messages only from a phone that holds them.
1352        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        // Where every list comes at once already, Always through gives nothing more: not said.
1366        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        // Under do-not-disturb: at most shown; the layers never let more through.
1379        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        // A phone screening calls: the calls' rows, voicemail, and the system's line.
1382        let phone = Reach { phone: true, calls: true, messages: true, screens: true, ..Reach::default() };
1383        let leisure = card_lines(&usual, &config, Column::Leisure, phone);
1384        // Groups at once in leisure too (decision 14 of 18 October 2026).
1385        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        // Under do-not-disturb, their texts in the shade without a sound (decision 13); calls to voicemail.
1387        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        // Numbers nobody knows and hidden numbers: the call screening's, apart: no row of theirs in the grid, a
1394        // line of their own in each card where calls apply, never said by the time; the phone's mode lets anyone's
1395        // call through when the screening lets them ring (here: off).
1396        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            // Where your contacts wait (do-not-disturb's card, the night, a pause), so do they (review of part 2, F3).
1402            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        // Strangers ringing by an old cell of their Calls row, where their mail and messages wait: never said.
1406        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        // The screening off: anyone's call through the mode where your contacts ring then, never where they wait.
1413        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        // A person's lines: a neutral contact, and the same on Always through.
1419        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}