1use crate::backend::{config_path, load_config};
15use jiff::Zoned;
16use serde_json::{Value, json};
17use sioul_core::attention::{self, Attention, Column, Kind, Level, Now, Person, Phone, Preset, Row, Senders};
18use sioul_core::config::{Config, SettingValue};
19use sioul_core::everywhere::People;
20use sioul_core::i18n::Translator;
21use sioul_core::reach::{Channel, Who};
22
23#[derive(Debug, Clone, Copy, Default)]
27struct Reach {
28 phone: bool,
30 calls: bool,
32 messages: bool,
34 screens: bool,
36 phones: Phones,
38}
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42enum Phones {
43 #[default]
45 None,
46 WithoutAccess,
48 Holding,
51}
52
53impl Phones {
54 fn of(entries: &[sioul_sync::devices::Entry]) -> Phones {
56 let phones: Vec<_> = entries.iter().filter(|d| d.kind == sioul_sync::devices::PHONE && !d.left).collect();
57 if phones.iter().any(|d| d.notifications != Some(false)) {
58 Phones::Holding
59 } else if phones.is_empty() {
60 Phones::None
61 } else {
62 Phones::WithoutAccess
63 }
64 }
65}
66
67fn reach() -> Reach {
68 let phone = cfg!(target_os = "android");
69 let screens = crate::calls::screens_here();
70 let calls = screens || crate::calls::moment()["shown"] == true;
71 let phones = if phone { Phones::None } else { phones_known() };
72 Reach { phone, calls, messages: phone || phones == Phones::Holding, screens, phones }
73}
74
75fn phones_known() -> Phones {
77 let Some((_, Some((folder, key)))) = crate::share::vault() else { return Phones::None };
78 Phones::of(&sioul_sync::devices::all(&folder, &key).0)
79}
80
81fn channels(reach: Reach) -> Vec<Channel> {
83 let mut out = vec![Channel::Mail];
84 if reach.calls {
85 out.push(Channel::Calls);
86 }
87 if reach.messages {
88 out.push(Channel::Messages);
89 }
90 out
91}
92
93fn kinds(phone: bool) -> Vec<Kind> {
96 use Kind::*;
97 let mut out = vec![Codes, Doses];
98 if phone {
99 out.push(Wake);
100 }
101 out.extend([Alarms, Before, DayBefore, Dates, Needs, Move, WorkOver]);
102 if phone {
103 out.extend([AppAutomatons, AppAtOnce]);
104 } else {
105 out.extend([Sites, SitesLive, SiteCalls]);
106 }
107 out
108}
109
110fn shown_rows(phone: bool) -> Vec<Row> {
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
120thread_local! {
123 static SPOKEN: std::cell::Cell<Option<&'static Translator>> = const { std::cell::Cell::new(None) };
125}
126
127fn tr() -> &'static Translator {
129 SPOKEN.with(|spoken| spoken.get()).unwrap_or_else(crate::backend::tr)
130}
131
132fn 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
145fn 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
156fn 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
162fn 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
168fn 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
177fn 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(×, false)
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185enum Subject {
186 People(Channel, Person),
187 Own(Kind),
188}
189
190fn 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
209fn channels_words(channels: &[Channel]) -> String {
211 listed(&channels.iter().map(|c| text(&format!("attention-of-{}", c.id()))).collect::<Vec<_>>(), false)
212}
213
214fn 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
222fn 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 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
258fn 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
281fn 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
290fn 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
297fn 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(×, true));
316 tr().text("attention-until-times", Some(&args))
317}
318
319fn 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 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 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 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 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 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 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
415fn 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
425fn 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
467fn 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 let joined = parts.into_iter().reduce(|one, other| say("attention-when-list", &[("one", one), ("other", other)]))?;
520 Some(capital(&joined))
521}
522
523fn when_words(config: &Config, column: Column) -> String {
525 match column {
526 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
534fn 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
547fn column_now(now: &Now) -> Column {
549 now.times.first().copied().unwrap_or(Column::Work)
550}
551
552pub(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 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 "exact": !reach.phone || crate::alarms::exact(),
622 })
623 .to_string()
624}
625
626fn 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
635fn 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 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
695fn 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
708fn 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
730fn 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 let level = if person == Person::Always { attention.cell(Row::People(channel, Person::Always), column) } else { attention.person(channel, person, always, &Now::time(column)) };
744 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 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
790fn 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
801pub(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 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
815pub(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
825fn 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
833fn 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
840struct Someone {
844 card: Option<sioul_core::contacts::Contact>,
845 addresses: Vec<String>,
846}
847
848fn 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 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 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 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
935pub(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 "calls_title": text("calls-history-title"),
984 "calls": if who == Who::Blocked { Vec::new() } else { crate::calls::history(&someone.phones()) },
985 "texts_title": text("phonemsgs-history-title"),
987 "texts": if who == Who::Blocked { Vec::new() } else { crate::phonemsgs::history(&someone.phones()) },
988 "texts_id": crate::texts::conversation_of(&someone.phones()),
990 })
991 .to_string()
992}
993
994pub(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 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 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 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 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 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 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 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 #[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 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 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 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 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 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}