1use crate::backend::{load_config, tr};
19use crate::everywhere::Gated;
20use jiff::Zoned;
21use serde_json::{Value, json};
22use sioul_core::appnotes::{self, AppKind, Ask, Cause, Choices, Decision, Gate, Gathering, Kind, Ledger, Live, Own, Posted, Sender, Through, Why};
23use sioul_core::areas::Area;
24use sioul_core::attention::Column;
25use sioul_core::config::Config;
26use sioul_core::everywhere::People;
27use sioul_core::porch::{By, Senders};
28use sioul_core::reach::{Channel, Clock, Who};
29use std::ffi::c_char;
30use std::path::Path;
31use std::sync::Mutex;
32use std::time::SystemTime;
33
34#[cfg(target_os = "android")]
35unsafe extern "C" {
37 fn sioul_android_appnotes(verb: *const c_char, json: *const c_char) -> *mut c_char;
39 fn sioul_android_dnd_free(text: *mut c_char);
41}
42
43fn java(verb: &str, json: &str) -> Value {
45 #[cfg(target_os = "android")]
46 {
47 use std::ffi::{CStr, CString};
48 let (Ok(verb), Ok(json)) = (CString::new(verb), CString::new(json)) else { return Value::Null };
49 let answer = unsafe { sioul_android_appnotes(verb.as_ptr(), json.as_ptr()) };
51 if answer.is_null() {
52 return Value::Null;
53 }
54 let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
56 unsafe { sioul_android_dnd_free(answer) };
58 serde_json::from_str(&text).unwrap_or(Value::Null)
59 }
60 #[cfg(not(target_os = "android"))]
61 {
62 let _ = (verb, json);
63 Value::Null
64 }
65}
66
67pub(crate) fn access_here() -> Option<bool> {
71 java("access", "{}").as_bool()
72}
73
74fn changed(path: &Path) -> Option<SystemTime> {
76 std::fs::metadata(path).and_then(|m| m.modified()).ok()
77}
78
79fn live(config: &Config, now: &Zoned) -> Live {
83 type Key = (i64, [Option<SystemTime>; 4]);
84 static KEPT: Mutex<Option<(Key, Live)>> = Mutex::new(None);
85 let files = [
86 sioul_core::quiet::Overrides::default_path(),
87 sioul_core::health::Health::default_path(),
88 sioul_core::needs::Days::default_path(),
89 sioul_core::config::config_dir().join("config.toml"),
90 ];
91 let key: Key = (now.timestamp().as_second() / 60, files.each_ref().map(|f| changed(f)));
92 if let Ok(kept) = KEPT.lock()
93 && let Some((known, live)) = kept.as_ref()
94 && *known == key
95 {
96 return live.clone();
97 }
98 let live = Live { clock: Clock::load(config, now) };
99 if let Ok(mut kept) = KEPT.lock() {
100 *kept = Some((key, live.clone()));
101 }
102 live
103}
104
105fn who_of(sender: &Sender, senders: &Senders, always: &People, config: &Config) -> (Option<Who>, bool) {
109 match sender {
110 Sender::Known { numbers, emails, contact } => {
111 let by_number = numbers.iter().map(|n| senders.judge_number(n)).find(|j| j.by != By::Default).map(|j| j.who);
112 let by_address = emails.iter().map(|e| senders.who(e)).find(|w| *w != Who::Stranger);
113 let region = sioul_core::reach::region(config);
114 let admitted = numbers.iter().any(|n| always.admits_number(n, region)) || emails.iter().any(|e| always.admits_address(e));
115 let unknown = if *contact { Who::Neutral } else { Who::Stranger };
117 (Some(by_number.or(by_address).unwrap_or(unknown)), admitted)
118 }
119 Sender::Named { name, book } => (senders.judge_name(name).map(|j| appnotes::named(Some(j.who), *book)), false),
120 Sender::Unknown => (None, false),
121 }
122}
123
124fn reachable_until() -> Option<i64> {
127 crate::everywhere::reachable().map(|r| r.until.unwrap_or(i64::MAX))
128}
129
130const MACHINE: &str = "machine";
133
134fn machine_number(sender: &Sender, config: &Config, now: &Zoned) -> bool {
138 let Sender::Known { numbers, .. } = sender else { return false };
139 if sioul_core::screening::Screening::of(config).automated != sioul_core::screening::Spam::Voicemail {
140 return false;
141 }
142 let roots = sioul_core::screening::shipped().automated(now.date());
143 let region = sioul_core::reach::region(config);
144 numbers.iter().any(|n| sioul_core::screening::national(&sioul_core::phones::key(n, region)).is_some_and(|national| sioul_core::screening::starts_with_any(&national, &roots)))
145}
146
147fn area_of(posted: &Posted, kind: &Kind, choices: &Choices, config: &Config) -> Option<Area> {
150 choices.app(&posted.package).area.or_else(|| match kind {
151 Kind::People(talk) if !talk.to.is_empty() => config
152 .accounts
153 .iter()
154 .find(|a| a.address.as_deref().is_some_and(|address| address.trim().eq_ignore_ascii_case(&talk.to)))
155 .map(|a| a.area.as_deref().and_then(Area::parse).unwrap_or(Area::WORK)),
156 _ => None,
157 })
158}
159
160fn gate() -> Option<Gated> {
164 crate::everywhere::gated()
165}
166
167fn decide(json: &str) -> String {
174 let Ok(posted) = serde_json::from_str::<Posted>(json) else { return "null".into() };
175 let config = load_config();
176 let choices = Choices::load(&Choices::default_path());
177 let kind = appnotes::classify(&posted, &choices, &sioul_core::words::Words::of(&config));
178 let now = Zoned::now();
179 let stamp = now.timestamp().as_second();
180 let gate = gate();
181 let senders = Senders::load(&config);
182 let (who, admitted) = match &kind {
183 Kind::People(talk) => who_of(&talk.sender, &senders, &People::load(&People::default_path()), &config),
184 _ => (None, false),
185 };
186 let persons = match &kind {
189 Kind::People(talk) => match &talk.sender {
190 Sender::Known { numbers, emails, .. } => senders.person_rows(numbers, emails),
191 Sender::Named { name, book: true } => senders.person_rows_named(name),
192 _ => Vec::new(),
193 },
194 _ => Vec::new(),
195 };
196 let machine = matches!(&kind, Kind::People(talk) if talk.sms && who == Some(Who::Stranger) && machine_number(&talk.sender, &config, &now));
200 let kind = if machine { Kind::Automaton { site: String::new() } } else { kind };
201 let (who, admitted) = if matches!(kind, Kind::People(_)) { (who, admitted) } else { (None, false) };
202 let area = area_of(&posted, &kind, &choices, &config);
203 let live = live(&config, &now);
204 let gathered = config.reminders.gathered_times();
205 let slots = crate::hours::slots();
207 let slot = |at: &Zoned| slots.at(at);
208 let also = slots.edges();
209 let attention = sioul_core::attention::Attention::of(&config);
211 let nothing = sioul_core::pause::nothing_now(&sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path()), &config.free_time);
212 let conversation = match &kind {
214 Kind::People(talk) => talk.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
215 _ => String::new(),
216 };
217 let sources = sioul_core::attention::source_rows_with(&posted.package, &conversation, &persons);
218 let reachable = reachable_until();
219 let silenced = posted.intercepted;
221 let ask = Ask { now: &now, clock: &live, attention: &attention, gathered: &gathered, area, gate: gate.map(|g| Gate { until: g.until }), slot_at: &slot, nothing, also: &also, sources: &sources, reachable, silenced };
222 let hash = appnotes::key_hash(&posted.key);
223 let paused = live.clock.mode(&now).paused();
226 let answered = !paused
227 && match &kind {
228 Kind::People(talk) => {
229 talk.conversation.as_ref().is_some_and(|c| crate::replies::answered_lately(&c.key))
230 || ((talk.sms || posted.sms_app) && matches!(&talk.sender, Sender::Known { numbers, .. } if crate::replies::texted_lately(&numbers.iter().map(|n| sioul_core::phones::key(n, sioul_core::reach::region(&config))).collect::<Vec<_>>())))
231 }
232 _ => false,
233 };
234 let path = Ledger::default_path();
235 let (decision, released, returning) = sioul_core::filelock::with_lock(&path, || {
236 let mut ledger = Ledger::load(&path);
237 let returning = ledger.returning(&hash, stamp);
238 let mut decision = appnotes::decide(&kind, who, admitted, &choices, &ask);
239 if answered {
240 decision = decision.answered();
241 }
242 if returning
246 && !paused
247 && let Some(held) = ledger.held.get(&hash)
248 {
249 decision = decision.released(held, &ledger);
250 }
251 if returning
253 && let Some(until) = decision.until
254 && decision.why != Why::Never
255 {
256 let short = until - stamp < appnotes::AGAIN_SHORT;
257 let again = if short { ledger.held.get(&hash).map_or(0, |h| h.again) + 1 } else { 0 };
258 if again > appnotes::AGAIN_MAX {
259 decision = Decision::through(Why::Again);
260 } else if let Some(held) = ledger.held.get_mut(&hash) {
261 held.again = again;
262 }
263 }
264 let mut released = None;
266 if returning && decision.until.is_none() {
267 let kind = ledger.held.get(&hash).map_or("other apps", kind_of);
268 ledger.came_back(&hash, stamp);
269 let mut counted = vec![Counted { kind, until: None, why: decision.why }];
270 counted.extend(ledger.held.values().filter(|h| h.until > stamp).map(|h| Counted::held(h, h.until)));
271 released = Some(counted);
272 }
273 ledger.note(&posted, &kind, who, admitted, area, &decision, stamp);
274 if let Some(held) = ledger.held.get_mut(&hash) {
275 held.persons = persons.clone();
276 }
277 if machine && decision.why == Why::Gathered
278 && let Some(app) = ledger.apps.get_mut(&posted.package)
279 {
280 app.why = MACHINE.to_string();
281 }
282 ledger.forget_old(stamp, &choices);
283 if let Err(e) = ledger.save(&path) {
284 eprintln!("Notes: {e}");
285 }
286 (decision, released, returning)
287 });
288 let shared = crate::phonemsgs::note(json, &posted, &kind, who, admitted, &decision) | crate::replies::heard(json, &posted);
291 let hold = decision.until.map_or(0, |until| (until - stamp).max(1) * 1000);
292 let why = if machine && decision.why == Why::Gathered { MACHINE } else { decision.why.id() };
293 let mut answer = json!({ "hold": hold, "why": why, "shared": shared });
294 if let Some(counted) = released {
295 let columns = column_words(&columns_now(&live, &now, slot(&now), gate.is_some()), nothing && live.clock.mode(&now).free(), gate.as_ref());
296 answer["line"] = json!(log_line("release", &counted, &columns.iter().map(String::as_str).collect::<Vec<_>>(), &now));
297 } else if decision.until.is_some() {
298 let cause = appnotes::cause(&posted.package, &kind, who, admitted, &choices, &ask).map(|mut cause| {
300 if machine && cause.gathered == Some(Gathering::Automaton) {
301 cause.gathered = Some(Gathering::Machine);
302 }
303 cause
304 });
305 answer["line"] = json!(hold_line(if returning { "held again" } else { "held" }, kind_word(&kind), &decision, cause.as_ref(), gate.as_ref(), &now));
306 }
307 answer.to_string()
308}
309
310fn review(json: &str) -> String {
316 let asked: Value = serde_json::from_str(json).unwrap_or(Value::Null);
317 let keys: Vec<String> = asked["snoozed"].as_array().map(|a| a.iter().filter_map(|k| k.as_str().map(str::to_string)).collect()).unwrap_or_default();
318 let reason: String = asked["reason"].as_str().unwrap_or_default().chars().filter(char::is_ascii_lowercase).take(16).collect();
320 let silenced = asked["silenced"].as_bool().unwrap_or(false);
323 let config = load_config();
324 let choices = Choices::load(&Choices::default_path());
325 let now = Zoned::now();
326 let stamp = now.timestamp().as_second();
327 let gate = gate();
328 let live = live(&config, &now);
329 let gathered = config.reminders.gathered_times();
330 let slots = crate::hours::slots();
331 let slot = |at: &Zoned| slots.at(at);
332 let also = slots.edges();
333 let attention = sioul_core::attention::Attention::of(&config);
334 let nothing = sioul_core::pause::nothing_now(&sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path()), &config.free_time);
335 let paused = live.clock.mode(&now).paused();
337 let reachable = reachable_until();
338 let releases = crate::everywhere::release_stamps();
342 let talks = released_talks();
343 let path = Ledger::default_path();
344 let (again, counted, ahead) = sioul_core::filelock::with_lock(&path, || {
345 let mut ledger = Ledger::load(&path);
346 let (_, ahead) = if paused { (false, Vec::new()) } else { ledger.apply_releases(&releases, stamp) };
347 let present: std::collections::BTreeSet<String> = keys.iter().map(|k| appnotes::key_hash(k)).collect();
348 ledger.held.retain(|hash, held| present.contains(hash) || stamp - held.since < 60);
349 let mut again = Vec::new();
350 let mut counted = Vec::new();
351 for key in &keys {
352 let hash = appnotes::key_hash(key);
353 let Some(held) = ledger.held.get(&hash).cloned() else { continue };
354 let sources = sioul_core::attention::source_rows_with(&held.app, &held.conversation, &held.persons);
355 let ask = Ask { now: &now, clock: &live, attention: &attention, gathered: &gathered, area: held.area, gate: gate.map(|g| Gate { until: g.until }), slot_at: &slot, nothing, also: &also, sources: &sources, reachable, silenced };
356 let mut decided = appnotes::again(&held, &choices, &ask);
357 if !paused && crate::replies::answered_lately(&held.conversation) {
358 decided = decided.answered();
359 }
360 if !paused {
364 decided = decided.released(&held, &ledger);
365 if decided.until.is_some() && decided.why != Why::Never && talks.get(&held.conversation).is_some_and(|at| held.first_held() <= *at) {
367 decided = Decision::through(Why::Released);
368 }
369 }
370 let until = decided.until.unwrap_or(stamp);
371 counted.push(Counted { why: decided.why, ..Counted::held(&held, until) });
372 if decided.why == Why::Never
375 && let Some(kept) = ledger.held.get_mut(&hash)
376 {
377 kept.kind = "never".to_string();
378 }
379 if (until - held.until).abs() > 60 {
380 if let Some(kept) = ledger.held.get_mut(&hash) {
381 kept.until = until;
382 }
383 again.push(json!({ "key": key, "ms": (until - stamp).max(1) * 1000 }));
384 }
385 }
386 ledger.forget_old(stamp, &choices);
387 if let Err(e) = ledger.save(&path) {
388 eprintln!("Notes: {e}");
389 }
390 (again, counted, ahead.len())
391 });
392 let event = if reason.is_empty() { "review".to_string() } else { format!("review ({reason})") };
393 let columns = column_words(&columns_now(&live, &now, slot(&now), gate.is_some()), nothing && live.clock.mode(&now).free(), gate.as_ref());
394 let mut line = log_line(&event, &counted, &columns.iter().map(String::as_str).collect::<Vec<_>>(), &now);
395 if ahead > 0 {
397 line.push_str(&format!(" A release asked from {ahead} device(s) more than a day ahead of this phone's clock: passed over."));
398 }
399 json!({ "snooze": again, "line": line }).to_string()
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408struct Counted {
409 kind: &'static str,
410 until: Option<i64>,
411 why: Why,
412}
413
414impl Counted {
415 fn held(held: &appnotes::Held, until: i64) -> Counted {
418 Counted { kind: kind_of(held), until: Some(until), why: if held.kind == "never" { Why::Never } else { Why::Waiting } }
419 }
420}
421
422fn kind_of(held: &appnotes::Held) -> &'static str {
426 match held.via.as_str() {
427 "calls" => "calls",
428 "messages" => "messages",
429 "mail" => "mail",
430 _ => "other apps",
431 }
432}
433
434fn columns_now(live: &Live, now: &Zoned, slot: bool, dnd: bool) -> Vec<Column> {
438 let moment = sioul_core::attention::Now::of(&live.clock.mode(now)).layers(slot, dnd);
439 let mut columns = moment.times.clone();
440 if moment.slot {
441 columns.push(Column::Slot);
442 }
443 if moment.dnd {
444 columns.push(Column::Dnd);
445 }
446 columns
447}
448
449fn column_words(columns: &[Column], nothing: bool, gated: Option<&Gated>) -> Vec<String> {
453 columns
454 .iter()
455 .map(|column| match column {
456 Column::Free if nothing => "free (nothing at all)".to_string(),
457 Column::Dnd => match gated {
458 Some(Gated { focus: true, switch: true, .. }) => "dnd (switch and focus session)".to_string(),
459 Some(Gated { focus: true, .. }) => "dnd (focus session)".to_string(),
460 Some(Gated { switch: true, .. }) => "dnd (switch)".to_string(),
461 _ => column.id().to_string(),
462 },
463 _ => column.id().to_string(),
464 })
465 .collect()
466}
467
468fn at_words(at: i64, now: &Zoned) -> String {
470 match jiff::Timestamp::from_second(at).map(|t| t.to_zoned(now.time_zone().clone())) {
471 Err(_) => "an unknown time".to_string(),
472 Ok(at) if at.date() == now.date() => at.strftime("%H:%M").to_string(),
473 Ok(at) if now.date().tomorrow().is_ok_and(|tomorrow| tomorrow == at.date()) => at.strftime("tomorrow %H:%M").to_string(),
474 Ok(at) => at.strftime("%Y-%m-%d %H:%M").to_string(),
475 }
476}
477
478fn kind_word(kind: &Kind) -> &'static str {
480 match kind {
481 Kind::People(talk) => match talk.via {
482 Channel::Calls => "calls",
483 Channel::Messages => "messages",
484 Channel::Mail => "mail",
485 },
486 _ => "other apps",
487 }
488}
489
490fn hold_line(event: &str, kind: &str, decision: &Decision, cause: Option<&Cause>, gated: Option<&Gated>, now: &Zoned) -> String {
499 let until = match decision.until {
500 _ if decision.why == Why::Never => "for good".to_string(),
501 Some(until) => format!("until {}", at_words(until, now)),
502 None => "until now".to_string(),
503 };
504 let mut why: Vec<String> = Vec::new();
505 if let Some(cause) = cause {
506 if cause.for_good {
507 why.push("never".into());
508 }
509 match cause.own {
510 Some(Own::Conversation) => why.push("its conversation's row".into()),
511 Some(Own::Person) => why.push("their own row".into()),
512 Some(Own::App) => why.push("its app's row".into()),
513 None => {}
514 }
515 match cause.gathered {
516 Some(Gathering::Automaton) => why.push("gathered".into()),
517 Some(Gathering::App) => why.push("gathered (its app)".into()),
518 Some(Gathering::Conversation) => why.push("gathered (its conversation)".into()),
519 Some(Gathering::Named) => why.push("gathered (a sender named only)".into()),
520 Some(Gathering::Machine) => why.push("gathered (a number kept for machines)".into()),
521 None => {}
522 }
523 if let Some(area) = cause.area {
524 why.push(format!("area ({})", area.id()));
525 }
526 if !cause.columns.is_empty() {
527 why.push(column_words(&cause.columns, cause.nothing, gated).join(" + "));
528 }
529 }
530 let why = if why.is_empty() { "unknown".to_string() } else { why.join(", ") };
531 format!("Notes: {event}: {kind} 1 {until}; why: {why}.")
532}
533
534fn log_line(event: &str, counted: &[Counted], columns: &[&str], now: &Zoned) -> String {
543 let stamp = now.timestamp().as_second();
544 let (released, held): (Vec<&Counted>, Vec<&Counted>) = counted.iter().partition(|c| c.until.is_none_or(|until| until <= stamp));
545 let by_kind = |items: &[&Counted]| {
546 let kinds: Vec<String> = ["calls", "messages", "mail", "other apps"].iter().map(|kind| (kind, items.iter().filter(|c| c.kind == *kind).count())).filter(|(_, n)| *n > 0).map(|(kind, n)| format!("{kind} {n}")).collect();
547 if kinds.is_empty() { items.len().to_string() } else { format!("{} ({})", items.len(), kinds.join(", ")) }
548 };
549 let why = if released.iter().any(|c| c.why == Why::Always) {
550 "Always through".to_string()
551 } else if released.iter().any(|c| c.why == Why::Answered) {
552 "answered from a computer".to_string()
553 } else if released.iter().any(|c| c.why == Why::Released) {
554 "released as you asked".to_string()
555 } else if columns.is_empty() {
556 "unknown".to_string()
557 } else {
558 columns.join(" + ")
559 };
560 let next = held.iter().filter(|c| c.why != Why::Never).filter_map(|c| c.until).min().filter(|at| jiff::Timestamp::from_second(*at).is_ok()).map_or_else(|| "none".to_string(), |at| at_words(at, now));
562 format!("Notes: {event}: held {}; released {}; why: {why}; next release {next}.", by_kind(&held), by_kind(&released))
563}
564
565#[unsafe(no_mangle)]
572pub unsafe extern "C" fn sioul_appnotes_decide(json: *const c_char) -> *mut c_char {
573 let json = unsafe { crate::alarms::key_of(json) };
575 let answer = std::panic::catch_unwind(|| decide(&json)).unwrap_or_else(|_| "null".to_string());
576 crate::alarms::handed(answer)
577}
578
579#[unsafe(no_mangle)]
585pub unsafe extern "C" fn sioul_appnotes_review(json: *const c_char) -> *mut c_char {
586 let json = unsafe { crate::alarms::key_of(json) };
588 let answer = std::panic::catch_unwind(|| review(&json)).unwrap_or_else(|_| "null".to_string());
589 crate::alarms::handed(answer)
590}
591
592const TALKS_FILE: &str = "app-notes-talks.toml";
598
599fn talks_path() -> std::path::PathBuf {
600 sioul_core::config::state_dir().join(TALKS_FILE)
601}
602
603fn released_talks() -> std::collections::BTreeMap<String, i64> {
604 std::fs::read_to_string(talks_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
605}
606
607pub(crate) fn held_talks() -> Vec<Value> {
612 if !cfg!(target_os = "android") {
613 return Vec::new();
614 }
615 let path = Ledger::default_path();
616 if !path.exists() {
617 return Vec::new();
618 }
619 let ledger = Ledger::load(&path);
620 let now = jiff::Timestamp::now().as_second();
621 let mut held: Vec<&appnotes::Held> = ledger.held.values().filter(|h| h.kind == "people" && !h.conversation.is_empty() && h.until > now).collect();
622 held.sort_by_key(|h| h.until);
623 let mut out: Vec<Value> = Vec::new();
624 for h in held {
625 if out.iter().any(|t| t["key"] == h.conversation.as_str()) {
626 continue;
627 }
628 let title = ledger.conversations.get(&h.conversation).map(|c| c.title.split_whitespace().collect::<Vec<_>>().join(" ")).filter(|t| !t.is_empty()).unwrap_or_else(|| h.label.clone());
629 out.push(json!({ "key": h.conversation, "title": title, "app": h.label }));
630 }
631 out
632}
633
634pub(crate) fn release_talk(key: &str) -> String {
637 let now = jiff::Timestamp::now().as_second();
638 let mut talks = released_talks();
639 talks.retain(|_, at| now - *at < 2 * 86_400);
640 talks.insert(key.to_string(), now);
641 let path = talks_path();
642 let written = toml::to_string(&talks).map_err(|e| e.to_string()).and_then(|text| {
643 if let Some(parent) = path.parent() {
644 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
645 }
646 sioul_core::filelock::replace(&path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
647 });
648 match written {
649 Ok(()) => tr().text("reach-card-let-it-done", None),
650 Err(e) => e,
651 }
652}
653
654fn talk_then(held: &appnotes::Held, now: &sioul_core::attention::Now) -> (String, Vec<Column>) {
660 let person = if held.group {
663 None
664 } else {
665 held.persons.iter().find(|p| p.starts_with("person.contact:")).or_else(|| held.persons.iter().find(|p| p.starts_with("person.tel:")).filter(|_| !matches!(held.who.as_str(), "" | "stranger")))
666 };
667 let id = person.cloned().unwrap_or_else(|| format!("{}{}", sioul_core::attention::CONVERSATION_ROW, held.conversation));
668 let columns: Vec<Column> = if now.dnd {
669 vec![Column::Dnd]
670 } else if now.slot {
671 vec![Column::Slot]
672 } else if now.times.iter().any(|c| matches!(c, Column::Work | Column::Admin)) {
673 vec![Column::Work, Column::Admin]
674 } else {
675 now.times.clone()
676 };
677 (id, columns)
678}
679
680pub(crate) fn let_talk_then(key: &str) -> String {
686 let path = Ledger::default_path();
687 let ledger = Ledger::load(&path);
688 let stamp = jiff::Timestamp::now().as_second();
690 let Some(held) = ledger.held.values().filter(|h| h.conversation == key && h.until > stamp).max_by_key(|h| h.since) else { return String::new() };
691 let (id, columns) = talk_then(held, &crate::hours::attention_now());
692 let words: Vec<String> = columns.iter().map(|c| c.id().to_string()).collect();
693 if let Err(e) = sioul_core::attention::apply_change(&crate::backend::config_path(), &format!("attention.{id}"), None, &sioul_core::config::SettingValue::Texts(words)) {
694 return e;
695 }
696 let time = columns.iter().map(|c| sioul_core::attention::column_label(tr(), *c)).collect::<Vec<_>>().join(", ");
697 let mut args = sioul_core::i18n::args();
698 args.set("time", time);
699 tr().text(if sioul_core::attention::is_person(&id) { "reach-card-let-then-done" } else { "reach-card-let-then-done-talk" }, Some(&args))
701}
702
703pub(crate) fn porch_line() -> String {
706 let path = Ledger::default_path();
707 if !path.exists() {
708 return String::new();
709 }
710 appnotes::porch_line(tr(), &Ledger::load(&path), &Zoned::now())
711}
712
713pub(crate) fn with_line(porch: String) -> String {
715 let line = porch_line();
716 if line.is_empty() {
717 return porch;
718 }
719 match serde_json::from_str::<Value>(&porch) {
720 Ok(Value::Object(mut fields)) => {
721 fields.insert("apps_line".into(), Value::String(line));
722 Value::Object(fields).to_string()
723 }
724 _ => porch,
725 }
726}
727
728fn row(key: &str, kind: &str, label: &str, help: &str, value: Value, choices: Vec<(Value, String)>) -> Value {
730 json!({
731 "key": key,
732 "kind": kind,
733 "label": label,
734 "help": help,
735 "value": value,
736 "choices": choices.into_iter().map(|(value, label)| json!({ "value": value, "label": label })).collect::<Vec<_>>(),
737 })
738}
739
740fn by_time(attention: &sioul_core::attention::Attention, ledger: &Ledger, choices: &Choices) -> (Value, bool) {
747 use sioul_core::attention::{APP_ROW, CONVERSATION_ROW, SourceLine};
748 let tr = tr();
749 let apps_group = tr.text("attention-source-group-apps", None);
750 let talks_group = tr.text("attention-source-group-conversations", None);
751 let mut apps: Vec<SourceLine> = ledger.apps.iter().map(|(package, seen)| SourceLine { id: format!("{APP_ROW}{package}"), label: seen.label.clone(), group: apps_group.clone(), group_id: "app-rows".into() }).collect();
752 for id in attention.sources().keys().filter(|id| id.starts_with(APP_ROW)) {
754 if !apps.iter().any(|a| &a.id == id) {
755 let name = attention.source_name(id);
756 let label = if name.is_empty() { id[APP_ROW.len()..].to_string() } else { name.to_string() };
757 apps.push(SourceLine { id: id.clone(), label, group: apps_group.clone(), group_id: "app-rows".into() });
758 }
759 }
760 apps.sort_by_cached_key(|a| sioul_core::text::fold(&a.label).into_iter().collect::<String>());
761 let mut talks: Vec<(&String, &appnotes::SeenTalk)> = ledger.conversations.iter().collect();
762 talks.sort_by_key(|(_, t)| -t.seen);
763 let total = talks.len();
764 let chosen = |key: &str| choices.conversation.contains_key(key) || attention.source(&format!("{CONVERSATION_ROW}{key}")).is_some();
765 let talks: Vec<(&String, &appnotes::SeenTalk)> = talks.into_iter().enumerate().filter(|(i, (key, _))| *i < 10 || chosen(key)).map(|(_, t)| t).collect();
766 let more = talks.len() < total;
767 let mut lines = apps;
768 lines.extend(talks.into_iter().map(|(key, seen)| SourceLine { id: format!("{CONVERSATION_ROW}{key}"), label: format!("{} · {}", seen.title, seen.label), group: talks_group.clone(), group_id: "conversation-rows".into() }));
769 (serde_json::to_value(sioul_core::attention::source_grid(attention, tr, &lines)).unwrap_or(Value::Null), more)
770}
771
772fn times_in_words(times: &[String]) -> String {
774 let times: Vec<String> = appnotes::times_of(times).iter().map(|t| t.strftime("%H:%M").to_string()).collect();
775 match times.as_slice() {
776 [] => String::new(),
777 [one] => one.clone(),
778 [rest @ .., last] => format!("{} {} {last}", rest.join(", "), tr().text("word-and", None)),
779 }
780}
781
782pub(crate) fn setup() -> String {
786 let android = cfg!(target_os = "android");
787 let config = load_config();
788 let choices = Choices::load(&Choices::default_path());
789 let ledger = Ledger::load(&Ledger::default_path());
790 let now = Zoned::now();
791 let tr = tr();
792 let text = |id: &str| tr.text(id, None);
793 let mut times = sioul_core::i18n::args();
794 times.set("times", times_in_words(&config.reminders.gathered_times()));
795 let heard = if ledger.heard > 0 {
796 let mut args = sioul_core::i18n::args();
797 let at = jiff::Timestamp::from_second(ledger.heard).map(|t| t.to_zoned(now.time_zone().clone())).unwrap_or_else(|_| now.clone());
798 args.set("when", tr.when(&at));
799 tr.text("appnotes-heard", Some(&args))
800 } else {
801 String::new()
802 };
803 let mut rang: Vec<Value> = Vec::new();
804 for (package, app) in &ledger.apps {
805 for (channel, r) in &app.rang {
806 let mut args = sioul_core::i18n::args();
807 args.set("app", app.label.clone());
808 args.set("channel", if r.name.is_empty() { channel.clone() } else { r.name.clone() });
809 rang.push(json!({ "package": package, "channel": channel, "line": tr.text("appnotes-rang-line", Some(&args)) }));
810 }
811 }
812 let kinds: Vec<(Value, String)> = AppKind::ALL.iter().map(|k| (json!(k.id()), text(&format!("appnotes-kind-{}", k.id())))).collect();
813 let attention = sioul_core::attention::Attention::of(&config);
814 let (grid, more) = by_time(&attention, &ledger, &choices);
815 let mut apps: Vec<(&String, &appnotes::SeenApp)> = ledger.apps.iter().collect();
816 apps.sort_by_cached_key(|(_, a)| sioul_core::text::fold(&a.label).into_iter().collect::<String>());
817 let apps: Vec<Value> = apps
818 .into_iter()
819 .map(|(package, seen)| {
820 let chosen = choices.app(package);
821 let why = appnotes::why_text(tr, &seen.why, seen.until, &now);
822 json!({
823 "package": package,
824 "times": sioul_core::attention::source_sentence(&attention, tr, &format!("{}{package}", sioul_core::attention::APP_ROW)),
826 "kind": row(&format!("app.{package}.kind"), "choice", &seen.label, &why, json!(chosen.kind.id()), kinds.clone()),
827 "area": row(&format!("app.{package}.area"), "areas", &text("appnotes-area"), "", json!(chosen.area.map(Area::id).unwrap_or_default()), Vec::new()),
828 })
829 })
830 .collect();
831 let throughs: Vec<(Value, String)> = Through::ALL.iter().map(|t| (json!(t.id()), text(&format!("appnotes-through-{}", t.id())))).collect();
832 let mut talks: Vec<(&String, &appnotes::SeenTalk)> = ledger.conversations.iter().collect();
833 talks.sort_by_key(|(_, t)| -t.seen);
834 let android_says = if android { java("conversations", "{}") } else { Value::Null };
836 let conversations: Vec<Value> = talks
837 .into_iter()
838 .map(|(key, seen)| {
839 let label = format!("{} · {}", seen.title, seen.label);
840 let help = if seen.group { text("appnotes-group") } else { String::new() };
841 let mut said = row(&format!("conversation.{key}"), "choice", &label, &help, json!(choices.through(key).id()), throughs.clone());
842 said["talk"] = json!(key);
843 said["important"] = json!(android_says["conversations"][key.as_str()]["important"] == true);
844 said
845 })
846 .collect();
847 let at_once = vec![(json!(false), text("appnotes-site-gathered")), (json!(true), text("appnotes-site-at-once"))];
848 let sites: Vec<Value> = ledger
849 .sites
850 .iter()
851 .map(|(host, seen)| {
852 let app = ledger.apps.get(&seen.app).map_or(seen.app.as_str(), |a| a.label.as_str());
853 row(&format!("site.{host}"), "choice", &format!("{host} · {app}"), "", json!(choices.site.get(host).is_some_and(|s| s.at_once)), at_once.clone())
854 })
855 .collect();
856 json!({
857 "android": android,
858 "access": java("access", "{}") == Value::Bool(true),
859 "restricted": java("restricted", "{}") == Value::Bool(true),
860 "contacts": java("contacts", "{}") == Value::Bool(true),
861 "hold": row("hold", "bool", &text("appnotes-hold"), &text("appnotes-hold-help"), json!(choices.hold), Vec::new()),
862 "times": tr.text("appnotes-times", Some(×)),
863 "heard": heard,
864 "rang": rang,
865 "apps": apps,
866 "conversations": conversations,
867 "sites": sites,
868 "grid": grid,
869 "more": more,
870 })
871 .to_string()
872}
873
874pub(crate) fn change(verb: &str, json: &str) -> String {
879 let asked: Value = serde_json::from_str(json).unwrap_or(Value::Null);
880 match verb {
881 "set" => {
882 let key = asked["key"].as_str().unwrap_or_default().to_string();
883 let value = asked["value"].clone();
884 let path = Choices::default_path();
885 let result = sioul_core::filelock::with_lock(&path, || {
886 let mut choices = Choices::load(&path);
887 let ledger = Ledger::load(&Ledger::default_path());
888 set(&mut choices, &ledger, &key, &value);
889 choices.save(&path)
890 });
891 if let Err(e) = result {
892 eprintln!("Notes: {e}");
893 }
894 }
895 "open-access" | "open-info" | "open-app" | "open-channel" | "open-conversation" => {
897 java(verb, json);
898 }
899 "contacts" => crate::steps::ask_contacts(),
900 "row" => {
902 let key = asked["key"].as_str().unwrap_or_default();
903 let mut words: Vec<String> = asked["words"].as_array().map(|w| w.iter().filter_map(|w| w.as_str().map(str::to_string)).collect()).unwrap_or_default();
904 if let Some(package) = key.strip_prefix("attention.").and_then(|id| id.strip_prefix(sioul_core::attention::APP_ROW))
905 && let Some(seen) = Ledger::load(&Ledger::default_path()).apps.get(package)
906 {
907 words.push(format!("{}{}", sioul_core::attention::NAME, seen.label));
908 }
909 let shown: Option<Vec<String>> = asked["shown"].as_array().map(|w| w.iter().filter_map(|w| w.as_str().map(str::to_string)).collect());
911 let shown = shown.map(|mut shown| {
912 shown.extend(words.iter().filter(|w| w.starts_with(sioul_core::attention::NAME)).cloned());
913 sioul_core::config::SettingValue::Texts(shown)
914 });
915 if let Err(e) = sioul_core::attention::apply_change(&crate::backend::config_path(), key, shown.as_ref(), &sioul_core::config::SettingValue::Texts(words)) {
916 eprintln!("Notes: {e}");
917 }
918 }
919 _ => {}
920 }
921 setup()
922}
923
924fn set(choices: &mut Choices, ledger: &Ledger, key: &str, value: &Value) {
927 if key == "hold" {
928 choices.hold = value.as_bool().unwrap_or(true);
929 } else if let Some(rest) = key.strip_prefix("app.") {
930 let Some((package, field)) = rest.rsplit_once('.') else { return };
931 let mut chosen = choices.app(package);
932 match field {
933 "kind" => chosen.kind = value.as_str().and_then(AppKind::read).unwrap_or_default(),
934 "area" => chosen.area = value.as_str().and_then(Area::parse),
935 _ => return,
936 }
937 chosen.label = ledger.apps.get(package).map(|a| a.label.clone()).unwrap_or(chosen.label);
938 if chosen.kind == AppKind::Usual && chosen.area.is_none() {
939 choices.app.remove(package);
940 } else {
941 choices.app.insert(package.to_string(), chosen);
942 }
943 } else if let Some(conversation) = key.strip_prefix("conversation.") {
944 let through = value.as_str().and_then(Through::read).unwrap_or_default();
945 if through == Through::Usual {
946 choices.conversation.remove(conversation);
947 } else {
948 let seen = ledger.conversations.get(conversation);
949 let title = seen.map(|s| s.title.clone()).or_else(|| choices.conversation.get(conversation).map(|c| c.title.clone())).unwrap_or_default();
950 let app = seen.map(|s| s.label.clone()).or_else(|| choices.conversation.get(conversation).map(|c| c.app.clone())).unwrap_or_default();
951 choices.conversation.insert(conversation.to_string(), appnotes::TalkChoice { through, title, app });
952 }
953 } else if let Some(host) = key.strip_prefix("site.") {
954 if value.as_bool().unwrap_or(false) {
955 choices.site.insert(host.to_string(), appnotes::SiteChoice { at_once: true });
956 } else {
957 choices.site.remove(host);
958 }
959 }
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 #[test]
971 fn let_them_through_then_sets_the_cell_of_what_held_it() {
972 use sioul_core::attention::Now;
973 let held = |persons: &[&str]| appnotes::Held { conversation: "talk-1".into(), persons: persons.iter().map(|p| p.to_string()).collect(), kind: "people".into(), ..Default::default() };
974 let work = Now::time(Column::Work);
975 assert_eq!(talk_then(&held(&["person.tel:+33199001234", "person.contact:card-1"]), &work), ("person.contact:card-1".to_string(), vec![Column::Work, Column::Admin]));
976 assert_eq!(talk_then(&appnotes::Held { who: "neutral".into(), ..held(&["person.noa@example.org", "person.tel:+33199001234"]) }, &Now::time(Column::Leisure)).0, "person.tel:+33199001234");
977 assert_eq!(talk_then(&appnotes::Held { who: "stranger".into(), ..held(&["person.tel:+33199001234"]) }, &Now::time(Column::Leisure)).0, "conversation.talk-1");
979 assert_eq!(talk_then(&appnotes::Held { group: true, ..held(&["person.contact:card-1"]) }, &Now::time(Column::Leisure)).0, "conversation.talk-1");
980 assert_eq!(talk_then(&held(&[]), &Now::time(Column::Leisure)), ("conversation.talk-1".to_string(), vec![Column::Leisure]), "no card nor number: the conversation's");
981 assert_eq!(talk_then(&held(&["person.contact:card-1"]), &Now::time(Column::Work).layers(false, true)).1, vec![Column::Dnd]);
982 }
983
984 #[test]
991 fn no_release_during_a_pause_it_waits_for_its_end() {
992 const PEOPLE: &str = "0|com.google.android.apps.messaging|2|sms|10001";
993 const SHOP_KEY: &str = "0|com.shop.example|1|null|10500";
994 const SHOP: &str = r#"{"key":"0|com.shop.example|1|null|10500","package":"com.shop.example","app":"Shop","category":"promo","channel":"offers","title":"-30 % sur les chaussures","text":"Jusqu'à dimanche seulement","alerted":true}"#;
995 let _files = crate::everywhere::FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
996 crate::everywhere::files_cleared();
997 let (stamp, ms) = (jiff::Timestamp::now().as_second(), jiff::Timestamp::now().as_millisecond());
998 let held = |app: &str, kind: &str, via: &str, who: &str, since: i64, until: i64| appnotes::Held { app: app.into(), label: app.into(), kind: kind.into(), via: via.into(), who: who.into(), admitted: false, group: false, conversation: String::new(), site: String::new(), area: None, since, until, first: 0, again: 0, persons: Vec::new() };
999 let mut ledger = Ledger::default();
1000 ledger.held.insert(appnotes::key_hash(PEOPLE), held("com.google.android.apps.messaging", "people", "messages", "neutral", stamp - 3600, stamp + 7200));
1001 ledger.held.insert(appnotes::key_hash(SHOP_KEY), held("com.shop.example", "gathered", "", "", stamp - 1800, stamp + 7200));
1002 ledger.save(&Ledger::default_path()).unwrap();
1003 let mut overrides = sioul_core::quiet::Overrides { paused_since: Some(stamp - 300), ..Default::default() };
1004 overrides.save(&sioul_core::quiet::Overrides::default_path()).unwrap();
1005 let laptop = sioul_core::everywhere::Device { kind: "computer".into(), pressed: ms - 420_000, mode: "reachable".into(), until: ms + 3_600_000, at: ms - 420_000, ..Default::default() };
1006 sioul_core::everywhere::Switch { device: [("laptop-1".to_string(), laptop)].into_iter().collect(), ..Default::default() }.save(&sioul_core::everywhere::Switch::default_path()).unwrap();
1007 let released = |answer: &str| -> Vec<String> {
1008 let v: serde_json::Value = serde_json::from_str(answer).unwrap();
1009 v["snooze"].as_array().unwrap().iter().filter(|s| s["ms"].as_i64() == Some(1000)).map(|s| s["key"].as_str().unwrap().to_string()).collect()
1010 };
1011 let all = json!({ "snoozed": [PEOPLE, SHOP_KEY], "reason": "files" }).to_string();
1012 assert!(released(&review(&all)).is_empty(), "nothing let through during the pause");
1013 assert!(Ledger::load(&Ledger::default_path()).released_by.is_empty(), "the stamp waits");
1014 let mut ledger = Ledger::load(&Ledger::default_path());
1016 ledger.held.get_mut(&appnotes::key_hash(SHOP_KEY)).unwrap().until = stamp;
1017 ledger.save(&Ledger::default_path()).unwrap();
1018 let back: serde_json::Value = serde_json::from_str(&decide(SHOP)).unwrap();
1019 assert_ne!(back["why"], "released", "{back}");
1020 overrides.paused_ended = Some(stamp);
1023 overrides.save(&sioul_core::quiet::Overrides::default_path()).unwrap();
1024 assert_eq!(released(&review(&all)), vec![PEOPLE.to_string(), SHOP_KEY.to_string()]);
1025 assert!(released(&review(&all)).is_empty(), "once");
1026 crate::everywhere::files_cleared();
1027 }
1028
1029 #[test]
1030 fn choices_set_from_the_tab() {
1031 let mut choices = Choices::default();
1032 let mut ledger = Ledger::default();
1033 ledger.apps.insert("com.discord".into(), appnotes::SeenApp { label: "Discord".into(), ..Default::default() });
1034 ledger.conversations.insert("c1".into(), appnotes::SeenTalk { app: "com.whatsapp".into(), label: "WhatsApp".into(), title: "École".into(), group: true, seen: 1 });
1035 set(&mut choices, &ledger, "app.com.discord.kind", &json!("automaton"));
1036 set(&mut choices, &ledger, "app.com.discord.area", &json!("leisure"));
1037 assert_eq!(choices.app("com.discord"), appnotes::AppChoice { kind: AppKind::Automaton, area: Some(Area::LEISURE), label: "Discord".into() });
1038 set(&mut choices, &ledger, "app.com.discord.kind", &json!("usual"));
1039 set(&mut choices, &ledger, "app.com.discord.area", &json!(""));
1040 assert!(choices.app.is_empty(), "as usual: taken out");
1041 set(&mut choices, &ledger, "conversation.c1", &json!("always"));
1042 assert_eq!(choices.conversation["c1"], appnotes::TalkChoice { through: Through::Always, title: "École".into(), app: "WhatsApp".into() });
1043 set(&mut choices, &ledger, "conversation.c1", &json!("usual"));
1044 assert!(choices.conversation.is_empty());
1045 set(&mut choices, &ledger, "site.forum.example.net", &json!(true));
1046 assert!(choices.site["forum.example.net"].at_once);
1047 set(&mut choices, &ledger, "site.forum.example.net", &json!(false));
1048 assert!(choices.site.is_empty());
1049 set(&mut choices, &ledger, "hold", &json!(false));
1050 assert!(!choices.hold);
1051 }
1052
1053 #[test]
1057 fn the_table_by_time() {
1058 let mut ledger = Ledger::default();
1059 ledger.apps.insert("com.discord".into(), appnotes::SeenApp { label: "Discord".into(), ..Default::default() });
1060 ledger.apps.insert("org.example.news".into(), appnotes::SeenApp { label: "Actualités".into(), ..Default::default() });
1061 for i in 0..14 {
1062 ledger.conversations.insert(format!("c{i:02}"), appnotes::SeenTalk { app: "com.discord".into(), label: "Discord".into(), title: format!("Salon {i}"), group: true, seen: i });
1063 }
1064 let mut choices = Choices::default();
1065 choices.conversation.insert("c00".into(), appnotes::TalkChoice { through: Through::Always, ..Default::default() });
1066 let mut attention = sioul_core::attention::Attention::usual();
1067 attention.set_source("app.com.discord", sioul_core::attention::Column::Work, sioul_core::attention::Level::Now).unwrap();
1068 attention.set_source("app.org.example.chat", sioul_core::attention::Column::Leisure, sioul_core::attention::Level::Later).unwrap();
1069 attention.name_source("app.org.example.chat", "Chat");
1070 attention.set_source("conversation.c01", sioul_core::attention::Column::Sleep, sioul_core::attention::Level::Later).unwrap();
1071 let (grid, more) = by_time(&attention, &ledger, &choices);
1072 let ids: Vec<&str> = grid["rows"].as_array().unwrap().iter().map(|r| r["id"].as_str().unwrap()).collect();
1073 let labels: Vec<&str> = grid["rows"].as_array().unwrap().iter().map(|r| r["label"].as_str().unwrap()).collect();
1074 assert_eq!(&ids[..3], ["app.org.example.news", "app.org.example.chat", "app.com.discord"], "by name: Actualités, Chat, Discord");
1075 assert_eq!(&labels[..3], ["Actualités", "Chat", "Discord"]);
1076 let talks: Vec<&str> = ids.iter().filter(|id| id.starts_with("conversation.")).copied().collect();
1078 assert_eq!(talks.len(), 12);
1079 assert!(talks.contains(&"conversation.c00") && talks.contains(&"conversation.c01") && !talks.contains(&"conversation.c02") && talks[0] == "conversation.c13");
1080 assert!(more, "two conversations left out");
1081 let discord = &grid["rows"][2];
1082 assert_eq!(discord["cells"][0]["value"], "now");
1083 assert_eq!(discord["cells"][1]["value"], "as");
1084 assert_eq!(discord["choices"].as_array().unwrap().len(), 4);
1085 assert_eq!(grid["columns"].as_array().unwrap().len(), 9);
1086 let (none, more) = by_time(&sioul_core::attention::Attention::usual(), &Ledger::default(), &Choices::default());
1088 assert!(none["rows"].as_array().unwrap().is_empty() && !more);
1089 }
1090
1091 #[test]
1096 fn the_log_line_says_kinds_counts_and_times_only() {
1097 let now: Zoned = "2026-10-11T21:30:00+02:00[Europe/Paris]".parse().unwrap();
1098 let stamp = now.timestamp().as_second();
1099 let held = |app: &str, label: &str, via: &str, who: &str, conversation: &str, kind: &str, until: i64| appnotes::Held { app: app.into(), label: label.into(), kind: kind.into(), via: via.into(), who: who.into(), conversation: conversation.into(), since: stamp - 3_600, until, ..Default::default() };
1100 let ledger = [
1101 held("org.example.chat", "Chat de Noa", "messages", "safe", "c0ffee12", "people", stamp + 30 * 60),
1102 held("org.example.chat", "Chat de Noa", "messages", "neutral", "c0ffee13", "people", stamp + 9 * 3_600 + 30 * 60),
1103 held("org.example.mail", "Courrier 01 99 00", "mail", "stranger", "", "people", stamp + 10 * 3_600),
1104 held("org.example.news", "Actualités", "", "", "", "gathered", stamp + 11 * 3_600),
1105 held("org.example.spam", "Bloqué", "messages", "blocked", "", "never", stamp + 7 * 86_400),
1106 ];
1107 let counted: Vec<Counted> = ledger.iter().map(|h| Counted::held(h, h.until)).collect();
1108 let review = log_line("review (files)", &counted, &["leisure", "dnd"], &now);
1109 assert_eq!(review, "Notes: review (files): held 5 (messages 3, mail 1, other apps 1); released 0; why: leisure + dnd; next release 22:00.");
1110 let mut released = vec![Counted { kind: "messages", until: None, why: Why::Always }];
1112 released.extend(counted.iter().skip(1).copied());
1113 let release = log_line("release", &released, &["sleep"], &now);
1114 assert_eq!(release, "Notes: release: held 4 (messages 2, mail 1, other apps 1); released 1 (messages 1); why: Always through; next release tomorrow 07:00.");
1115 let mut over = counted.clone();
1117 over[3].until = Some(stamp);
1118 assert!(log_line("review (files)", &over, &["leisure"], &now).contains("released 1 (other apps 1); why: leisure;"));
1119 assert_eq!(log_line("review (connected)", &[], &["work", "admin"], &now), "Notes: review (connected): held 0; released 0; why: work + admin; next release none.");
1121 for line in [review, release] {
1123 for private in ["org.example", "Noa", "Courrier", "01 99 00", "Actualités", "Bloqué", "c0ffee", "safe", "neutral", "stranger", "blocked"] {
1124 assert!(!line.contains(private), "{line} says {private}");
1125 }
1126 }
1127 let switch = Gated { until: None, focus: false, switch: true };
1129 let columns = column_words(&[Column::Free, Column::Dnd], true, Some(&switch));
1130 assert_eq!(columns, ["free (nothing at all)", "dnd (switch)"]);
1131 assert_eq!(column_words(&[Column::Leisure, Column::Dnd], true, None), ["leisure", "dnd"], "nothing at all says nothing outside Free time");
1132 }
1133
1134 #[test]
1143 fn the_hold_line_says_what_decided_it_and_names_no_one() {
1144 let now: Zoned = "2026-10-14T10:20:00+02:00[Europe/Paris]".parse().unwrap();
1145 let stamp = now.timestamp().as_second();
1146 let focus = Gated { until: Some(stamp + 30 * 60), focus: true, switch: false };
1147 let both = Gated { until: None, focus: true, switch: true };
1148 let line = |event: &str, kind: &str, until: Option<i64>, why: Why, cause: Cause, gated: Option<&Gated>| hold_line(event, kind, &Decision { until, why }, Some(&cause), gated, &now);
1149 let columns = |columns: &[Column]| Cause { columns: columns.to_vec(), ..Cause::default() };
1150 let in_minutes = |m: i64| Some(stamp + m * 60);
1151 assert_eq!(line("held", "messages", in_minutes(30), Why::Waiting, columns(&[Column::Dnd]), Some(&focus)), "Notes: held: messages 1 until 10:50; why: dnd (focus session).");
1152 assert_eq!(line("held", "calls", in_minutes(30), Why::Waiting, columns(&[Column::Dnd]), Some(&both)), "Notes: held: calls 1 until 10:50; why: dnd (switch and focus session).");
1153 assert_eq!(line("held", "messages", in_minutes(100), Why::Gathered, Cause { gathered: Some(Gathering::Named), ..Cause::default() }, None), "Notes: held: messages 1 until 12:00; why: gathered (a sender named only).");
1154 assert_eq!(line("held", "messages", in_minutes(100), Why::Gathered, Cause { gathered: Some(Gathering::Machine), ..Cause::default() }, None), "Notes: held: messages 1 until 12:00; why: gathered (a number kept for machines).");
1155 assert_eq!(line("held", "other apps", Some(stamp + 22 * 3600 + 40 * 60), Why::Gathered, Cause { gathered: Some(Gathering::Automaton), columns: vec![Column::Sleep], ..Cause::default() }, None), "Notes: held: other apps 1 until tomorrow 09:00; why: gathered, sleep.");
1156 assert_eq!(line("held", "messages", in_minutes(400), Why::Times, Cause { own: Some(Own::App), columns: vec![Column::Work, Column::Admin], ..Cause::default() }, None), "Notes: held: messages 1 until 17:00; why: its app's row, work + admin.");
1157 assert_eq!(line("held", "messages", in_minutes(400), Why::Times, Cause { own: Some(Own::Conversation), gathered: None, columns: vec![Column::Work], ..Cause::default() }, None), "Notes: held: messages 1 until 17:00; why: its conversation's row, work.");
1158 assert_eq!(line("held", "messages", in_minutes(400), Why::Times, Cause { own: Some(Own::Person), columns: vec![Column::Work], ..Cause::default() }, None), "Notes: held: messages 1 until 17:00; why: their own row, work.");
1160 assert_eq!(line("held", "messages", in_minutes(100), Why::Gathered, Cause { gathered: Some(Gathering::Conversation), ..Cause::default() }, None), "Notes: held: messages 1 until 12:00; why: gathered (its conversation).");
1161 assert_eq!(line("held", "other apps", in_minutes(100), Why::Gathered, Cause { gathered: Some(Gathering::App), ..Cause::default() }, None), "Notes: held: other apps 1 until 12:00; why: gathered (its app).");
1162 assert_eq!(line("held", "mail", in_minutes(400), Why::Waiting, Cause { area: Some(Area::LEISURE), ..Cause::default() }, None), "Notes: held: mail 1 until 17:00; why: area (leisure).");
1163 assert_eq!(line("held", "messages", Some(stamp + 7 * 86_400), Why::Never, Cause { for_good: true, ..Cause::default() }, None), "Notes: held: messages 1 for good; why: never.");
1164 assert_eq!(line("held again", "messages", Some(stamp + 3 * 86_400), Why::Waiting, columns(&[Column::Pause]), None), "Notes: held again: messages 1 until 2026-10-17 10:20; why: pause.");
1165 assert_eq!(hold_line("held", "messages", &Decision { until: in_minutes(5), why: Why::Waiting }, None, None, &now), "Notes: held: messages 1 until 10:25; why: unknown.");
1166 struct Work;
1168 impl appnotes::TimeSource for Work {
1169 fn mode(&self, _: &Zoned) -> sioul_core::quiet::Mode {
1170 let week = sioul_core::areas::Week { work_hours: true, admin_hours: true, meals: true, sleep: true };
1171 sioul_core::quiet::Mode { quiet: false, time: sioul_core::areas::Time::Work, week, reason: sioul_core::quiet::Reason::Working, until: None, back: None, label: String::new() }
1172 }
1173 fn starts(&self, _: &Zoned, _: &Zoned) -> Vec<Zoned> {
1174 Vec::new()
1175 }
1176 }
1177 let posted: Posted = serde_json::from_str(r#"{"key":"0|org.example.chat|7|c0ffee12|10100","package":"org.example.chat","app":"Chat de Noa","category":"msg","template":"android.app.Notification$MessagingStyle","title":"Noa Martin","shortcut":"c0ffee12","messages":[{"sender":{"name":"Noa Martin","uri":"tel:%2B33199001234"},"text":"Tu passes ce soir ?"}]}"#).unwrap();
1178 let choices = Choices::default();
1179 let kind = appnotes::classify(&posted, &choices, sioul_core::words::Words::builtin_ref());
1180 let attention = sioul_core::attention::Attention::usual();
1181 let gathered = vec!["09:00".to_string(), "12:00".into(), "15:00".into(), "18:00".into()];
1182 let never = |_: &Zoned| false;
1183 let sources = sioul_core::attention::source_rows(&posted.package, "");
1184 let ask = Ask { now: &now, clock: &Work, attention: &attention, gathered: &gathered, area: None, gate: Some(Gate { until: focus.until }), slot_at: &never, nothing: false, also: &[], sources: &sources, reachable: None, silenced: false };
1185 let decision = appnotes::decide(&kind, Some(Who::Neutral), false, &choices, &ask);
1186 assert_eq!(decision.until, focus.until, "held until the session's end");
1187 let cause = appnotes::cause(&posted.package, &kind, Some(Who::Neutral), false, &choices, &ask);
1188 let said = hold_line("held", kind_word(&kind), &decision, cause.as_ref(), Some(&focus), &now);
1189 assert_eq!(said, "Notes: held: messages 1 until 10:50; why: dnd (focus session).");
1190 for private in ["org.example", "Noa", "Martin", "01 99 00", "+33", "Chat", "c0ffee", "ce soir", "safe", "neutral", "stranger", "blocked", "restricted"] {
1191 assert!(!said.contains(private), "{said} says {private}");
1192 }
1193 }
1194
1195 #[test]
1202 fn a_machine_s_text_is_an_automaton_s() {
1203 let now: Zoned = "2026-10-20T10:00:00+02:00[Europe/Paris]".parse().unwrap();
1204 let config: Config = toml::from_str("[contacts]\nregion = \"FR\"\n").unwrap();
1205 let machine = Sender::Known { numbers: vec!["01 63 12 34 56".into()], emails: Vec::new(), contact: false };
1206 assert!(machine_number(&machine, &config, &now));
1207 assert!(!machine_number(&Sender::Known { numbers: vec!["01 99 00 12 34".into()], emails: Vec::new(), contact: false }, &config, &now));
1208 assert!(!machine_number(&Sender::Named { name: "AMELI".into(), book: true }, &config, &now));
1209 let as_unknown: Config = toml::from_str("[contacts]\nregion = \"FR\"\n[screening]\nautomated = \"unknown\"\n").unwrap();
1210 assert!(!machine_number(&machine, &as_unknown, &now), "set as any number nobody knows");
1211 }
1212
1213 #[test]
1214 fn nothing_reaches_java_on_a_computer() {
1215 assert_eq!(java("access", "{}"), Value::Null);
1216 assert_eq!(access_here(), None, "a computer says nothing of a phone's access");
1217 assert_eq!(times_in_words(&["18:00".into(), "9:00".into(), "13:00".into()]), format!("09:00, 13:00 {} 18:00", tr().text("word-and", None)));
1218 }
1219}