1use crate::backend::{load_config, tr};
19use jiff::Zoned;
20use serde_json::{Value, json};
21use sioul_core::appnotes::{self, AppKind, Ask, Choices, Decision, Gate, Kind, Ledger, Live, Posted, Sender, Through, Why};
22use sioul_core::areas::Area;
23use sioul_core::config::Config;
24use sioul_core::everywhere::People;
25use sioul_core::porch::{By, Senders};
26use sioul_core::reach::{Clock, Who};
27use std::ffi::c_char;
28use std::path::Path;
29use std::sync::Mutex;
30use std::time::SystemTime;
31
32#[cfg(target_os = "android")]
33unsafe extern "C" {
35 fn sioul_android_appnotes(verb: *const c_char, json: *const c_char) -> *mut c_char;
37 fn sioul_android_dnd_free(text: *mut c_char);
39}
40
41fn java(verb: &str, json: &str) -> Value {
43 #[cfg(target_os = "android")]
44 {
45 use std::ffi::{CStr, CString};
46 let (Ok(verb), Ok(json)) = (CString::new(verb), CString::new(json)) else { return Value::Null };
47 let answer = unsafe { sioul_android_appnotes(verb.as_ptr(), json.as_ptr()) };
49 if answer.is_null() {
50 return Value::Null;
51 }
52 let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
54 unsafe { sioul_android_dnd_free(answer) };
56 serde_json::from_str(&text).unwrap_or(Value::Null)
57 }
58 #[cfg(not(target_os = "android"))]
59 {
60 let _ = (verb, json);
61 Value::Null
62 }
63}
64
65pub(crate) fn access_here() -> Option<bool> {
69 java("access", "{}").as_bool()
70}
71
72fn changed(path: &Path) -> Option<SystemTime> {
74 std::fs::metadata(path).and_then(|m| m.modified()).ok()
75}
76
77fn live(config: &Config, now: &Zoned) -> Live {
81 type Key = (i64, [Option<SystemTime>; 4]);
82 static KEPT: Mutex<Option<(Key, Live)>> = Mutex::new(None);
83 let files = [
84 sioul_core::quiet::Overrides::default_path(),
85 sioul_core::health::Health::default_path(),
86 sioul_core::needs::Days::default_path(),
87 sioul_core::config::config_dir().join("config.toml"),
88 ];
89 let key: Key = (now.timestamp().as_second() / 60, files.each_ref().map(|f| changed(f)));
90 if let Ok(kept) = KEPT.lock()
91 && let Some((known, live)) = kept.as_ref()
92 && *known == key
93 {
94 return live.clone();
95 }
96 let live = Live { clock: Clock::load(config, now) };
97 if let Ok(mut kept) = KEPT.lock() {
98 *kept = Some((key, live.clone()));
99 }
100 live
101}
102
103fn who_of(sender: &Sender, senders: &Senders, always: &People, config: &Config) -> (Option<Who>, bool) {
107 match sender {
108 Sender::Known { numbers, emails, contact } => {
109 let by_number = numbers.iter().map(|n| senders.judge_number(n)).find(|j| j.by != By::Default).map(|j| j.who);
110 let by_address = emails.iter().map(|e| senders.who(e)).find(|w| *w != Who::Stranger);
111 let region = sioul_core::reach::region(config);
112 let admitted = numbers.iter().any(|n| always.admits_number(n, region)) || emails.iter().any(|e| always.admits_address(e));
113 let unknown = if *contact { Who::Neutral } else { Who::Stranger };
115 (Some(by_number.or(by_address).unwrap_or(unknown)), admitted)
116 }
117 Sender::Named { name, book } => (senders.judge_name(name).map(|j| appnotes::named(Some(j.who), *book)), false),
118 Sender::Unknown => (None, false),
119 }
120}
121
122fn area_of(posted: &Posted, kind: &Kind, choices: &Choices, config: &Config) -> Option<Area> {
125 choices.app(&posted.package).area.or_else(|| match kind {
126 Kind::People(talk) if !talk.to.is_empty() => config
127 .accounts
128 .iter()
129 .find(|a| a.address.as_deref().is_some_and(|address| address.trim().eq_ignore_ascii_case(&talk.to)))
130 .map(|a| a.area.as_deref().and_then(Area::parse).unwrap_or(Area::WORK)),
131 _ => None,
132 })
133}
134
135fn gate() -> Option<Option<i64>> {
138 crate::everywhere::gate()
139}
140
141fn decide(json: &str) -> String {
148 let Ok(posted) = serde_json::from_str::<Posted>(json) else { return "null".into() };
149 let config = load_config();
150 let choices = Choices::load(&Choices::default_path());
151 let kind = appnotes::classify(&posted, &choices, &sioul_core::words::Words::of(&config));
152 let now = Zoned::now();
153 let stamp = now.timestamp().as_second();
154 let gate = gate();
155 let (who, admitted) = match &kind {
156 Kind::People(talk) => who_of(&talk.sender, &Senders::load(&config), &People::load(&People::default_path()), &config),
157 _ => (None, false),
158 };
159 let area = area_of(&posted, &kind, &choices, &config);
160 let live = live(&config, &now);
161 let gathered = config.reminders.gathered_times();
162 let slots = crate::hours::slots();
164 let slot = |at: &Zoned| slots.at(at);
165 let also = slots.edges();
166 let attention = sioul_core::attention::Attention::of(&config);
168 let nothing = sioul_core::pause::nothing_now(&sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path()), &config.free_time);
169 let conversation = match &kind {
171 Kind::People(talk) => talk.conversation.as_ref().map(|c| c.key.clone()).unwrap_or_default(),
172 _ => String::new(),
173 };
174 let sources = sioul_core::attention::source_rows(&posted.package, &conversation);
175 let ask = Ask { now: &now, clock: &live, attention: &attention, gathered: &gathered, area, gate: gate.map(|until| Gate { until }), slot_at: &slot, nothing, also: &also, sources: &sources };
176 let hash = appnotes::key_hash(&posted.key);
177 let path = Ledger::default_path();
178 let decision = sioul_core::filelock::with_lock(&path, || {
179 let mut ledger = Ledger::load(&path);
180 let returning = ledger.returning(&hash, stamp);
181 let mut decision = appnotes::decide(&kind, who, admitted, &choices, &ask);
182 if returning
184 && let Some(until) = decision.until
185 && decision.why != Why::Never
186 {
187 let short = until - stamp < appnotes::AGAIN_SHORT;
188 let again = if short { ledger.held.get(&hash).map_or(0, |h| h.again) + 1 } else { 0 };
189 if again > appnotes::AGAIN_MAX {
190 decision = Decision::through(Why::Again);
191 } else if let Some(held) = ledger.held.get_mut(&hash) {
192 held.again = again;
193 }
194 }
195 if returning && decision.until.is_none() {
196 ledger.came_back(&hash, stamp);
197 }
198 ledger.note(&posted, &kind, who, admitted, area, &decision, stamp);
199 ledger.forget_old(stamp, &choices);
200 if let Err(e) = ledger.save(&path) {
201 eprintln!("Notes: {e}");
202 }
203 decision
204 });
205 let shared = crate::phonemsgs::note(json, &posted, &kind, who, admitted, &decision);
207 let hold = decision.until.map_or(0, |until| (until - stamp).max(1) * 1000);
208 json!({ "hold": hold, "why": decision.why.id(), "shared": shared }).to_string()
209}
210
211fn review(json: &str) -> String {
216 let asked: Value = serde_json::from_str(json).unwrap_or(Value::Null);
217 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();
218 let config = load_config();
219 let choices = Choices::load(&Choices::default_path());
220 let now = Zoned::now();
221 let stamp = now.timestamp().as_second();
222 let gate = gate();
223 let live = live(&config, &now);
224 let gathered = config.reminders.gathered_times();
225 let slots = crate::hours::slots();
226 let slot = |at: &Zoned| slots.at(at);
227 let also = slots.edges();
228 let attention = sioul_core::attention::Attention::of(&config);
229 let nothing = sioul_core::pause::nothing_now(&sioul_core::quiet::Overrides::load(&sioul_core::quiet::Overrides::default_path()), &config.free_time);
230 let path = Ledger::default_path();
231 let again = sioul_core::filelock::with_lock(&path, || {
232 let mut ledger = Ledger::load(&path);
233 let present: std::collections::BTreeSet<String> = keys.iter().map(|k| appnotes::key_hash(k)).collect();
234 ledger.held.retain(|hash, held| present.contains(hash) || stamp - held.since < 60);
235 let mut again = Vec::new();
236 for key in &keys {
237 let hash = appnotes::key_hash(key);
238 let Some(held) = ledger.held.get(&hash).cloned() else { continue };
239 let sources = sioul_core::attention::source_rows(&held.app, &held.conversation);
240 let ask = Ask { now: &now, clock: &live, attention: &attention, gathered: &gathered, area: held.area, gate: gate.map(|until| Gate { until }), slot_at: &slot, nothing, also: &also, sources: &sources };
241 let until = appnotes::again(&held, &choices, &ask).until.unwrap_or(stamp);
242 if (until - held.until).abs() > 60 {
243 if let Some(kept) = ledger.held.get_mut(&hash) {
244 kept.until = until;
245 }
246 again.push(json!({ "key": key, "ms": (until - stamp).max(1) * 1000 }));
247 }
248 }
249 ledger.forget_old(stamp, &choices);
250 if let Err(e) = ledger.save(&path) {
251 eprintln!("Notes: {e}");
252 }
253 again
254 });
255 json!({ "snooze": again }).to_string()
256}
257
258#[unsafe(no_mangle)]
265pub unsafe extern "C" fn sioul_appnotes_decide(json: *const c_char) -> *mut c_char {
266 let json = unsafe { crate::alarms::key_of(json) };
268 let answer = std::panic::catch_unwind(|| decide(&json)).unwrap_or_else(|_| "null".to_string());
269 crate::alarms::handed(answer)
270}
271
272#[unsafe(no_mangle)]
278pub unsafe extern "C" fn sioul_appnotes_review(json: *const c_char) -> *mut c_char {
279 let json = unsafe { crate::alarms::key_of(json) };
281 let answer = std::panic::catch_unwind(|| review(&json)).unwrap_or_else(|_| "null".to_string());
282 crate::alarms::handed(answer)
283}
284
285pub(crate) fn porch_line() -> String {
290 let path = Ledger::default_path();
291 if !path.exists() {
292 return String::new();
293 }
294 appnotes::porch_line(tr(), &Ledger::load(&path), &Zoned::now())
295}
296
297pub(crate) fn with_line(porch: String) -> String {
299 let line = porch_line();
300 if line.is_empty() {
301 return porch;
302 }
303 match serde_json::from_str::<Value>(&porch) {
304 Ok(Value::Object(mut fields)) => {
305 fields.insert("apps_line".into(), Value::String(line));
306 Value::Object(fields).to_string()
307 }
308 _ => porch,
309 }
310}
311
312fn row(key: &str, kind: &str, label: &str, help: &str, value: Value, choices: Vec<(Value, String)>) -> Value {
314 json!({
315 "key": key,
316 "kind": kind,
317 "label": label,
318 "help": help,
319 "value": value,
320 "choices": choices.into_iter().map(|(value, label)| json!({ "value": value, "label": label })).collect::<Vec<_>>(),
321 })
322}
323
324fn by_time(attention: &sioul_core::attention::Attention, ledger: &Ledger, choices: &Choices) -> (Value, bool) {
331 use sioul_core::attention::{APP_ROW, CONVERSATION_ROW, SourceLine};
332 let tr = tr();
333 let apps_group = tr.text("attention-source-group-apps", None);
334 let talks_group = tr.text("attention-source-group-conversations", None);
335 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();
336 for id in attention.sources().keys().filter(|id| id.starts_with(APP_ROW)) {
338 if !apps.iter().any(|a| &a.id == id) {
339 let name = attention.source_name(id);
340 let label = if name.is_empty() { id[APP_ROW.len()..].to_string() } else { name.to_string() };
341 apps.push(SourceLine { id: id.clone(), label, group: apps_group.clone(), group_id: "app-rows".into() });
342 }
343 }
344 apps.sort_by_cached_key(|a| sioul_core::text::fold(&a.label).into_iter().collect::<String>());
345 let mut talks: Vec<(&String, &appnotes::SeenTalk)> = ledger.conversations.iter().collect();
346 talks.sort_by_key(|(_, t)| -t.seen);
347 let total = talks.len();
348 let chosen = |key: &str| choices.conversation.contains_key(key) || attention.source(&format!("{CONVERSATION_ROW}{key}")).is_some();
349 let talks: Vec<(&String, &appnotes::SeenTalk)> = talks.into_iter().enumerate().filter(|(i, (key, _))| *i < 10 || chosen(key)).map(|(_, t)| t).collect();
350 let more = talks.len() < total;
351 let mut lines = apps;
352 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() }));
353 (serde_json::to_value(sioul_core::attention::source_grid(attention, tr, &lines)).unwrap_or(Value::Null), more)
354}
355
356fn times_in_words(times: &[String]) -> String {
358 let times: Vec<String> = appnotes::times_of(times).iter().map(|t| t.strftime("%H:%M").to_string()).collect();
359 match times.as_slice() {
360 [] => String::new(),
361 [one] => one.clone(),
362 [rest @ .., last] => format!("{} {} {last}", rest.join(", "), tr().text("word-and", None)),
363 }
364}
365
366pub(crate) fn setup() -> String {
370 let android = cfg!(target_os = "android");
371 let config = load_config();
372 let choices = Choices::load(&Choices::default_path());
373 let ledger = Ledger::load(&Ledger::default_path());
374 let now = Zoned::now();
375 let tr = tr();
376 let text = |id: &str| tr.text(id, None);
377 let mut times = sioul_core::i18n::args();
378 times.set("times", times_in_words(&config.reminders.gathered_times()));
379 let heard = if ledger.heard > 0 {
380 let mut args = sioul_core::i18n::args();
381 let at = jiff::Timestamp::from_second(ledger.heard).map(|t| t.to_zoned(now.time_zone().clone())).unwrap_or_else(|_| now.clone());
382 args.set("when", tr.when(&at));
383 tr.text("appnotes-heard", Some(&args))
384 } else {
385 String::new()
386 };
387 let mut rang: Vec<Value> = Vec::new();
388 for (package, app) in &ledger.apps {
389 for (channel, r) in &app.rang {
390 let mut args = sioul_core::i18n::args();
391 args.set("app", app.label.clone());
392 args.set("channel", if r.name.is_empty() { channel.clone() } else { r.name.clone() });
393 rang.push(json!({ "package": package, "channel": channel, "line": tr.text("appnotes-rang-line", Some(&args)) }));
394 }
395 }
396 let kinds: Vec<(Value, String)> = AppKind::ALL.iter().map(|k| (json!(k.id()), text(&format!("appnotes-kind-{}", k.id())))).collect();
397 let attention = sioul_core::attention::Attention::of(&config);
398 let (grid, more) = by_time(&attention, &ledger, &choices);
399 let mut apps: Vec<(&String, &appnotes::SeenApp)> = ledger.apps.iter().collect();
400 apps.sort_by_cached_key(|(_, a)| sioul_core::text::fold(&a.label).into_iter().collect::<String>());
401 let apps: Vec<Value> = apps
402 .into_iter()
403 .map(|(package, seen)| {
404 let chosen = choices.app(package);
405 let why = appnotes::why_text(tr, &seen.why, seen.until, &now);
406 json!({
407 "package": package,
408 "times": sioul_core::attention::source_sentence(&attention, tr, &format!("{}{package}", sioul_core::attention::APP_ROW)),
410 "kind": row(&format!("app.{package}.kind"), "choice", &seen.label, &why, json!(chosen.kind.id()), kinds.clone()),
411 "area": row(&format!("app.{package}.area"), "areas", &text("appnotes-area"), "", json!(chosen.area.map(Area::id).unwrap_or_default()), Vec::new()),
412 })
413 })
414 .collect();
415 let throughs: Vec<(Value, String)> = Through::ALL.iter().map(|t| (json!(t.id()), text(&format!("appnotes-through-{}", t.id())))).collect();
416 let mut talks: Vec<(&String, &appnotes::SeenTalk)> = ledger.conversations.iter().collect();
417 talks.sort_by_key(|(_, t)| -t.seen);
418 let android_says = if android { java("conversations", "{}") } else { Value::Null };
420 let conversations: Vec<Value> = talks
421 .into_iter()
422 .map(|(key, seen)| {
423 let label = format!("{} · {}", seen.title, seen.label);
424 let help = if seen.group { text("appnotes-group") } else { String::new() };
425 let mut said = row(&format!("conversation.{key}"), "choice", &label, &help, json!(choices.through(key).id()), throughs.clone());
426 said["talk"] = json!(key);
427 said["important"] = json!(android_says["conversations"][key.as_str()]["important"] == true);
428 said
429 })
430 .collect();
431 let at_once = vec![(json!(false), text("appnotes-site-gathered")), (json!(true), text("appnotes-site-at-once"))];
432 let sites: Vec<Value> = ledger
433 .sites
434 .iter()
435 .map(|(host, seen)| {
436 let app = ledger.apps.get(&seen.app).map_or(seen.app.as_str(), |a| a.label.as_str());
437 row(&format!("site.{host}"), "choice", &format!("{host} · {app}"), "", json!(choices.site.get(host).is_some_and(|s| s.at_once)), at_once.clone())
438 })
439 .collect();
440 json!({
441 "android": android,
442 "access": java("access", "{}") == Value::Bool(true),
443 "restricted": java("restricted", "{}") == Value::Bool(true),
444 "contacts": java("contacts", "{}") == Value::Bool(true),
445 "hold": row("hold", "bool", &text("appnotes-hold"), &text("appnotes-hold-help"), json!(choices.hold), Vec::new()),
446 "times": tr.text("appnotes-times", Some(×)),
447 "heard": heard,
448 "rang": rang,
449 "apps": apps,
450 "conversations": conversations,
451 "sites": sites,
452 "grid": grid,
453 "more": more,
454 })
455 .to_string()
456}
457
458pub(crate) fn change(verb: &str, json: &str) -> String {
463 let asked: Value = serde_json::from_str(json).unwrap_or(Value::Null);
464 match verb {
465 "set" => {
466 let key = asked["key"].as_str().unwrap_or_default().to_string();
467 let value = asked["value"].clone();
468 let path = Choices::default_path();
469 let result = sioul_core::filelock::with_lock(&path, || {
470 let mut choices = Choices::load(&path);
471 let ledger = Ledger::load(&Ledger::default_path());
472 set(&mut choices, &ledger, &key, &value);
473 choices.save(&path)
474 });
475 if let Err(e) = result {
476 eprintln!("Notes: {e}");
477 }
478 }
479 "open-access" | "open-info" | "open-app" | "open-channel" | "open-conversation" => {
481 java(verb, json);
482 }
483 "contacts" => crate::steps::ask_contacts(),
484 "row" => {
486 let key = asked["key"].as_str().unwrap_or_default();
487 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();
488 if let Some(package) = key.strip_prefix("attention.").and_then(|id| id.strip_prefix(sioul_core::attention::APP_ROW))
489 && let Some(seen) = Ledger::load(&Ledger::default_path()).apps.get(package)
490 {
491 words.push(format!("{}{}", sioul_core::attention::NAME, seen.label));
492 }
493 let shown: Option<Vec<String>> = asked["shown"].as_array().map(|w| w.iter().filter_map(|w| w.as_str().map(str::to_string)).collect());
495 let shown = shown.map(|mut shown| {
496 shown.extend(words.iter().filter(|w| w.starts_with(sioul_core::attention::NAME)).cloned());
497 sioul_core::config::SettingValue::Texts(shown)
498 });
499 if let Err(e) = sioul_core::attention::apply_change(&crate::backend::config_path(), key, shown.as_ref(), &sioul_core::config::SettingValue::Texts(words)) {
500 eprintln!("Notes: {e}");
501 }
502 }
503 _ => {}
504 }
505 setup()
506}
507
508fn set(choices: &mut Choices, ledger: &Ledger, key: &str, value: &Value) {
511 if key == "hold" {
512 choices.hold = value.as_bool().unwrap_or(true);
513 } else if let Some(rest) = key.strip_prefix("app.") {
514 let Some((package, field)) = rest.rsplit_once('.') else { return };
515 let mut chosen = choices.app(package);
516 match field {
517 "kind" => chosen.kind = value.as_str().and_then(AppKind::read).unwrap_or_default(),
518 "area" => chosen.area = value.as_str().and_then(Area::parse),
519 _ => return,
520 }
521 chosen.label = ledger.apps.get(package).map(|a| a.label.clone()).unwrap_or(chosen.label);
522 if chosen.kind == AppKind::Usual && chosen.area.is_none() {
523 choices.app.remove(package);
524 } else {
525 choices.app.insert(package.to_string(), chosen);
526 }
527 } else if let Some(conversation) = key.strip_prefix("conversation.") {
528 let through = value.as_str().and_then(Through::read).unwrap_or_default();
529 if through == Through::Usual {
530 choices.conversation.remove(conversation);
531 } else {
532 let seen = ledger.conversations.get(conversation);
533 let title = seen.map(|s| s.title.clone()).or_else(|| choices.conversation.get(conversation).map(|c| c.title.clone())).unwrap_or_default();
534 let app = seen.map(|s| s.label.clone()).or_else(|| choices.conversation.get(conversation).map(|c| c.app.clone())).unwrap_or_default();
535 choices.conversation.insert(conversation.to_string(), appnotes::TalkChoice { through, title, app });
536 }
537 } else if let Some(host) = key.strip_prefix("site.") {
538 if value.as_bool().unwrap_or(false) {
539 choices.site.insert(host.to_string(), appnotes::SiteChoice { at_once: true });
540 } else {
541 choices.site.remove(host);
542 }
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn choices_set_from_the_tab() {
552 let mut choices = Choices::default();
553 let mut ledger = Ledger::default();
554 ledger.apps.insert("com.discord".into(), appnotes::SeenApp { label: "Discord".into(), ..Default::default() });
555 ledger.conversations.insert("c1".into(), appnotes::SeenTalk { app: "com.whatsapp".into(), label: "WhatsApp".into(), title: "École".into(), group: true, seen: 1 });
556 set(&mut choices, &ledger, "app.com.discord.kind", &json!("automaton"));
557 set(&mut choices, &ledger, "app.com.discord.area", &json!("leisure"));
558 assert_eq!(choices.app("com.discord"), appnotes::AppChoice { kind: AppKind::Automaton, area: Some(Area::LEISURE), label: "Discord".into() });
559 set(&mut choices, &ledger, "app.com.discord.kind", &json!("usual"));
560 set(&mut choices, &ledger, "app.com.discord.area", &json!(""));
561 assert!(choices.app.is_empty(), "as usual: taken out");
562 set(&mut choices, &ledger, "conversation.c1", &json!("always"));
563 assert_eq!(choices.conversation["c1"], appnotes::TalkChoice { through: Through::Always, title: "École".into(), app: "WhatsApp".into() });
564 set(&mut choices, &ledger, "conversation.c1", &json!("usual"));
565 assert!(choices.conversation.is_empty());
566 set(&mut choices, &ledger, "site.forum.example.net", &json!(true));
567 assert!(choices.site["forum.example.net"].at_once);
568 set(&mut choices, &ledger, "site.forum.example.net", &json!(false));
569 assert!(choices.site.is_empty());
570 set(&mut choices, &ledger, "hold", &json!(false));
571 assert!(!choices.hold);
572 }
573
574 #[test]
578 fn the_table_by_time() {
579 let mut ledger = Ledger::default();
580 ledger.apps.insert("com.discord".into(), appnotes::SeenApp { label: "Discord".into(), ..Default::default() });
581 ledger.apps.insert("org.example.news".into(), appnotes::SeenApp { label: "Actualités".into(), ..Default::default() });
582 for i in 0..14 {
583 ledger.conversations.insert(format!("c{i:02}"), appnotes::SeenTalk { app: "com.discord".into(), label: "Discord".into(), title: format!("Salon {i}"), group: true, seen: i });
584 }
585 let mut choices = Choices::default();
586 choices.conversation.insert("c00".into(), appnotes::TalkChoice { through: Through::Always, ..Default::default() });
587 let mut attention = sioul_core::attention::Attention::usual();
588 attention.set_source("app.com.discord", sioul_core::attention::Column::Work, sioul_core::attention::Level::Now).unwrap();
589 attention.set_source("app.org.example.chat", sioul_core::attention::Column::Leisure, sioul_core::attention::Level::Later).unwrap();
590 attention.name_source("app.org.example.chat", "Chat");
591 attention.set_source("conversation.c01", sioul_core::attention::Column::Sleep, sioul_core::attention::Level::Later).unwrap();
592 let (grid, more) = by_time(&attention, &ledger, &choices);
593 let ids: Vec<&str> = grid["rows"].as_array().unwrap().iter().map(|r| r["id"].as_str().unwrap()).collect();
594 let labels: Vec<&str> = grid["rows"].as_array().unwrap().iter().map(|r| r["label"].as_str().unwrap()).collect();
595 assert_eq!(&ids[..3], ["app.org.example.news", "app.org.example.chat", "app.com.discord"], "by name: Actualités, Chat, Discord");
596 assert_eq!(&labels[..3], ["Actualités", "Chat", "Discord"]);
597 let talks: Vec<&str> = ids.iter().filter(|id| id.starts_with("conversation.")).copied().collect();
599 assert_eq!(talks.len(), 12);
600 assert!(talks.contains(&"conversation.c00") && talks.contains(&"conversation.c01") && !talks.contains(&"conversation.c02") && talks[0] == "conversation.c13");
601 assert!(more, "two conversations left out");
602 let discord = &grid["rows"][2];
603 assert_eq!(discord["cells"][0]["value"], "now");
604 assert_eq!(discord["cells"][1]["value"], "as");
605 assert_eq!(discord["choices"].as_array().unwrap().len(), 4);
606 assert_eq!(grid["columns"].as_array().unwrap().len(), 9);
607 let (none, more) = by_time(&sioul_core::attention::Attention::usual(), &Ledger::default(), &Choices::default());
609 assert!(none["rows"].as_array().unwrap().is_empty() && !more);
610 }
611
612 #[test]
613 fn nothing_reaches_java_on_a_computer() {
614 assert_eq!(java("access", "{}"), Value::Null);
615 assert_eq!(access_here(), None, "a computer says nothing of a phone's access");
616 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)));
617 }
618}