Skip to main content

sioul_app/
appnotes.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Other apps' notifications on a phone (docs/android.md, "Notifications from
5//! other apps"). Android's listener (AppNotes.java, in a process of its own,
6//! ":listener") asks here, for each notification of another app, whether it
7//! comes now or how long it waits (`sioul_appnotes_decide`), and, when
8//! something moved the times (a pause ended, the hours or the choices
9//! changed), how long what it holds waits now (`sioul_appnotes_review`). The
10//! rules are `sioul_core::appnotes`; what reaches you when, `sioul_core::attention`,
11//! with today's slots of time for you from their file (`hours::slots`): Time
12//! for you holds in this process too.
13//!
14//! Also here, for the window: Settings ▸ Other apps (`setup`, `change`), and
15//! the Porch's line on what waits and what came back (`porch_line`). On a
16//! computer, nothing of it runs: the listener is the phone's.
17
18use 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")]
33// SAFETY: declared as android/main.cpp defines them: extern "C", the same types.
34unsafe extern "C" {
35    /// AppNotes.call (android/main.cpp): a verb and its JSON, a JSON answer or null.
36    fn sioul_android_appnotes(verb: *const c_char, json: *const c_char) -> *mut c_char;
37    /// An answer of `sioul_android_appnotes` given back.
38    fn sioul_android_dnd_free(text: *mut c_char);
39}
40
41/// Java's answer to `verb` (AppNotes.call); null elsewhere, or without one.
42fn 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        // SAFETY: two zero-terminated texts, valid for the call.
48        let answer = unsafe { sioul_android_appnotes(verb.as_ptr(), json.as_ptr()) };
49        if answer.is_null() {
50            return Value::Null;
51        }
52        // SAFETY: a zero-terminated text from sioul_android_appnotes, read before it is given back.
53        let text = unsafe { CStr::from_ptr(answer) }.to_string_lossy().to_string();
54        // SAFETY: given back once, as main.cpp asks; not read after.
55        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
65/// Whether Android lets Sioul hold other apps' notifications here
66/// (notification access): on a phone, as Java says now; None elsewhere, or
67/// when Java cannot say (its library not loaded in this process).
68pub(crate) fn access_here() -> Option<bool> {
69    java("access", "{}").as_bool()
70}
71
72/// When a file last changed; none when it is not there.
73fn changed(path: &Path) -> Option<SystemTime> {
74    std::fs::metadata(path).and_then(|m| m.modified()).ok()
75}
76
77/// The clock as it stands, kept a minute in the listener's process: read
78/// again sooner when the pause or free time, Health, the hours or the
79/// settings change. Asked at every notification.
80fn 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
103/// Who wrote, as your address books and lists say (`porch::Senders`): none
104/// when nobody knows a name; and whether the Always through list holds them
105/// (by a number or an address, never a name, which anyone can take).
106fn 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            // In the phone's address book though not in Sioul's: someone you know.
114            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
122/// What a notification's source is for: the app's, as you set it; else, for
123/// mail, the address it came to, when it is one of your accounts' (unsaid: work).
124fn 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
135/// Do-not-disturb's switch or a focus session, holding now: when it ends,
136/// when that is known (a layer of the matrix).
137fn gate() -> Option<Option<i64>> {
138    crate::everywhere::gate()
139}
140
141/// One notification decided (`AppNotes.decide`): {"hold": milliseconds (0:
142/// it comes now), "why"}. What it says is read here and dropped; the ledger
143/// keeps its key hashed, its app's and conversation's names, and its time.
144/// Only for the apps you send to your computers, while that part of the
145/// sharing is on, its messages also go, sealed (`phonemsgs::note`; "shared":
146/// a line was written, Java asks for a step soon).
147fn 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    // Today's slots of time for you, as the window wrote them; where they begin and end.
163    let slots = crate::hours::slots();
164    let slot = |at: &Zoned| slots.at(at);
165    let also = slots.edges();
166    // What reaches you when (Settings ▸ What reaches you); Free time's "Nothing at all".
167    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    // Its own rows in the matrix, when set: its conversation's, then its app's.
170    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        // Back at its time and held again within minutes: not again and again.
183        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    // The phone's messages on your computers: its lines written when its app sends them (`phonemsgs`).
206    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
211/// What Android holds for Sioul now, worked out again (`AppNotes.review`):
212/// {"snooze": [{"key", "ms"}]}, those whose time moved by more than a minute
213/// (1 s: it may come now). Marks of notifications Android no longer holds
214/// (their app took them away, or they came back unseen) are let go.
215fn 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/// `decide`, for Java (android/main.cpp); a panic answers "null", and the
259/// notification comes as it was sent. Given back to `sioul_string_free`.
260///
261/// # Safety
262/// `json` is null, or a zero-terminated text valid for the call.
263// SAFETY: no other symbol of the program has this name.
264#[unsafe(no_mangle)]
265pub unsafe extern "C" fn sioul_appnotes_decide(json: *const c_char) -> *mut c_char {
266    // SAFETY: `json` is null or a C string valid for the call, as this function's contract says.
267    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/// `review`, for Java; "null" when it fails, and what is held keeps its time.
273///
274/// # Safety
275/// `json` is null, or a zero-terminated text valid for the call.
276// SAFETY: no other symbol of the program has this name.
277#[unsafe(no_mangle)]
278pub unsafe extern "C" fn sioul_appnotes_review(json: *const c_char) -> *mut c_char {
279    // SAFETY: `json` is null or a C string valid for the call, as this function's contract says.
280    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
285// ---------------------------------------------------------------- the window
286
287/// The Porch's line on other apps (`appnotes::porch_line`); "" when nothing
288/// waits nor came back lately, and on a computer (no listener there).
289pub(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
297/// The Porch's JSON with its line on other apps (`apps_line`), when there is one.
298pub(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
312/// A row of the tab, as `SettingRow.qml` shows it: {key, kind, label, help, value, choices}.
313fn 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
324/// The table of other apps by time (What reaches you ▸ Exceptions): each app
325/// seen lately a row of the matrix, by name, and the apps' rows set that this
326/// device has not seen (a computer's, set on the phone: by the name written
327/// with them); then the conversations chosen for (a row or a way through)
328/// and the ten seen last ("Title · App"), never a wall of them. Whether
329/// some were left out. `AttentionGrid.qml` draws it.
330fn 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    // Set elsewhere (on the phone, for a computer), or no longer seen here: by the name written with it.
337    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
356/// "09:00, 13:00 and 18:00".
357fn 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
366/// Settings ▸ Other apps, as JSON (AppNotesSetup.qml): whether this is a
367/// phone, the access and its steps, the switch, the times, what rang while
368/// held, then a row for each app, conversation and site seen.
369pub(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                // Its own row of the matrix, in one sentence: "At once: Work; held: Leisure…".
409                "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    // What Android says of each: marked Priority there (it rings through Sioul's modes), its page.
419    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(&times)),
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
458/// A choice of the tab, or one of Android's pages (`AppNotesSetup.act`): the tab again, as JSON.
459/// "set" {key, value}: "hold", `app.<package>.kind`, `app.<package>.area`,
460/// `conversation.<key>`, `site.<host>`; "open-access", "open-info",
461/// "open-app" {package}, "open-channel" {package, channel}; "contacts".
462pub(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        // Android's pages; a conversation's by its key (`talk`), to mark it Priority there.
480        "open-access" | "open-info" | "open-app" | "open-channel" | "open-conversation" => {
481            java(verb, json);
482        }
483        "contacts" => crate::steps::ask_contacts(),
484        // A row of the table by time: its words whole ("attention.app.<package>", nine words), an app's name with them.
485        "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            // What the row showed: only the cells changed from it are set, over the row as it is now.
494            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
508/// One choice written: the app's kind or area, a conversation's way
509/// through, a site at once. What says "as usual" is taken out.
510fn 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    /// The table of other apps by time: each app seen, by name, and an app's row
575    /// set elsewhere by the name written with it; the conversations chosen for
576    /// and the ten seen last, never more; each row's cells as set or as usual.
577    #[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        // The ten seen last, and the two chosen for among the older ones.
598        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        // Nothing seen, nothing set: no table.
608        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}