1use crate::backend::qobject::Sioul;
30use crate::backend::{QtThread, Shared, load_config, mode_json, say, tr};
31use crate::dnd::{Ask, Report, Which};
32use cxx_qt::Threading;
33use cxx_qt_lib::QString;
34use jiff::Zoned;
35use std::pin::Pin;
36use sioul_core::config::Config;
37use sioul_core::everywhere::{self as rules, Now, People, Person, Sources, Switch, Why};
38use sioul_core::quiet::Overrides;
39use std::sync::atomic::{AtomicBool, Ordering};
40use std::sync::{Arc, Mutex, PoisonError};
41use std::time::{Duration, Instant};
42
43fn here_id() -> String {
45 sioul_sync::share::Here::load(&sioul_core::config::state_dir()).id
46}
47
48fn sources(overrides: &Overrides, blocks: &sioul_core::quiet::Blocks, now: &Zoned) -> Sources {
50 let stamp = now.timestamp().as_second();
51 let free = overrides.free_from().map(|since| (since, sioul_core::pause::free_until(since, blocks, now.time_zone())));
52 let sleep = blocks.at(stamp, &["sleep", "nap"]).map(|k| (k.start, k.end));
53 let focus = sioul_core::timelog::running().and_then(|r| rules::focus(&r, stamp));
54 Sources { paused: overrides.paused_from(), free, sleep, focus }
55}
56
57fn blocks(now: &Zoned) -> sioul_core::quiet::Blocks {
61 if crate::steps::in_service() { sioul_core::quiet::Blocks::read(now, &[]) } else { crate::hours::blocks(now) }
62}
63
64pub(crate) fn rest_now() -> (bool, bool) {
67 let now = Zoned::now();
68 let paused = Overrides::load(&Overrides::default_path()).paused_from().is_some();
69 (blocks(&now).at(now.timestamp().as_second(), &["sleep", "nap"]).is_some(), paused)
70}
71
72struct Look {
75 config: Config,
76 overrides: Overrides,
77 switch: Switch,
78 state: Now,
79 mode: sioul_core::quiet::Mode,
80 now: Zoned,
81}
82
83impl Look {
84 fn now() -> Look {
85 let config = load_config();
86 let now = Zoned::now();
87 let overrides = Overrides::load(&Overrides::default_path());
88 let switch = Switch::load(&Switch::default_path());
89 let blocks = blocks(&now);
90 let state = rules::now(&config.dnd, &sources(&overrides, &blocks, &now), switch.latest().as_ref(), now.timestamp().as_second());
91 let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &overrides, &blocks, &now);
92 Look { config, overrides, switch, state, mode, now }
93 }
94
95 fn asks(&self) -> [(Which, Option<Ask>); 3] {
104 use sioul_core::attention::{Column, Now as Moment, Senders};
105 let (config, state) = (&self.config, &self.state);
106 let gnome = config.pause.gnome;
107 let paused = state.has(Why::Paused);
108 let free = state.has(Why::FreeTime) && !paused;
109 let attention = sioul_core::attention::Attention::of(config);
110 let phone = phone_does();
111 let mode = &self.mode;
112 let through = self.through();
113 let moment = |times: Vec<Column>, dnd: bool, nothing: bool| {
114 let mut moment = Moment::of(mode);
115 moment.times = times;
116 moment.dnd = dnd;
117 moment.nothing = nothing;
118 moment.through = through;
119 moment
120 };
121 let ask = |which: Which, moment: Moment| {
122 let silence = attention.silence_at(&moment, phone);
123 Ask { which, people: silence.calls != Senders::None, doses: silence.doses, desktop: gnome, silence: Some(silence), stack: true }
124 };
125 let latest = self.switch.latest();
128 let from_system = latest.as_ref().is_some_and(|p| p.on && p.via == rules::VIA_SYSTEM);
129 let stack = !(from_system && rules::held_by_system(state, latest.as_ref(), &here_id()));
130 let global = if state.has(Why::Sleep) { vec![Column::Sleep] } else { Moment::of(mode).times };
131 [
132 (Which::Paused, paused.then(|| ask(Which::Paused, moment(vec![Column::Pause], false, false)))),
133 (Which::FreeTime, free.then(|| ask(Which::FreeTime, moment(vec![Column::Free], false, sioul_core::pause::nothing_now(&self.overrides, &config.free_time))))),
134 (Which::Global, state.global().then(|| Ask { stack, ..ask(Which::Global, moment(global, state.gates(), false)) })),
135 ]
136 }
137
138 fn through(&self) -> bool {
140 crate::calls::through_now(&self.switch).is_some_and(|t| t.holds(self.now.timestamp().as_millisecond()))
141 }
142
143 fn signature(&self) -> String {
146 self.asks().iter().map(|(which, ask)| format!("{which:?}:{}", ask.as_ref().map_or_else(|| "-".to_string(), |a| format!("{}{}{}{}{}", u8::from(a.people), u8::from(a.doses), u8::from(a.desktop), u8::from(a.stack), a.silence.map(|s| format!("{:?}{:?}{}{}{}", s.calls, s.messages, u8::from(s.repeat), u8::from(s.conversations), u8::from(s.events))).unwrap_or_default())))).collect::<Vec<_>>().join(",")
147 }
148
149 fn next_change(&self) -> Option<i64> {
153 let stamp = self.now.timestamp().as_second();
154 let night = if self.config.dnd.sleep { blocks(&self.now).kept.iter().filter(|k| (k.kind == "sleep" || k.kind == "nap") && k.start > stamp).map(|k| k.start).min() } else { None };
155 let time = if self.state.gates() { self.mode.until.as_ref().map(|u| u.timestamp().as_second()) } else { None };
156 let through = crate::calls::through_now(&self.switch).filter(|t| t.holds(stamp * 1000) && t.until > 0).map(|t| (t.until + 999) / 1000);
157 [self.state.next_end(), night, time, through].into_iter().flatten().filter(|t| *t > stamp).min()
158 }
159}
160
161fn phone_does() -> sioul_core::attention::Phone {
170 if !cfg!(target_os = "android") {
171 return sioul_core::attention::Phone::default();
172 }
173 sioul_core::attention::Phone { screens: crate::calls::screens_here() }
174}
175
176pub(crate) fn now() -> Now {
178 Look::now().state
179}
180
181pub(crate) fn holds_others() -> bool {
188 static KEPT: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
189 if let Ok(kept) = KEPT.lock()
190 && let Some((at, holds)) = *kept
191 && at.elapsed() < Duration::from_secs(5)
192 {
193 return holds;
194 }
195 let holds = now().gates();
196 if let Ok(mut kept) = KEPT.lock() {
197 *kept = Some((Instant::now(), holds));
198 }
199 holds
200}
201
202pub(crate) fn gate() -> Option<Option<i64>> {
206 let state = now();
207 let gates: Vec<&sioul_core::everywhere::Held> = state.holds.iter().filter(|h| matches!(h.why, Why::Focus | Why::Manual)).collect();
208 let until = if gates.iter().any(|h| h.until.is_none()) { None } else { gates.iter().filter_map(|h| h.until).max() };
209 (!gates.is_empty()).then_some(until)
210}
211
212static AGAIN: AtomicBool = AtomicBool::new(false);
216static BUSY: Mutex<()> = Mutex::new(());
217
218pub(crate) fn apply() {
226 AGAIN.store(true, Ordering::SeqCst);
227 while AGAIN.load(Ordering::SeqCst) {
228 let Some(busy) = crate::backend::one_at_a_time(&BUSY) else { return };
229 if !AGAIN.swap(false, Ordering::SeqCst) {
230 return;
231 }
232 apply_once();
233 drop(busy);
234 }
235}
236
237fn apply_once() {
238 let look = Look::now();
239 let mut reports: Vec<(Which, Report)> = Vec::new();
240 for (which, ask) in look.asks() {
241 match ask {
242 Some(ask) => reports.push((which, crate::dnd::enter(&ask))),
243 None => {
244 crate::dnd::leave(which);
245 }
246 }
247 }
248 let said = match look.state.why() {
250 Some(Why::Paused) => Which::Paused,
251 Some(Why::FreeTime) => Which::FreeTime,
252 _ => Which::Global,
253 };
254 let report = reports.iter().find(|(w, _)| *w == said).or(reports.first()).map(|(_, r)| r.clone()).unwrap_or_default();
255 let why = look.state.why().map(Why::id).unwrap_or_default().to_string();
256 let on = look.state.on();
260 crate::dnd::tell_system(on);
261 let was = crate::dnd::on_here(on);
262 let acted = was && !on && crate::dnd::system_off(look.config.pause.gnome);
263 let still = if on || acted { None } else { crate::dnd::still_on() };
264 write_own(|own| {
265 own.closed = false;
267 own.why = why.clone();
268 own.silenced = report.on && !why.is_empty();
269 own.turned_off = report.turned_off && !why.is_empty();
270 own.system_on = still.is_some();
271 own.line = match &still {
272 _ if !why.is_empty() => report.line.clone(),
273 Some(still) => still.line.clone(),
274 None => String::new(),
275 };
276 });
277 crate::steps::tell_note();
281 crate::homecard::dnd_seen(moment());
282 crate::steps::next(look.next_change());
284 crate::calls::refresh(false);
287}
288
289pub(crate) fn note() -> (String, String, Option<i64>) {
295 let look = Look::now();
296 let tr = tr();
297 let moment = moment_of(&look);
298 let dnd = if moment["on"] == true {
299 moment["line"].as_str().unwrap_or_default().to_string()
300 } else if moment["system_on"] == true {
301 moment["system_line"].as_str().unwrap_or_default().to_string()
302 } else {
303 String::new()
304 };
305 let dnd = if dnd.is_empty() { tr.text("steps-note-dnd-off", None) } else { dnd };
306 let mode = crate::hours::mode_at(&look.now);
309 let times: Vec<String> = sioul_core::attention::Now::of(&mode).times.iter().map(|c| sioul_core::attention::column_label(tr, *c)).collect();
310 let time = match times.as_slice() {
311 [] => String::new(),
312 [one] => one.clone(),
313 [rest @ .., last] => format!("{} {} {}", rest.join(", "), tr.text("word-and", None), last.to_lowercase()),
314 };
315 let until = if mode.paused() { None } else { mode.until.clone() };
316 let mut args = sioul_core::i18n::args();
317 args.set("time", time);
318 let title = match &until {
319 Some(until) => {
320 args.set("until", sioul_core::quiet::until_text(tr, until, &look.now));
321 tr.text("steps-note-time", Some(&args))
322 }
323 None => tr.text("steps-note-time-open", Some(&args)),
324 };
325 (title, dnd, until.map(|u| u.timestamp().as_second()))
326}
327
328pub(crate) fn wanted() -> (String, String) {
332 let look = Look::now();
333 (look.signature(), moment_of(&look)["line"].as_str().unwrap_or_default().to_string())
334}
335
336fn write_own(change: impl FnOnce(&mut rules::Device)) {
340 let here = here_id();
341 let now = jiff::Timestamp::now().as_millisecond();
342 let result = rules::change_own(&Switch::default_path(), &here, |switch| {
343 let after = switch.latest_known(None) + 1;
344 let own = switch.device.entry(here.clone()).or_default();
345 let before = own.clone();
346 change(own);
347 own.kind = crate::devices::kind().to_string();
348 own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
349 let changed = *own != before;
350 if changed {
351 own.at = now.max(after);
353 }
354 changed
355 });
356 if let Err(e) = result {
357 eprintln!("sioul: do-not-disturb: {e}");
358 }
359}
360
361pub(crate) fn toggle(mut sioul: Pin<&mut Sioul>, on: bool, minutes: i32) {
365 let now = Zoned::now();
366 let until = match minutes {
367 m if m > 0 => Some(now.timestamp().as_second() + i64::from(m) * 60),
368 -1 => crate::hours::mode_at(&now).until.map(|u| u.timestamp().as_second()),
369 _ => None,
370 };
371 if let Err(e) = press(on, until.filter(|_| on)) {
372 sioul.as_mut().set_status(QString::from(&e));
373 return;
374 }
375 let (qt, shared) = (sioul.qt_thread(), sioul.shared());
376 std::thread::spawn(move || {
377 apply();
378 let _ = qt.queue(|mut sioul| sioul.as_mut().set_mode(QString::from(&mode_json())));
379 crate::share::exchange(&qt, &shared);
380 });
381}
382
383pub(crate) fn press(on: bool, until: Option<i64>) -> Result<(), String> {
387 press_via(on, until, "")
388}
389
390fn press_via(on: bool, until: Option<i64>, via: &str) -> Result<(), String> {
393 let here = here_id();
394 let now = jiff::Timestamp::now().as_millisecond();
395 rules::change_own(&Switch::default_path(), &here, |switch| {
396 switch.press_via(&here, on, until.map_or(0, |u| u.saturating_mul(1000)), now, via);
397 let own = switch.device.entry(here.clone()).or_default();
398 own.kind = crate::devices::kind().to_string();
399 own.name = if own.kind == "phone" { String::new() } else { crate::devices::name() };
400 true
401 })?;
402 Ok(())
403}
404
405static HEARING: Mutex<()> = Mutex::new(());
410
411pub(crate) fn heard_from_system(system_on: bool) -> bool {
418 let _one = HEARING.lock().unwrap_or_else(PoisonError::into_inner);
419 let Some(on) = rules::heard(now().on(), system_on) else { return false };
420 if let Err(e) = press_via(on, None, rules::VIA_SYSTEM) {
421 eprintln!("sioul: do-not-disturb: {e}");
422 return false;
423 }
424 apply();
425 told();
426 true
427}
428
429static WINDOW: Mutex<Option<(QtThread, Arc<Shared>)>> = Mutex::new(None);
432
433pub(crate) fn started(qt: &QtThread, shared: &Arc<Shared>) {
436 let mut window = WINDOW.lock().unwrap_or_else(PoisonError::into_inner);
437 if window.is_none() {
438 *window = Some((qt.clone(), Arc::clone(shared)));
439 crate::dnd::listen(Arc::new(|on| {
440 std::thread::spawn(move || heard_from_system(on));
441 }));
442 }
443}
444
445fn told() {
449 let window = WINDOW.lock().ok().and_then(|window| window.clone());
450 match window {
451 Some((qt, shared)) => {
452 let _ = qt.queue(|mut sioul| sioul.as_mut().set_mode(QString::from(&mode_json())));
453 crate::share::exchange(&qt, &shared);
454 }
455 None => crate::steps::send_now(),
456 }
457}
458
459pub(crate) fn closing() {
465 if cfg!(target_os = "android") {
466 return;
467 }
468 let still = crate::dnd::still_after_quit();
469 write_own(|own| own.closing(still));
470}
471
472pub(crate) fn toggle_here() {
475 if let Err(e) = press(!now().on(), None) {
476 eprintln!("sioul: do-not-disturb: {e}");
477 return;
478 }
479 apply();
480 told();
481}
482
483pub(crate) fn again() {
488 if Look::now().switch.latest().is_some_and(|p| p.on && p.via == rules::VIA_SYSTEM)
489 && let Err(e) = press(true, None)
490 {
491 eprintln!("sioul: do-not-disturb: {e}");
492 }
493 for ask in Look::now().asks().into_iter().filter_map(|(_, ask)| ask) {
494 crate::dnd::again(&ask);
495 }
496 apply();
497 told();
498}
499
500fn name_of(switch: &Switch, id: &str) -> String {
504 let (kind, name) = switch.device.get(id).filter(|d| !d.kind.is_empty()).map(|d| (d.kind.clone(), d.name.clone())).or_else(|| crate::devices::kind_and_name(id)).unwrap_or_default();
505 match kind.as_str() {
506 "phone" => tr().text("dnd-device-phone", None),
507 _ if !name.trim().is_empty() => name,
508 _ => tr().text("dnd-device-other", None),
509 }
510}
511
512fn heard(here: &str) -> Vec<(String, i64)> {
514 let Some((_, Some((folder, _)))) = crate::share::vault() else { return Vec::new() };
515 sioul_sync::share::others(&folder, here).into_iter().map(|o| (o.id, o.heard)).collect()
516}
517
518fn when(stamp: i64, now: &Zoned) -> String {
520 jiff::Timestamp::from_second(stamp).map(|t| sioul_core::quiet::until_text(tr(), &t.to_zoned(now.time_zone().clone()), now)).unwrap_or_default()
521}
522
523pub(crate) fn moment() -> serde_json::Value {
528 moment_of(&Look::now())
529}
530
531fn moment_of(look: &Look) -> serde_json::Value {
532 let state = &look.state;
533 let now = &look.now;
534 let stamp = now.timestamp().as_second();
535 let end = if crate::steps::in_service() { None } else { crate::hours::mode_at(now).until.map(|u| u.timestamp().as_second()).filter(|u| *u > stamp && *u - stamp < 24 * 3600) };
537 let base = serde_json::json!({
538 "button": look.config.dnd.button,
539 "end_time": end.map(|e| when(e, now)).unwrap_or_default(),
540 "end_at": end.unwrap_or(0),
541 });
542 let here = here_id();
543 let own = look.switch.device.get(&here);
544 if !state.on() {
545 let system_line = own.filter(|d| d.system_on && d.why.is_empty()).map(|d| d.line.clone()).unwrap_or_default();
547 let mut off = serde_json::json!({
548 "on": false, "why": "", "global": false, "manual": false, "until": "", "until_at": 0, "line": "", "why_line": "", "details": [], "everywhere": false, "here": false,
549 "here_off": false, "here_offers": [],
550 "system_on": !system_line.is_empty(), "system_offers": system_offers(&system_line), "system_line": system_line,
551 "last_off": last_off(look),
552 });
553 merge(&mut off, base);
554 return off;
555 }
556 let (silenced, line) = own.filter(|d| !d.why.is_empty()).map_or((false, String::new()), |d| (d.silenced, d.line.clone()));
558 let since = state.holds.first().map_or(0, |h| h.since.saturating_mul(1000));
560 let others = rules::others(&look.switch, &here, &heard(&here), stamp, since, &|id| crate::devices::kind_and_name(id).is_some());
562 let until = state.until();
563 let until_words = until.map(|u| when(u, now)).unwrap_or_default();
564 let turned_off = own.is_some_and(|d| d.turned_off && !d.why.is_empty());
565 let said = rules::said(state, rules::Here { silenced, line: &line, turned_off }, &others, &until_words, &|id| name_of(&look.switch, id), tr());
566 let mut on = serde_json::json!({
567 "on": true,
568 "why": state.why().map(Why::id).unwrap_or_default(),
569 "global": state.global(),
570 "manual": state.has(Why::Manual),
571 "until": until_words,
572 "until_at": until.unwrap_or(0),
573 "line": said.line,
574 "why_line": said.why,
575 "details": said.details,
576 "everywhere": said.everywhere,
577 "here": said.here,
578 "here_off": turned_off,
579 "here_offers": system_offers(if turned_off { &line } else { "" }),
580 "system_on": false, "system_line": "", "system_offers": [], "last_off": "",
581 });
582 merge(&mut on, base);
583 on
584}
585
586fn system_offers(line: &str) -> serde_json::Value {
589 if line.is_empty() || !cfg!(target_os = "android") {
590 return serde_json::json!([]);
591 }
592 serde_json::json!([{ "key": "modes", "label": crate::dnd::offer_label("modes") }])
593}
594
595fn last_off(look: &Look) -> String {
599 let Some(press) = look.switch.latest().filter(|p| !p.on && p.via == rules::VIA_SYSTEM) else { return String::new() };
600 let at = press.at / 1000;
601 if look.now.timestamp().as_second() - at > 12 * 3600 {
602 return String::new();
603 }
604 let Ok(time) = jiff::Timestamp::from_second(at) else { return String::new() };
605 let time = time.to_zoned(look.now.time_zone().clone()).strftime("%H:%M").to_string();
606 say("dnd-last-off", &[("device", name_of(&look.switch, &press.from)), ("time", time)])
607}
608
609fn merge(into: &mut serde_json::Value, from: serde_json::Value) {
610 if let (Some(into), serde_json::Value::Object(from)) = (into.as_object_mut(), from) {
611 into.extend(from);
612 }
613}
614
615fn region(config: &Config) -> Option<&'static sioul_core::phones::Region> {
619 sioul_core::phones::chosen(config.contacts.region.as_deref(), &tr().text("qt-locale", None))
620}
621
622pub(crate) fn setup() -> String {
626 setup_with(String::new())
627}
628
629fn setup_with(said: String) -> String {
630 let config = load_config();
631 let mut here = crate::dnd::can().json();
632 let mut lines = Vec::new();
633 let offers: Vec<String> = here["offers"].as_array().map(|o| o.iter().filter_map(|x| x["key"].as_str().map(str::to_string)).collect()).unwrap_or_default();
634 if !cfg!(target_os = "android") && here["on"] == true {
635 lines.push(tr().text(if offers.iter().any(|o| o == "plasma") { "dnd-setup-plasma-mail" } else { "dnd-setup-critical" }, None));
637 }
638 here["lines"] = serde_json::json!(lines);
639 let people = People::read(&People::default_path());
640 let problem = people.as_ref().err().map(|why| say("dnd-setup-unreadable", &[("why", why.clone())])).unwrap_or_default();
641 let people: Vec<serde_json::Value> = people
642 .unwrap_or_default()
643 .people
644 .iter()
645 .map(|p| {
646 let mut notes = Vec::new();
647 if p.phones.is_empty() {
648 notes.push(tr().text("dnd-setup-no-number", None));
649 }
650 if p.emails.is_empty() {
651 notes.push(tr().text("dnd-setup-no-email", None));
652 }
653 serde_json::json!({ "id": p.id, "name": p.name, "phones": p.phones, "emails": p.emails, "notes": notes })
654 })
655 .collect();
656 let mut setup = serde_json::json!({
657 "here": here,
658 "gnome": config.pause.gnome,
659 "people": people,
660 "problem": problem,
661 "said": said,
662 "android": cfg!(target_os = "android"),
663 });
664 if cfg!(target_os = "android") {
665 setup["stars"] = stars(&config);
666 setup["steps"] = crate::steps::setup(&config);
667 }
668 setup.to_string()
669}
670
671fn stars(config: &Config) -> serde_json::Value {
676 let people = People::load(&People::default_path());
677 let asked: Vec<serde_json::Value> = people.people.iter().map(|p| serde_json::json!({ "id": p.id, "phones": p.phones })).collect();
678 let answer = crate::steps::java("stars", &serde_json::json!({ "people": asked }).to_string());
679 let _ = config;
680 stars_of(&people, &answer, tr())
681}
682
683fn stars_of(people: &People, answer: &serde_json::Value, tr: &sioul_core::i18n::Translator) -> serde_json::Value {
685 if answer["permission"] != true {
686 return serde_json::json!({ "permission": false, "summary": tr.text("dnd-stars-permission", None), "people": [] });
687 }
688 let found = answer["people"].as_array().cloned().unwrap_or_default();
689 let mut missing = 0;
690 let listed: Vec<serde_json::Value> = people
691 .people
692 .iter()
693 .map(|p| {
694 let seen = found.iter().find(|f| f["id"] == p.id.as_str());
695 let state = if p.phones.is_empty() { "no-number" } else { seen.and_then(|f| f["state"].as_str()).unwrap_or("unknown") };
696 if state == "not-starred" || state == "unknown" {
697 missing += 1;
698 }
699 serde_json::json!({ "id": p.id, "name": if p.name.is_empty() { p.emails.first().cloned().unwrap_or_default() } else { p.name.clone() }, "state": state, "contact": seen.and_then(|f| f["contact"].as_str()).unwrap_or_default() })
700 })
701 .collect();
702 let summary = if missing == 0 {
703 tr.text("dnd-stars-all", None)
704 } else {
705 tr.text("dnd-stars-missing", Some(&tr.counted(missing)))
706 };
707 serde_json::json!({ "permission": true, "summary": summary, "missing": missing, "people": listed })
708}
709
710pub(crate) fn change(verb: &str, json: &str) -> (String, bool) {
714 let changes = matches!(verb, "add-safe" | "add-contact" | "add" | "edit" | "remove" | "gnome");
715 let answer = change_here(verb, json);
716 if verb == "gnome" {
717 std::thread::spawn(apply);
718 }
719 (answer, changes)
720}
721
722fn change_here(verb: &str, json: &str) -> String {
723 let asked: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
724 let config = load_config();
725 let region = region(&config);
726 let path = People::default_path();
727 let text = |key: &str| asked[key].as_str().unwrap_or_default().trim().to_string();
728 let lines = |key: &str| asked[key].as_str().unwrap_or_default().lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect::<Vec<_>>();
729 let said = match verb {
730 "add-safe" => {
733 let list = sioul_core::porch::SenderList::load(&config.safe_senders_path());
734 let entries: Vec<String> = list
735 .entries()
736 .into_iter()
737 .chain(list.categories().iter().map(|c| format!("{}{c}", sioul_core::porch::CATEGORY)))
738 .chain(list.cards().iter().map(|c| format!("{}{c}", sioul_core::porch::CONTACT)))
739 .chain(list.numbers().iter().map(|n| format!("{}{n}", sioul_core::porch::TEL)))
740 .collect();
741 let (found, patterns) = rules::from_safe(&entries, &sioul_core::contacts::all());
742 let mut added = 0;
743 match rules::change_people(&path, |people| {
744 for person in found {
745 let before = people.people.len();
746 people.add(person, region);
747 added += people.people.len() - before;
748 }
749 }) {
750 Ok(_) => {
751 let mut said = tr().text("dnd-setup-added-safe", Some(&tr().counted(added)));
752 if patterns > 0 {
753 said = format!("{said} {}", tr().text("dnd-setup-patterns", Some(&tr().counted(patterns))));
754 }
755 said
756 }
757 Err(why) => say("dnd-setup-unreadable", &[("why", why)]),
758 }
759 }
760 "add-contact" => {
762 let uid = text("uid");
763 match sioul_core::contacts::all().into_iter().find(|c| c.uid == uid) {
764 Some(card) => {
765 let person = rules::from_contact(&card);
766 let unblocked = unblock(&config, &person);
767 let said = edit_people(&path, |people| {
768 people.add(person, region);
769 });
770 with_unblocked(said, unblocked)
771 }
772 None => String::new(),
773 }
774 }
775 "add" | "edit" => {
777 let person = Person { id: text("id"), name: text("name"), phones: lines("phones"), emails: lines("emails"), ..Person::default() };
778 if person.name.is_empty() && person.phones.is_empty() && person.emails.is_empty() {
779 String::new()
780 } else {
781 let unblocked = unblock(&config, &person);
782 let said = edit_people(&path, |people| match people.people.iter_mut().find(|p| verb == "edit" && p.id == person.id) {
783 Some(known) => {
784 known.name = person.name;
785 known.phones = person.phones;
786 known.emails = person.emails;
787 }
788 None => {
789 people.add(Person { id: String::new(), ..person }, region);
790 }
791 });
792 with_unblocked(said, unblocked)
793 }
794 }
795 "remove" => edit_people(&path, |people| {
796 people.remove(&text("id"));
797 }),
798 "search" => {
800 let all = sioul_core::contacts::all();
801 let query = text("query");
802 let found: Vec<serde_json::Value> = sioul_core::contacts::search(&all, &query)
803 .into_iter()
804 .filter(|c| !c.phones.is_empty() || !c.emails.is_empty())
805 .take(20)
806 .map(|c| serde_json::json!({ "uid": c.uid, "name": c.name, "detail": c.phones.first().map(|p| p.value.clone()).or_else(|| c.emails.first().map(|e| e.value.clone())).unwrap_or_default() }))
807 .collect();
808 return serde_json::json!({ "found": found, "none": if found.is_empty() { tr().text("dnd-setup-no-match", None) } else { String::new() } }).to_string();
809 }
810 "open" => {
812 crate::dnd::open(&text("key"));
813 String::new()
814 }
815 "again" => {
817 std::thread::spawn(again);
818 String::new()
819 }
820 "open-contact" => {
822 crate::steps::java("open-contact", &serde_json::json!({ "uri": text("contact") }).to_string());
823 String::new()
824 }
825 "add-to-contacts" => {
826 if let Some(p) = People::load(&path).people.into_iter().find(|p| p.id == text("id")) {
827 crate::steps::java("add-contact", &serde_json::json!({ "name": p.name, "phones": p.phones, "emails": p.emails }).to_string());
828 }
829 String::new()
830 }
831 "allow-contacts" => {
832 crate::steps::ask_contacts();
833 String::new()
834 }
835 "battery" => {
836 crate::steps::java("ask-battery", "{}");
837 String::new()
838 }
839 "background" => crate::steps::set_background(asked["on"] == true).err().unwrap_or_default(),
841 "gnome" => sioul_core::settings::apply(&crate::backend::config_path(), &config, "pause.gnome", &sioul_core::config::SettingValue::Bool(asked["on"] == true)).err().unwrap_or_default(),
843 _ => String::new(),
844 };
845 setup_with(said)
846}
847
848fn unblock(config: &Config, person: &Person) -> bool {
851 sioul_core::porch::unblock_person(config, &person.emails, &person.phones, &person.contact).unwrap_or_else(|e| {
852 eprintln!("sioul: {e}");
853 false
854 })
855}
856
857fn with_unblocked(said: String, unblocked: bool) -> String {
859 if !unblocked {
860 return said;
861 }
862 [said, tr().text("attention-unblocked", None)].into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ")
863}
864
865fn edit_people(path: &std::path::Path, change: impl FnOnce(&mut People)) -> String {
867 match rules::change_people(path, change) {
868 Ok(_) => String::new(),
869 Err(why) => say("dnd-setup-unreadable", &[("why", why)]),
870 }
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876
877 #[test]
878 fn the_signature_follows_what_is_asked() {
879 let look = |paused: bool, manual: bool| {
880 let config = Config::default();
881 let press = manual.then(|| rules::Press { at: 1_000, on: true, until: 0, from: "x".into(), via: String::new() });
882 let sources = Sources { paused: paused.then_some(1), ..Sources::default() };
883 let state = rules::now(&config.dnd, &sources, press.as_ref(), 100);
884 let now = Zoned::now();
885 let mode = sioul_core::quiet::mode(&config.week_hours(), &config.time_off, &Overrides::default(), &sioul_core::quiet::Blocks::default(), &now);
886 Look { config, overrides: Overrides::default(), switch: Switch::default(), state, mode, now }
887 };
888 let off = look(false, false).signature();
889 assert_eq!(off, "Paused:-,FreeTime:-,Global:-");
890 assert_ne!(look(false, true).signature(), off);
891 assert_ne!(look(true, false).signature(), look(false, true).signature());
892 assert_eq!(look(true, true).asks().iter().filter(|(_, a)| a.is_some()).count(), 2, "the pause and the switch, each its own mode");
893 let mut through = look(true, false);
895 sioul_core::calls::press(&mut through.switch, "phone", true, 0, Zoned::now().timestamp().as_millisecond());
896 let calls = |look: &Look| look.asks()[0].1.as_ref().and_then(|a| a.silence).map(|s| s.calls);
897 assert_eq!((calls(&through), calls(&look(true, false))), (Some(sioul_core::attention::Senders::Anyone), Some(sioul_core::attention::Senders::Starred)));
898 assert_ne!(through.signature(), look(true, false).signature());
899 }
900
901 #[test]
902 fn the_phone_says_who_on_the_list_is_not_starred_there() {
903 let mut people = People::default();
904 for (name, phone) in [("Alice", "+262639980001"), ("Bob", "+262639980002"), ("Carol", "+262639980003"), ("Dan", "")] {
905 people.add(Person { name: name.into(), phones: if phone.is_empty() { Vec::new() } else { vec![phone.into()] }, emails: vec![format!("{name}@example.org")], ..Person::default() }, None);
906 }
907 let id = |name: &str| people.people.iter().find(|p| p.name == name).unwrap().id.clone();
908 let answer = serde_json::json!({ "permission": true, "people": [
909 { "id": id("Alice"), "state": "starred", "contact": "" },
910 { "id": id("Bob"), "state": "not-starred", "contact": "content://com.android.contacts/contacts/lookup/x/1" },
911 { "id": id("Carol"), "state": "unknown", "contact": "" },
912 ] });
913 let en = sioul_core::i18n::Translator::new("en");
914 let said = stars_of(&people, &answer, &en);
915 assert_eq!(said["missing"], 2);
916 assert_eq!(said["summary"], "Two people on your list are not starred on this phone.");
917 let states: Vec<&str> = said["people"].as_array().unwrap().iter().map(|p| p["state"].as_str().unwrap()).collect();
918 assert_eq!(states, ["starred", "not-starred", "unknown", "no-number"]);
919 assert_eq!(said["people"][1]["contact"], "content://com.android.contacts/contacts/lookup/x/1");
920 let all = serde_json::json!({ "permission": true, "people": [{ "id": id("Alice"), "state": "starred" }, { "id": id("Bob"), "state": "starred" }, { "id": id("Carol"), "state": "starred" }] });
922 assert_eq!(stars_of(&people, &all, &en)["summary"], "Everyone on your list with a number is starred on this phone: their calls and messages ring during do-not-disturb.");
923 let refused = stars_of(&people, &serde_json::json!({ "permission": false }), &en);
924 assert_eq!(refused["permission"], false);
925 assert!(refused["summary"].as_str().unwrap().contains("read your contacts"));
926 }
927}