Skip to main content

sioul_core/
settings.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Settings, shown where they apply. Each page has its own few, behind one
5//! small button, each with a sentence saying what it changes; there is no long
6//! preferences window (docs/client.md, "Calm first"). What is set here is
7//! written to the configuration file in place, its comments kept
8//! (`config::set_value`), or to the senders' lists.
9
10use crate::config::{Config, SettingValue, WindowValue, set_value};
11use crate::i18n::Translator;
12use crate::porch::SenderList;
13use serde::Serialize;
14use std::path::Path;
15
16/// How a setting is edited.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Kind {
20    /// On or off.
21    Bool,
22    /// A whole number, between `min` and `max`.
23    Int,
24    /// A number with decimals, between `min` and `max` by `step`.
25    Float,
26    /// One of `choices`.
27    Choice,
28    /// Some of `choices`, each ticked or not: `value` lists those ticked.
29    Picks,
30    Text,
31    /// Several lines.
32    Long,
33    /// A folder on disk.
34    Folder,
35    /// A font family.
36    Font,
37    /// Minutes for each weekday, Monday first.
38    Week,
39    /// Hours for each day of the week: on or off, from a start to an end.
40    Windows,
41    /// Days off: from a day to another.
42    #[serde(rename = "timeoff")]
43    TimeOff,
44    /// Addresses and @domains, one per row.
45    Senders,
46    /// Words, one per row.
47    Words,
48    /// What a source is for: work, your admin, leisure, any of them together ("admin+leisure").
49    Areas,
50    /// A project's routes: domains, addresses, words in the subject or the text.
51    Routes,
52    /// Kinds of task: each renamed in place or taken away; new ones added.
53    Kinds,
54    /// Categories in use: each renamed or taken off every task that has it.
55    Categories,
56    /// Task lists, calendars, address books: renamed in place, deleted when empty.
57    Collections,
58    /// Nothing to change: what a lane holds (`label`) and how mail lands there (`help`, a line each).
59    Note,
60    /// A secret kept in the system keyring, typed and never shown: `value` says whether one is kept.
61    Secret,
62    /// Nothing to change here: a button to where it is changed (`value`: "needs", the Health page's meals and sleep).
63    Link,
64    /// Nothing to change: a sentence (`help`) and a button (`label`) that
65    /// does one thing when pressed, never by itself; pressing it saves `true`
66    /// under its key (the projects' file renamed: `projects::rename_file`).
67    Action,
68    /// Round buttons in a grid: `rows` down, `choices` across, one per row;
69    /// `value` lists each row's as "row:column"; saved a row at a time
70    /// (`<key>.<row>`): what Sioul's own spam filter does with each verdict.
71    Radios,
72    /// Sioul's own spam filter, the window's own block (`SpamFilter.qml`): on
73    /// a computer, "Train now" and what the last training found; on a phone,
74    /// where its table comes from. Nothing in `value`.
75    Spam,
76    /// The mail filters, the window's own block (`MailFilters.qml`): the
77    /// list, each in words, its editor, "Try it" and running them on the
78    /// inboxes; read and written whole (`config::set_filters`). Nothing in `value`.
79    Filters,
80}
81
82/// One of a setting's choices.
83#[derive(Debug, Clone, PartialEq, Serialize)]
84pub struct Choice {
85    pub value: SettingValue,
86    pub label: String,
87}
88
89/// One setting, as its page shows it.
90#[derive(Debug, Clone, PartialEq, Serialize)]
91pub struct Setting {
92    /// Where it is written: `history_weeks`, `reading.size`, `account.<id>.shield`, `window`, `known`, `blocked`.
93    pub key: String,
94    pub kind: Kind,
95    pub label: String,
96    /// What it changes, in a sentence.
97    pub help: String,
98    pub value: SettingValue,
99    #[serde(skip_serializing_if = "Vec::is_empty")]
100    pub choices: Vec<Choice>,
101    /// A grid's rows (`Kind::Radios`); its columns are the choices.
102    #[serde(skip_serializing_if = "Vec::is_empty")]
103    pub rows: Vec<Choice>,
104    pub min: f64,
105    pub max: f64,
106    pub step: f64,
107    /// "min", "px": said after the number.
108    pub unit: String,
109    /// A heading over a group of settings: an account's address.
110    pub group: String,
111    /// The tab it is in, on a page with tabs (Settings): "look", "hours", "reminders", "files", "invoices".
112    #[serde(skip_serializing_if = "String::is_empty")]
113    pub section: String,
114}
115
116struct Builder<'a> {
117    tr: &'a Translator,
118    out: Vec<Setting>,
119    group: String,
120    section: String,
121}
122
123impl Builder<'_> {
124    /// `id` names the label (`set-<id>`) and the sentence (`set-<id>-help`).
125    fn push(&mut self, key: &str, id: &str, kind: Kind, value: SettingValue) -> &mut Setting {
126        let label = self.tr.text(&format!("set-{id}"), None);
127        let help = self.tr.text(&format!("set-{id}-help"), None);
128        self.out.push(Setting { key: key.to_string(), kind, label, help, value, choices: Vec::new(), rows: Vec::new(), min: 0.0, max: 0.0, step: 1.0, unit: String::new(), group: self.group.clone(), section: self.section.clone() });
129        let last = self.out.len() - 1;
130        &mut self.out[last]
131    }
132
133    /// The collections of a kind, to rename or delete, named "account/id":
134    /// every one, empty ones too; a read-only one listed, fixed, its row
135    /// saying why (never offered for a new item: 21 October 2026); none, no setting.
136    fn collections(&mut self, config: &Config, id: &str, kind: crate::vdir::Kind, holding: &str) {
137        use crate::capabilities::{Provider, provider_of_collection};
138        let named: Vec<crate::config::NamedValue> = crate::vdir::collections(kind)
139            .into_iter()
140            .filter(|c| holding.is_empty() || c.holds(holding))
141            .map(|c| crate::config::NamedValue {
142                id: format!("{}/{}/{}", if kind == crate::vdir::Kind::Contacts { "contacts" } else { "calendars" }, c.account, c.id),
143                // Google's calendars and address books change on its own pages only.
144                locked: if c.read_only {
145                    self.tr.text("collection-read-only", None)
146                } else if provider_of_collection(config.account(&c.account), &c) == Provider::Google {
147                    self.tr.text("google-fixed-collection", None)
148                } else {
149                    String::new()
150                },
151                detail: { let place = c.place(config); if place.is_empty() { self.tr.text("collection-here", None) } else { place } },
152                label: c.name,
153            })
154            .collect();
155        if !named.is_empty() {
156            self.push("collections", id, Kind::Collections, SettingValue::Named(named));
157        }
158    }
159
160    /// The key for the AI, when an address allows it; whether one is kept is the window's to say.
161    fn ai_key(&mut self, config: &Config) {
162        if config.accounts.iter().any(|a| a.ai_reads()) {
163            self.push("ai_key", "ai-key", Kind::Secret, SettingValue::Bool(false));
164        }
165    }
166
167    /// A row whose words are given rather than found by an id (the words tab's).
168    fn said(&mut self, key: &str, kind: Kind, label: String, help: String, value: SettingValue) -> &mut Setting {
169        self.out.push(Setting { key: key.to_string(), kind, label, help, value, choices: Vec::new(), rows: Vec::new(), min: 0.0, max: 0.0, step: 1.0, unit: String::new(), group: self.group.clone(), section: self.section.clone() });
170        let last = self.out.len() - 1;
171        &mut self.out[last]
172    }
173
174    /// A sentence to read, nothing to change.
175    fn note(&mut self, about: String, lines: Vec<String>) {
176        let key = format!("note.{}", self.out.len());
177        self.out.push(Setting { key, kind: Kind::Note, label: about, help: lines.join("\n"), value: SettingValue::Text(String::new()), choices: Vec::new(), rows: Vec::new(), min: 0.0, max: 0.0, step: 1.0, unit: String::new(), group: self.group.clone(), section: self.section.clone() });
178    }
179
180    fn range(setting: &mut Setting, min: f64, max: f64, step: f64, unit: &str) {
181        setting.min = min;
182        setting.max = max;
183        setting.step = step;
184        setting.unit = unit.to_string();
185    }
186}
187
188/// The weeks of history a setting offers, and how each reads.
189fn history_choices(tr: &Translator, with_default: bool) -> Vec<Choice> {
190    let mut out = Vec::new();
191    if with_default {
192        out.push(Choice { value: SettingValue::Int(-1), label: tr.text("set-like-others", None) });
193    }
194    for weeks in [1, 2, 4, 13, 26, 52, 0] {
195        out.push(Choice { value: SettingValue::Int(weeks), label: tr.text(&format!("history-{weeks}"), None) });
196    }
197    out
198}
199
200/// The settings of one page: "porch", "mail", "agenda", "tasks", "notes",
201/// "reading", "contacts", "time", "general". `lists` names the task lists
202/// a new task can go into, as ("account/id", name).
203pub fn for_view(view: &str, config: &Config, tr: &Translator, lists: &[(String, String)], store: Option<&crate::projects::ProjectStore>) -> Vec<Setting> {
204    let mut b = Builder { tr, out: Vec::new(), group: String::new(), section: String::new() };
205    let int = |n: Option<u32>, default: u32| SettingValue::Int(i64::from(n.unwrap_or(default)));
206    let known = || SettingValue::Texts(SenderList::load(&config.known_senders_path()).entries());
207    // The Porch's lanes: what the Porch itself decides of where their mail
208    // lands. The rest belongs to its owner, said in the lane's rules: an
209    // address's rank and shield in Accounts, a project's routes in Projects,
210    // who is blocked in Settings.
211    if let Some(lane) = view.strip_prefix("lane:") {
212        match lane {
213            "people" | "screener" => {
214                b.push("known", "known", Kind::Senders, known());
215            }
216            // The automatic senders' words are in Settings ▸ Words, with the others (docs/words.md).
217            "filed" => {
218                b.push("link.words.senders", "filed-words", Kind::Link, SettingValue::Text("settings:words.senders.automatic".into()));
219            }
220            _ => {}
221        }
222        return b.out;
223    }
224    // A project's routes: the mail that belongs to it, on its page in Projects.
225    if let Some(id) = view.strip_prefix("project:") {
226        if let Some(project) = store.and_then(|s| s.get(id)) {
227            let routes = project.routes.iter().map(crate::projects::RouteValue::from).collect();
228            b.push(&format!("project.{}.routes", project.id), "routes", Kind::Routes, SettingValue::Routes(routes));
229        }
230        return b.out;
231    }
232    // One address's own settings, on its card in Accounts (its rank, name and
233    // signature are on the card itself): what it is for, how much of it, how
234    // often its folders are fetched, its shield.
235    if let Some(id) = view.strip_prefix("account:") {
236        if let Some(account) = config.account(id).filter(|a| a.kind == crate::config::AccountKind::Imap) {
237            let key = |field: &str| format!("account.{}.{field}", account.id);
238            // Unsaid: work's, so that it never reaches your evenings.
239            let area = account.area.as_deref().and_then(crate::areas::Area::parse).unwrap_or(crate::areas::Area::WORK);
240            b.push(&key("area"), "account-area", Kind::Areas, SettingValue::Text(area.id()));
241            let own = raw_account_history(config, &account.id);
242            let s = b.push(&key("history_weeks"), "account-history", Kind::Choice, SettingValue::Int(own.map_or(-1, i64::from)));
243            s.choices = history_choices(tr, true);
244            let s = b.push(&key("fetch_minutes"), "account-fetch", Kind::Int, SettingValue::Int(account.fetch_minutes.map_or(0, i64::from)));
245            Builder::range(s, 0.0, 1440.0, 5.0, "min");
246            b.push(&key("shield"), "account-shield", Kind::Bool, SettingValue::Bool(account.shield));
247            // The AI's reading sends a message's text to Anthropic: not offered for a work or
248            // school Microsoft account, whose mail its organisation must allow to go there.
249            if account.shield && account.is_microsoft_work() {
250                b.note(tr.text("set-account-shield-ai-work", None), Vec::new());
251            } else if account.shield {
252                b.push(&key("shield_ai"), "account-shield-ai", Kind::Bool, SettingValue::Bool(account.shield_ai));
253            }
254        }
255        return b.out;
256    }
257    match view {
258        "porch" => {
259            // Working hours and days off are Sioul's as a whole: in the settings page.
260            b.note(tr.text("set-hours-elsewhere", None), Vec::new());
261            // Which projects have their lane here; each is made, renamed and routed in Projects.
262            if let Some(store) = store.filter(|s| !s.projects.is_empty()) {
263                b.group = tr.text("set-projects-group", None);
264                let shown = store.projects.iter().filter(|c| !config.porch.hidden_projects.contains(&c.id)).map(|c| c.id.clone()).collect();
265                let s = b.push("porch.projects", "porch-projects", Kind::Picks, SettingValue::Texts(shown));
266                s.choices = store.projects.iter().map(|c| Choice { value: SettingValue::Text(c.id.clone()), label: c.title.clone() }).collect();
267            }
268            // Paper letters: the folder their scans arrive in.
269            b.group = tr.text("set-letters-group", None);
270            let inbox = config.letters.inbox.clone().unwrap_or_else(|| config.notes_root_path().map(|r| crate::letters::Letters::folder(&r).join("inbox").display().to_string()).unwrap_or_default());
271            b.push("letters.inbox", "letters-inbox", Kind::Folder, SettingValue::Text(inbox));
272            // The calls your phones declined, on the Porch of every device (docs/porch.md, "Calls declined").
273            b.group = tr.text("set-calls-group", None);
274            b.push("porch.calls", "porch-calls", Kind::Bool, SettingValue::Bool(config.porch.calls));
275            // How a message opened here reads: the reading panel's own, shown where messages are read.
276            b.group = tr.text("ui-reading", None);
277            reading(&mut b, config);
278            // Every lane, empty or not, in the order mail is sorted, each with how
279            // mail lands there and what the Porch itself decides of it; a
280            // project's lane is in its page.
281            b.group = tr.text("set-sorting", None);
282            b.note(tr.text("rule-order", None), Vec::new());
283            for lane in crate::view::lanes(config, store, tr).into_iter().filter(|l| !l.key.starts_with("project:")) {
284                b.group = lane.title.clone();
285                b.note(lane.about, lane.rules);
286                for mut setting in for_view(&format!("lane:{}", lane.key), config, tr, lists, store) {
287                    if b.out.iter().any(|s| s.key == setting.key) {
288                        continue;
289                    }
290                    setting.group = lane.title.clone();
291                    b.out.push(setting);
292                }
293            }
294        }
295        "mail" => {
296            b.push("mail.threads", "mail-threads", Kind::Bool, SettingValue::Bool(config.mail.threads));
297            let s = b.push("fetch_minutes", "fetch-minutes", Kind::Int, int(config.fetch_minutes, crate::config::DEFAULT_FETCH_MINUTES));
298            Builder::range(s, 1.0, 240.0, 1.0, "min");
299            // Each address's own: in Accounts, on its card.
300            b.note(tr.text("set-accounts-elsewhere", None), Vec::new());
301            // The mail filters: one list for every address, their own block.
302            b.group = tr.text("set-filters-group", None);
303            b.push("mail.filter", "filters", Kind::Filters, SettingValue::Text(String::new()));
304            // Sioul's own spam filter: what it does with each verdict (the
305            // matrix), how sure it must be.
306            b.group = tr.text("set-spam-group", None);
307            let actions = config.spam.actions();
308            let cells = crate::spam::Class::ALL.iter().map(|c| format!("{}:{}", c.as_str(), actions.of(*c).as_str())).collect();
309            let s = b.push("spam.actions", "spam-actions", Kind::Radios, SettingValue::Texts(cells));
310            s.rows = crate::spam::Class::ALL.iter().map(|c| Choice { value: SettingValue::Text(c.as_str().into()), label: tr.text(&format!("set-spam-class-{}", c.as_str()), None) }).collect();
311            s.choices = crate::spam::Action::ALL.iter().map(|a| Choice { value: SettingValue::Text(a.as_str().into()), label: tr.text(&format!("set-spam-action-{}", a.as_str()), None) }).collect();
312            let (spam, unsure) = config.spam.thresholds();
313            let hundredths = |t: f32| (f64::from(t) * 100.0).round() / 100.0;
314            // Shown as percentages ("%"); each stops a point short of the other: unsure stays below spam.
315            let s = b.push("spam.threshold_spam", "spam-threshold", Kind::Float, SettingValue::Float(hundredths(spam)));
316            Builder::range(s, (hundredths(unsure) + 0.01).max(0.5), 0.99, 0.01, "%");
317            let s = b.push("spam.threshold_unsure", "spam-unsure", Kind::Float, SettingValue::Float(hundredths(unsure)));
318            Builder::range(s, 0.05, (hundredths(spam) - 0.01).min(0.95), 0.01, "%");
319            // Its training on a computer, where its table comes from on a phone.
320            b.push("spam.filter", if cfg!(target_os = "android") { "spam-filter-phone" } else { "spam-filter" }, Kind::Spam, SettingValue::Text(String::new()));
321            // How a message reads: the reading panel's own ("Aa" in Notes), shown where messages are read.
322            b.group = tr.text("ui-reading", None);
323            reading(&mut b, config);
324            // The lists you left from a message, newest first: kept on this device (`unsubscribe::Record`).
325            let mut left = crate::unsubscribe::Record::load(&crate::unsubscribe::Record::default_path()).lists;
326            if !left.is_empty() {
327                left.sort_by_key(|l| std::cmp::Reverse(l.at));
328                let zone = jiff::tz::TimeZone::system();
329                let today = jiff::Timestamp::now().to_zoned(zone.clone()).date();
330                let lines = left
331                    .iter()
332                    .map(|l| {
333                        let mut args = crate::i18n::args();
334                        args.set("list", l.name.clone());
335                        args.set("date", jiff::Timestamp::from_second(l.at).map(|t| tr.day_in(t.to_zoned(zone.clone()).date(), today)).unwrap_or_default());
336                        args.set("way", tr.text(&format!("unsubscribe-way-{}", l.way), None));
337                        tr.text("unsubscribed-line", Some(&args))
338                    })
339                    .collect();
340                b.group = tr.text("unsubscribed-group", None);
341                b.note(tr.text("unsubscribed-note", None), lines);
342            }
343        }
344        // Who is on which list, under Settings ▸ What reaches you ▸ By person
345        // (its grids say when each list reaches you): the four lists; the
346        // people placed on them; your contacts' categories, each on a list or none.
347        "senders" => {
348            for (key, id, path) in [
349                ("safe", "safe", config.safe_senders_path()),
350                ("neutral", "neutral", config.neutral_senders_path()),
351                ("restricted", "restricted", config.restricted_senders_path()),
352                ("blocked", "blocked-all", config.blocked_senders_path()),
353            ] {
354                // Addresses and patterns, then numbers as written.
355                let list = SenderList::load(&path);
356                b.push(key, id, Kind::Senders, SettingValue::Texts(list.entries().into_iter().chain(list.numbers().iter().cloned()).collect()));
357            }
358            let senders = crate::porch::Senders::load(config);
359            let choices = |none: &str| -> Vec<Choice> {
360                std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text(none, None) })
361                    .chain(crate::porch::Standing::ALL.iter().map(|l| Choice { value: SettingValue::Text(l.as_str().into()), label: tr.text(&format!("sender-one-{}", l.as_str()), None) }))
362                    .collect()
363            };
364            // The people placed on a list themselves (their card): changed here or on their card.
365            let mut cards: Vec<String> = Vec::new();
366            for list in [&senders.safe, &senders.neutral, &senders.restricted, &senders.blocked] {
367                for uid in list.cards() {
368                    if !cards.contains(uid) {
369                        cards.push(uid.clone());
370                    }
371                }
372            }
373            if !cards.is_empty() {
374                b.group = tr.text("set-sender-people-group", None);
375                b.note(tr.text("set-sender-people-note", None), Vec::new());
376                let mut named: Vec<(String, String)> = cards
377                    .into_iter()
378                    .map(|uid| {
379                        let name = senders.contacts.find(&uid).and_then(|i| senders.contacts.card(i)).map(|c| c.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| tr.text("sender-card-elsewhere", None));
380                        (name, uid)
381                    })
382                    .collect();
383                named.sort_by_key(|(name, _)| crate::text::fold(name).into_iter().collect::<String>().to_lowercase());
384                for (name, uid) in named {
385                    let standing = senders.card_standing(&uid).map_or("", |s| s.as_str());
386                    let s = b.push(&format!("{}{uid}", crate::porch::CONTACT), "sender-person", Kind::Choice, SettingValue::Text(standing.into()));
387                    s.label = name;
388                    s.help = String::new();
389                    s.choices = choices("sender-no-choice");
390                }
391            }
392            // Your contacts' categories, those in use and those on a list.
393            let mut names = senders.contacts.names();
394            for list in [&senders.safe, &senders.neutral, &senders.restricted, &senders.blocked] {
395                for name in list.categories() {
396                    if !names.iter().any(|n| crate::porch::category_key(n) == crate::porch::category_key(name)) {
397                        names.push(name.clone());
398                    }
399                }
400            }
401            if !names.is_empty() {
402                b.group = tr.text("set-sender-categories-group", None);
403                b.note(tr.text("set-sender-categories-note", None), Vec::new());
404                let choices = choices("sender-no-list");
405                for name in names {
406                    let standing = senders.category_standing(&name).map_or("", |s| s.as_str());
407                    let s = b.push(&format!("{}{name}", crate::porch::CATEGORY), "sender-category", Kind::Choice, SettingValue::Text(standing.into()));
408                    s.label = name;
409                    s.help = String::new();
410                    s.choices = choices.clone();
411                }
412            }
413        }
414        // What all addresses share, under them in Accounts: the key for the AI that reads hostile mail.
415        "accounts" => {
416            b.group = tr.text("set-ai-group", None);
417            b.ai_key(config);
418        }
419        "agenda" => {
420            b.collections(config, "calendars", crate::vdir::Kind::Calendars, "VEVENT");
421            // Where a new event goes, chosen here (until then the first that takes events).
422            let writable: Vec<Choice> = crate::vdir::collections(crate::vdir::Kind::Calendars)
423                .into_iter()
424                .filter(|c| !c.read_only && c.holds("VEVENT"))
425                .map(|c| Choice { value: SettingValue::Text(format!("{}/{}", c.account, c.id)), label: c.label(config, tr) })
426                .collect();
427            let s = b.push("agenda.calendar", "agenda-calendar", Kind::Choice, SettingValue::Text(config.agenda.calendar.clone().unwrap_or_default()));
428            s.choices = std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text("set-agenda-calendar-first", None) }).chain(writable).collect();
429            let s = b.push("agenda.day_start", "day-start", Kind::Int, int(config.agenda.day_start, 7));
430            Builder::range(s, 0.0, 12.0, 1.0, "h");
431            // The only time ever kept between two events besides each event's own Before and After (22 October 2026).
432            let s = b.push("agenda.between", "agenda-between", Kind::Int, int(config.agenda.between, 0));
433            Builder::range(s, 0.0, 120.0, 5.0, "min");
434        }
435        "tasks" => {
436            let office = config.office_hours().iter().map(|w| WindowValue { day: w.day.clone(), start: w.start.clone(), end: w.end_text(), minutes: 0 }).collect();
437            b.push("office_hours", "office-hours", Kind::Windows, SettingValue::Windows(office));
438            let kinds = config.task_kinds(tr).into_iter().map(|(id, label)| crate::config::NamedValue { id, label, ..Default::default() }).collect();
439            b.push("tasks.kind", "task-kinds", Kind::Kinds, SettingValue::Named(kinds));
440            b.collections(config, "task-lists", crate::vdir::Kind::Calendars, "VTODO");
441            let s = b.push("tasks.estimate", "task-estimate", Kind::Int, int(config.tasks.estimate, crate::plan::Settings::default().default_estimate));
442            Builder::range(s, 5.0, 240.0, 5.0, "min");
443            let s = b.push("tasks.list", "task-list", Kind::Choice, SettingValue::Text(config.tasks.list.clone().unwrap_or_default()));
444            s.choices = std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text("set-task-list-first", None) })
445                .chain(lists.iter().map(|(id, name)| Choice { value: SettingValue::Text(id.clone()), label: name.clone() }))
446                .collect();
447            // Where a task pinned to a time goes, as an event (docs/tasks.md, "Pinned to a time").
448            let calendars: Vec<Choice> = crate::vdir::collections(crate::vdir::Kind::Calendars)
449                .into_iter()
450                .filter(|c| !c.read_only && c.holds("VEVENT"))
451                .map(|c| Choice { value: SettingValue::Text(format!("{}/{}", c.account, c.id)), label: c.label(config, tr) })
452                .collect();
453            let s = b.push("tasks.blocks", "task-blocks", Kind::Choice, SettingValue::Text(config.tasks.blocks.clone().unwrap_or_default()));
454            s.choices = std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text("set-task-blocks-own", None) }).chain(calendars).collect();
455            b.push("tasks.block_alarms", "task-block-alarms", Kind::Bool, SettingValue::Bool(config.tasks.block_alarms));
456            // What a day holds, as the plan learns it from your days (docs/capacity.md).
457            b.group = tr.text("set-planning-group", None);
458            let s = b.push("planning.start", "planning-start", Kind::Choice, SettingValue::Text(config.planning.start.clone().unwrap_or_default()));
459            s.choices = [("", "set-planning-start-as-now"), ("lighter", "set-planning-start-lighter"), ("much-lighter", "set-planning-start-much-lighter")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
460            let s = b.push("planning.window_days", "planning-window", Kind::Int, SettingValue::Int(i64::from(config.planning.window())));
461            Builder::range(s, f64::from(crate::capacity::WINDOW_LEAST), f64::from(crate::capacity::WINDOW_MOST), 1.0, "");
462            s.unit = tr.text("set-planning-window-unit", None);
463            b.push("planning.even_days", "planning-even", Kind::Bool, SettingValue::Bool(config.planning.even_days));
464            b.push("planning.gain_slots", "planning-gain-slots", Kind::Bool, SettingValue::Bool(config.planning.gain_slots()));
465            // What a task is for, by its categories (docs/areas.md): the hours it comes in.
466            b.group = tr.text("set-task-areas-group", None);
467            // Your lists, else those of the languages in use (`words.tasks`).
468            let tasks = &crate::words::Words::of(config).tasks;
469            b.push("quiet.work", "quiet-work", Kind::Words, SettingValue::Texts(tasks.work.clone()));
470            b.push("quiet.personal", "quiet-personal", Kind::Words, SettingValue::Texts(tasks.personal.clone()));
471            // GitHub, last and off: its issues and reviews as tasks, once asked.
472            b.group = tr.text("set-code-group", None);
473            b.push("github.enabled", "github", Kind::Bool, SettingValue::Bool(config.github.enabled));
474            if config.github.enabled {
475                b.push("github_token", "github-token", Kind::Secret, SettingValue::Bool(false));
476                b.push("github.assigned", "github-assigned", Kind::Bool, SettingValue::Bool(config.github.assigned));
477                b.push("github.reviews", "github-reviews", Kind::Bool, SettingValue::Bool(config.github.reviews));
478                b.push("github.created", "github-created", Kind::Bool, SettingValue::Bool(config.github.created));
479                b.push("github.mentioned", "github-mentioned", Kind::Bool, SettingValue::Bool(config.github.mentioned));
480            }
481        }
482        "notes" => {
483            b.push("notes_folder", "notes-folder", Kind::Text, SettingValue::Text(config.notes_folder.clone().unwrap_or_else(|| "notes".into())));
484        }
485        // How long text reads, behind "Aa" where it is read: a message, a note.
486        "reading" => reading(&mut b, config),
487        "sites" => {
488            b.push("bitwarden.email", "bitwarden-email", Kind::Text, SettingValue::Text(config.bitwarden.email.clone().unwrap_or_default()));
489            b.push("bitwarden.server", "bitwarden-server", Kind::Text, SettingValue::Text(config.bitwarden.server.clone().unwrap_or_default()));
490        }
491        "contacts" => {
492            b.collections(config, "address-books", crate::vdir::Kind::Contacts, "");
493            b.push("map.geocode", "map-geocode", Kind::Bool, SettingValue::Bool(config.map.geocode));
494            b.push("map.tiles", "map-tiles", Kind::Text, SettingValue::Text(config.map.tiles.clone().unwrap_or_default()));
495            // The country of numbers written without one: "04 65…" found as "+33 4 65…" (phones.rs).
496            let s = b.push("contacts.region", "phone-region", Kind::Choice, SettingValue::Text(config.contacts.region.clone().unwrap_or_default()));
497            s.choices = phone_regions(tr);
498        }
499        // Sioul as a whole: what belongs to no single page.
500        "parameters" | "general" => {
501            b.section = "look".into();
502            b.group = tr.text("set-look-group", None);
503            let s = b.push("language", "language", Kind::Choice, SettingValue::Text(config.language.clone().unwrap_or_default()));
504            s.choices = [("", "set-language-system"), ("fr", "set-language-fr"), ("en", "set-language-en")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
505            let s = b.push("theme", "theme", Kind::Choice, SettingValue::Text(config.theme.clone().unwrap_or_default()));
506            s.choices = [("", "set-theme-system"), ("light", "set-theme-light"), ("dark", "set-theme-dark")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
507            // The places' names beside their icons, for whoever reads words more easily (main.qml).
508            b.push("places_named", "places-named", Kind::Bool, SettingValue::Bool(config.places_named));
509            // Passwords shown as they are typed, for whoever needs to see them (the window keeps it, per computer).
510            b.push("passwords_shown", "passwords-shown", Kind::Bool, SettingValue::Bool(false));
511            // colour: the sites in the screen's own colours, and calmer colours (docs/colour.md);
512            // the window says which profile, and leaves both out on a phone (backend.rs).
513            b.push("screen_colours", "screen-colours", Kind::Bool, SettingValue::Bool(config.screen_colours.unwrap_or(true)));
514            let s = b.push("calmer_colours", "calmer-colours", Kind::Choice, SettingValue::Text(config.calmer_colours.clone().unwrap_or_default()));
515            s.choices = [("", "set-calmer-colours-off"), ("little", "set-calmer-colours-little"), ("more", "set-calmer-colours-more")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
516            // Your folder: notes, and beside them projects, budgets, letters. How far back
517            // mail and calendars go is the accounts', at the top of Accounts.
518            b.section = "files".into();
519            b.group = tr.text("set-files-group", None);
520            b.push("case_store", "notes-root", Kind::Folder, SettingValue::Text(config.notes_root.clone().unwrap_or_default()));
521            // The projects' file under its first name: renamed only when you say
522            // so (`projects::rename_file`); with both names there, which one is read.
523            if let Some(root) = config.notes_root_path() {
524                if crate::projects::only_old_in(&root) {
525                    b.push("projects.rename", "projects-rename", Kind::Action, SettingValue::Bool(false));
526                } else if crate::projects::both_in(&root) {
527                    b.note(tr.text("set-projects-both", None), Vec::new());
528                }
529            }
530            b.section = "hours".into();
531            b.group = tr.text("set-hours-group", None);
532            let week = |windows: &[crate::window::AdminWindow]| SettingValue::Windows(windows.iter().map(|w| WindowValue { day: w.day.clone(), start: w.start.clone(), end: w.end_text(), minutes: 0 }).collect());
533            // Working hours and hours for your own admin, each its own week; leisure
534            // is every other time, meals and sleep come from Health (docs/areas.md).
535            let of = |kind: &str| config.windows.iter().filter(|w| w.kind() == kind).cloned().collect::<Vec<_>>();
536            b.push("window", "windows", Kind::Windows, week(&of("work")));
537            b.push("window.admin", "windows-admin", Kind::Windows, week(&of("admin")));
538            b.push("link.needs", "hours-leisure", Kind::Link, SettingValue::Text("needs".into()));
539            let off = config.time_off.iter().map(|t| crate::config::TimeOffValue { id: t.id.clone(), from: t.from.to_string(), until: t.until.to_string(), label: t.label.clone() }).collect();
540            b.push("time_off", "time-off", Kind::TimeOff, SettingValue::TimeOff(off));
541            // What reaches you, and when (docs/attention.md): the matrix is the tab's own
542            // (`attention::grid`, the window's `reachesView`); these are the switches beside it.
543            b.section = "attention".into();
544            b.group = String::new();
545            // New mail told at the times its row says, once per batch (docs/porch.md, "Notifications").
546            b.push("reminders.mail", "reminders-mail", Kind::Bool, SettingValue::Bool(config.reminders.mail));
547            b.push("reminders.mail_newsletters", "reminders-mail-newsletters", Kind::Bool, SettingValue::Bool(config.reminders.mail_newsletters));
548            // What sites notified, gathered at the gathered times; the Sites row's switch.
549            b.push("reminders.gather", "reminders-gather", Kind::Bool, SettingValue::Bool(config.reminders.gather));
550            // Free time's "Nothing at all": its card's switch.
551            b.push("free_time.nothing", "free-nothing", Kind::Bool, SettingValue::Bool(config.free_time.nothing));
552            // What turns do-not-disturb on, on every device (docs/do-not-disturb.md).
553            b.push("dnd.button", "dnd-button", Kind::Bool, SettingValue::Bool(config.dnd.button));
554            b.push("dnd.focus", "dnd-focus", Kind::Bool, SettingValue::Bool(config.dnd.focus));
555            b.push("dnd.pauses", "dnd-pauses", Kind::Bool, SettingValue::Bool(config.dnd.pauses));
556            b.push("dnd.sleep", "dnd-sleep", Kind::Bool, SettingValue::Bool(config.dnd.sleep));
557            // The words Sioul looks for (docs/words.md): a tab of their own (`WordsTab.qml`).
558            b.section = "words".into();
559            words_rows(&mut b, config);
560            // Reminders before dates; with the window closed, the window says (an entry started with the session).
561            b.section = "reminders".into();
562            b.group = tr.text("set-reminders-group", None);
563            // An event's reminder before it, counted before its margin; each event may say its own (docs/reminders.md).
564            let lead = config.reminders.before_event;
565            let mut leads = crate::reminders::LEADS.to_vec();
566            if !leads.contains(&lead) {
567                leads.push(lead);
568                leads.sort_unstable();
569            }
570            let s = b.push("reminders.before_event", "reminders-before", Kind::Choice, SettingValue::Int(i64::from(lead)));
571            s.choices = leads.iter().map(|m| Choice { value: SettingValue::Int(i64::from(*m)), label: if *m == 0 { tr.text("set-reminders-before-none", None) } else { crate::reminders::lead_text(tr, *m) } }).collect();
572            b.push("reminders.events", "reminders-events", Kind::Bool, SettingValue::Bool(config.reminders.events));
573            let s = b.push("reminders.asked_days", "reminders-asked", Kind::Int, SettingValue::Int(i64::from(config.reminders.asked_days)));
574            Builder::range(s, 0.0, 10.0, 1.0, "");
575            b.push("reminders.waits", "reminders-waits", Kind::Bool, SettingValue::Bool(config.reminders.waits));
576            let s = b.push("reminders.payment_days", "reminders-payment", Kind::Int, SettingValue::Int(i64::from(config.reminders.payment_days)));
577            Builder::range(s, 0.0, 10.0, 1.0, "");
578            b.push("reminders_closed", "reminders-closed", Kind::Bool, SettingValue::Bool(false));
579            // The times sites and other apps' automatons are gathered at: their cells say "gathered".
580            b.push("reminders.gathered", "reminders-gathered", Kind::Words, SettingValue::Texts(config.reminders.gathered_times()));
581            // The two pauses, set up on a calm day (docs/pauses.md): free time, then the pause.
582            b.section = "pauses".into();
583            b.group = tr.text("set-free-time-group", None);
584            b.push("free_time.moves", "free-moves", Kind::Bool, SettingValue::Bool(config.free_time.moves));
585            let s = b.push("free_time.latest_after", "free-latest", Kind::Int, SettingValue::Int(i64::from(config.free_time.latest_after)));
586            Builder::range(s, 30.0, 360.0, 15.0, "min");
587            b.push("free_time.movement", "free-movement", Kind::Bool, SettingValue::Bool(config.free_time.movement(&config.planning)));
588            b.group = tr.text("set-pause-group", None);
589            // Said once, where the pause is set up (P5).
590            b.note(tr.text("set-pause-about", None), Vec::new());
591            // What comes during a pause, doses included: What reaches you ▸ By time, the pause's card.
592            b.push("link.attention.pause", "pause-attention", Kind::Link, SettingValue::Text("settings:attention.pause".into()));
593            b.push("pause.helps", "pause-helps", Kind::Words, SettingValue::Texts(config.pause.helps.clone()));
594            b.push("pause.grounding", "pause-grounding", Kind::Text, SettingValue::Text(config.pause.grounding.clone()));
595            b.push("pause.breathing", "pause-breathing", Kind::Bool, SettingValue::Bool(config.pause.breathing));
596            let s = b.push("pause.pace", "pause-pace", Kind::Int, SettingValue::Int(i64::from(config.pause.pace())));
597            Builder::range(s, 3.0, 10.0, 1.0, "");
598            let s = b.push("pause.after", "pause-after", Kind::Choice, SettingValue::Text(config.pause.after().id().to_string()));
599            s.choices = [("lighter", "set-pause-after-lighter"), ("rest", "set-pause-after-rest"), ("as-is", "set-pause-after-as-is")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
600            // The emergency number and the crisis line of a country (P13): the phone numbers' country unless chosen.
601            let s = b.push("pause.country", "pause-country", Kind::Choice, SettingValue::Text(config.pause.country.clone().unwrap_or_default()));
602            let mut countries: Vec<Choice> = crate::pause::CrisisLines::built_in()
603                .country
604                .iter()
605                .map(|c| Choice { value: SettingValue::Text(c.code.clone()), label: tr.text(&format!("set-pause-country-{}", c.code.to_ascii_lowercase()), None) })
606                .collect();
607            countries.sort_by_cached_key(|c| crate::text::fold(&c.label).into_iter().collect::<String>());
608            s.choices = std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text("set-pause-country-usual", None) }).chain(countries).collect();
609            // Invoices, made from Time and from Projects: who sends them, how they are numbered, where they go.
610            b.section = "invoices".into();
611            b.group = tr.text("set-invoice-group", None);
612            let invoice = &config.invoice;
613            for (field, kind, value) in [
614                ("name", Kind::Text, invoice.name.clone()),
615                ("address", Kind::Long, invoice.address.clone()),
616                ("siret", Kind::Text, invoice.siret.clone()),
617                ("vat", Kind::Text, invoice.vat.clone()),
618                ("prefix", Kind::Text, invoice.prefix.clone()),
619                ("currency", Kind::Text, if invoice.currency.is_empty() { "EUR".into() } else { invoice.currency.clone() }),
620                ("payment", Kind::Long, invoice.payment.clone()),
621            ] {
622                b.push(&format!("invoice.{field}"), &format!("invoice-{field}"), kind, SettingValue::Text(value));
623            }
624            b.push("invoice.folder", "invoice-folder", Kind::Folder, SettingValue::Text(invoice.folder.clone()));
625            let s = b.push("invoice.rate", "invoice-rate", Kind::Float, SettingValue::Float(invoice.rate));
626            Builder::range(s, 0.0, 2000.0, 5.0, "/h");
627            // AI agents connected with `sioul mcp` (docs/ai.md, docs/mcp.md): what they may read and
628            // write. Things in no project, then each project, closed until opened (`consent`).
629            b.section = "ai".into();
630            b.group = tr.text("set-ai-agents-group", None);
631            b.note(tr.text("set-ai-agents-note", None), Vec::new());
632            b.push("mcp.outside_projects", "mcp-outside-projects", Kind::Bool, SettingValue::Bool(config.mcp.outside_projects));
633            b.push("mcp.texts", "mcp-texts", Kind::Bool, SettingValue::Bool(config.mcp.texts));
634            b.push("mcp.spam", "mcp-spam", Kind::Bool, SettingValue::Bool(config.mcp.spam));
635            // Shown when there is one: the mail of work and school Microsoft accounts, closed unless opened.
636            if config.every_account().any(|a| a.is_microsoft_work()) {
637                b.push("mcp.work_accounts", "mcp-work-accounts", Kind::Bool, SettingValue::Bool(config.mcp.work_accounts));
638            }
639            b.group = tr.text("set-ai-projects-group", None);
640            let projects = config.notes_root_path().and_then(|root| crate::projects::ProjectStore::load(&root).ok()).map(|store| store.projects).unwrap_or_default();
641            b.note(tr.text(if projects.is_empty() { "set-ai-projects-none" } else { "set-ai-projects-note" }, None), Vec::new());
642            for project in projects {
643                b.said(&format!("project.{}.ai", project.id), Kind::Bool, project.title.clone(), String::new(), SettingValue::Bool(project.ai));
644            }
645        }
646        _ => {}
647    }
648    b.out
649}
650
651/// The words tab's lines (docs/words.md, Settings ▸ Words, `WordsTab.qml`):
652/// each in a family of lines (`words-family-<family>`), named by its id
653/// (`words-line-<id>`, and `words-line-<id>-help`: what its words do), with
654/// the lists it shows (`words-list-<list>`, dots and underscores as dashes).
655/// Only the words that say what a thing is: the grammar around them (months,
656/// "your … code", stop words) stays in the packs.
657pub const WORD_LINES: &[(&str, &str, &[&str])] = &[
658    ("mail", "codes", &["codes.code", "codes.password", "codes.reset", "codes.link", "codes.confirm", "codes.not_yours", "codes.promo"]),
659    ("mail", "senders", &["senders.automatic"]),
660    ("mail", "replies", &["replies.reply", "replies.forward", "quotes.openings", "quotes.wrote"]),
661    ("mail", "folders", &["folders.sent", "folders.drafts", "folders.junk", "folders.trash", "folders.archive"]),
662    ("mail", "brands", &["brands.brands", "brands.everyday", "brands.shared"]),
663    ("money", "payments", &["payments.bill", "payments.paid", "payments.received", "payments.order", "payments.refund", "payments.not_payments"]),
664    ("money", "bank", &["bank.filler", "accounts.between"]),
665    (
666        "money",
667        "letters",
668        &[
669            "letters.senders",
670            "letters.kinds.formal_notice",
671            "letters.kinds.tax_notice",
672            "letters.kinds.decision",
673            "letters.kinds.reminder",
674            "letters.kinds.appointment",
675            "letters.kinds.bill",
676            "letters.kinds.acknowledgment",
677            "letters.kinds.attestation",
678            "letters.kinds.contract",
679        ],
680    ),
681    (
682        "money",
683        "papers",
684        &[
685            "papers.kinds.passport",
686            "papers.kinds.identity",
687            "papers.kinds.residence",
688            "papers.kinds.driving",
689            "papers.kinds.health_cover",
690            "papers.kinds.health_card",
691            "papers.kinds.tax_notice",
692            "papers.kinds.rent_receipt",
693            "papers.kinds.bank_details",
694            "papers.kinds.payslip",
695            "papers.kinds.warranty",
696            "papers.kinds.insurance",
697            "papers.kinds.certificate",
698        ],
699    ),
700    (
701        "money",
702        "contracts",
703        &[
704            "contracts.kinds.rent",
705            "contracts.kinds.energy",
706            "contracts.kinds.health",
707            "contracts.kinds.insurance",
708            "contracts.kinds.telecom",
709            "contracts.kinds.hosting",
710            "contracts.kinds.subscription",
711            "contracts.kinds.bank",
712        ],
713    ),
714    ("phone", "approvals", &["approvals.phrases", "approvals.asks", "approvals.asked_about", "approvals.channels"]),
715    ("phone", "calls", &["calls.ringing", "calls.missed"]),
716    ("phone", "voicemail", &["voicemail.operators"]),
717    (
718        "tasks",
719        "tasks",
720        &[
721            "tasks.optional",
722            "tasks.joy",
723            "tasks.movement",
724            "capture.kind_verbs.call",
725            "capture.kind_verbs.write",
726            "capture.kind_verbs.online",
727            "capture.kind_verbs.out",
728            "capture.kind_verbs.read",
729            "capture.kind_verbs.think",
730            "capture.kind_verbs.make",
731            "capture.kind_words.call",
732            "capture.kind_words.write",
733            "capture.kind_words.online",
734            "capture.kind_words.out",
735            "capture.kind_words.read",
736            "capture.kind_words.think",
737            "capture.kind_words.make",
738        ],
739    ),
740    ("tasks", "spam", &["spam.provider_tags"]),
741    ("tasks", "shield", &["shield.threats", "shield.insults", "shield.rude", "shield.topics.work", "shield.topics.support", "shield.topics.press", "shield.topics.thanks", "shield.topics.donation"]),
742];
743
744/// Whether a list is one of the words tab's.
745fn word_list(path: &str) -> bool {
746    WORD_LINES.iter().any(|(_, _, lists)| lists.contains(&path))
747}
748
749/// A named list's entries as the tab shows them, one per chip: "Name: word, word".
750fn named_chips(entries: &[(String, Vec<String>)]) -> Vec<String> {
751    entries.iter().map(|(name, words)| if words.is_empty() { name.clone() } else { format!("{name}: {}", words.join(", ")) }).collect()
752}
753
754/// The chips of a named list read back: the name, then its words after a
755/// colon, separated by commas. A body named alone ("CAF") is looked for by
756/// its name; a brand needs its domains, or every sender naming it would be
757/// set aside.
758fn named_entries(path: &str, chips: &[String]) -> Result<Vec<(String, Vec<String>)>, String> {
759    let mut out = Vec::new();
760    for chip in chips {
761        let (name, words) = match chip.split_once(':') {
762            Some((name, words)) => (name.trim().to_string(), words.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect::<Vec<_>>()),
763            None => (chip.trim().to_string(), Vec::new()),
764        };
765        if name.is_empty() {
766            continue;
767        }
768        let words = match (words.is_empty(), path) {
769            (true, "brands.brands") => return Err(format!("{name}: write its domains after a colon (\"{name}: example.org\")")),
770            (true, _) => vec![crate::words::folded(&name)],
771            (false, _) => words,
772        };
773        out.push((name, words));
774    }
775    Ok(out)
776}
777
778/// A named list of a tree of lists (the packs alone, or with your changes).
779fn names_at(tree: &crate::words::Node, path: &str) -> Vec<(String, Vec<String>)> {
780    match tree.get(path) {
781        Some(crate::words::Node::Table(entries)) => entries
782            .iter()
783            .filter_map(|(name, node)| match node {
784                crate::words::Node::List(words) => Some((name.clone(), words.clone())),
785                crate::words::Node::Table(_) => None,
786            })
787            .collect(),
788        _ => Vec::new(),
789    }
790}
791
792/// Names joined as a sentence says them: "French and English", "French, English and German".
793fn and_list(tr: &Translator, names: &[String]) -> String {
794    let and = tr.text("word-and", None);
795    match names {
796        [] => String::new(),
797        [one] => one.clone(),
798        [first @ .., last] => format!("{} {and} {last}", first.join(", ")),
799    }
800}
801
802/// The words tab (docs/words.md): the languages and countries read, ticked;
803/// then each line (`WORD_LINES`) as a group of its own: a note (`words.about.<line>`,
804/// its family's name in `unit`) saying what its words do, how many there are
805/// and how many you changed, then its lists as Sioul uses them (the packs, your
806/// changes, and an older whole list that still wins: `filed_words`), each a
807/// row of words (`words.<list>`; a named list's entries as "Name: word, word"),
808/// your own words first.
809fn words_rows(b: &mut Builder, config: &Config) {
810    let tr = b.tr;
811    let words = crate::words::Words::of(config);
812    let compared = crate::words::compared(config);
813    b.group = String::new();
814    let s = b.push("words.languages", "words-languages", Kind::Picks, SettingValue::Texts(words.languages.clone()));
815    s.choices = crate::words::available_languages().iter().map(|code| Choice { value: SettingValue::Text(code.to_string()), label: tr.text(&format!("words-language-{code}"), None) }).collect();
816    let s = b.push("words.countries", "words-countries", Kind::Picks, SettingValue::Texts(words.countries.clone()));
817    s.choices = crate::words::available_countries()
818        .iter()
819        .map(|code| Choice { value: SettingValue::Text(code.to_string()), label: tr.text(&format!("country-{}", code.to_ascii_lowercase()), None) })
820        .collect();
821    let languages: Vec<String> = words.languages.iter().map(|code| tr.text(&format!("words-language-{code}-in"), None)).collect();
822    let languages = and_list(tr, &languages);
823    let same = |a: &str, b: &str| crate::words::folded(a) == crate::words::folded(b);
824    for (family, line, lists) in WORD_LINES {
825        b.group = tr.text(&format!("words-line-{line}"), None);
826        let (mut count, mut added, mut removed) = (0usize, 0usize, 0usize);
827        let mut rows: Vec<(&str, Vec<String>, bool)> = Vec::new();
828        for path in lists.iter().copied() {
829            if crate::words::MAPS.contains(&path) {
830                let now = names_at(&compared.yours, path);
831                let before = names_at(&compared.shipped, path);
832                count += now.len();
833                for (name, words) in &now {
834                    match before.iter().find(|(n, _)| same(n, name)) {
835                        None => added += 1,
836                        Some((_, shipped)) => {
837                            added += usize::from(words.iter().any(|w| !shipped.iter().any(|s| same(s, w))));
838                            removed += usize::from(shipped.iter().any(|s| !words.iter().any(|w| same(s, w))));
839                        }
840                    }
841                }
842                removed += before.iter().filter(|(n, _)| !now.iter().any(|(name, _)| same(n, name))).count();
843                // Your own names first, then the packs'.
844                let (yours, shipped): (Vec<(String, Vec<String>)>, Vec<(String, Vec<String>)>) = now.into_iter().partition(|(name, _)| !before.iter().any(|(n, _)| same(n, name)));
845                rows.push((path, named_chips(&yours.into_iter().chain(shipped).collect::<Vec<_>>()), true));
846            } else {
847                // The automatic senders' older whole list still wins while written (`words::older_settings`).
848                let now = if path == "senders.automatic" { words.senders.automatic.clone() } else { compared.yours.list(path).to_vec() };
849                let before = compared.shipped.list(path);
850                count += now.len();
851                added += now.iter().filter(|w| !before.iter().any(|s| same(s, w))).count();
852                removed += before.iter().filter(|s| !now.iter().any(|w| same(s, w))).count();
853                // Your own words first, then the packs'.
854                let (yours, shipped): (Vec<String>, Vec<String>) = now.into_iter().partition(|w| !before.iter().any(|s| same(s, w)));
855                rows.push((path, yours.into_iter().chain(shipped).collect(), false));
856            }
857        }
858        let mut args = tr.counted(count);
859        args.set("languages", languages.clone());
860        let mut said = vec![tr.text("words-line-count", Some(&args))];
861        if added > 0 {
862            said.push(tr.text("words-line-added", Some(&tr.counted(added))));
863        }
864        if removed > 0 {
865            said.push(tr.text("words-line-removed", Some(&tr.counted(removed))));
866        }
867        let about = format!("words.about.{line}");
868        let help = tr.text(&format!("words-line-{line}-help"), None);
869        let family = tr.text(&format!("words-family-{family}"), None);
870        let s = b.said(&about, Kind::Note, help, said.join(" · "), SettingValue::Text(String::new()));
871        s.unit = family;
872        for (path, now, named) in rows {
873            let label = tr.text(&format!("words-list-{}", path.replace(['.', '_'], "-")), None);
874            let help = if named { tr.text(&format!("words-named-{}", path.replace(['.', '_'], "-")), None) } else { String::new() };
875            b.said(&format!("words.{path}"), Kind::Words, label, help, SettingValue::Texts(now));
876        }
877    }
878}
879
880/// The countries numbers written without one can be read as: as the system
881/// says first (its locale's, else the language's), then each one by name.
882fn phone_regions(tr: &Translator) -> Vec<Choice> {
883    let name = |code: &str| tr.text(&format!("country-{}", code.to_ascii_lowercase()), None);
884    let usual = match crate::phones::usual_region(&tr.text("qt-locale", None)) {
885        Some(region) => {
886            let mut args = crate::i18n::args();
887            args.set("country", name(region.code));
888            tr.text("set-phone-region-usual", Some(&args))
889        }
890        None => tr.text("set-phone-region-none", None),
891    };
892    let mut countries: Vec<Choice> = crate::phones::REGIONS.iter().map(|r| Choice { value: SettingValue::Text(r.code.to_string()), label: name(r.code) }).collect();
893    countries.sort_by_cached_key(|c| crate::text::fold(&c.label).into_iter().collect::<String>());
894    std::iter::once(Choice { value: SettingValue::Text(String::new()), label: usual }).chain(countries).collect()
895}
896
897fn reading(b: &mut Builder, config: &Config) {
898    b.push("reading.family", "reading-family", Kind::Font, SettingValue::Text(config.reading.family.clone()));
899    let s = b.push("reading.size", "reading-size", Kind::Int, SettingValue::Int(i64::from(config.reading.size)));
900    Builder::range(s, 10.0, 30.0, 1.0, "px");
901    let s = b.push("reading.spacing", "reading-spacing", Kind::Float, SettingValue::Float(config.reading.spacing));
902    Builder::range(s, 1.0, 2.4, 0.1, "×");
903}
904
905/// An account's own history setting, before the global one fills it in (`Config::load`).
906fn raw_account_history(config: &Config, id: &str) -> Option<u32> {
907    let account = config.accounts.iter().find(|a| a.id == id)?;
908    account.history_weeks.filter(|w| Some(*w) != config.history_weeks)
909}
910
911/// Changes one setting: in the configuration file, or in a senders' list.
912/// The categories tasks have, the most used first, to rename or take off; the
913/// window reads the tasks, the core does not.
914pub fn categories(tr: &Translator, names: Vec<String>) -> Setting {
915    let mut b = Builder { tr, out: Vec::new(), group: String::new(), section: String::new() };
916    b.push("task_categories", "task-categories", Kind::Categories, SettingValue::Named(names.into_iter().map(|n| crate::config::NamedValue { id: n.clone(), label: n, ..Default::default() }).collect()));
917    b.out.remove(0)
918}
919
920/// A list's change, from what an editor showed (`shown`) to what it sends
921/// (`value`), set over the list as the file holds it now (`current`): what
922/// it took out taken out, what it added added, the rest as `current` holds
923/// it; the editor's order for what it holds, then what it never showed.
924/// Nothing changed meanwhile: `value` itself.
925pub fn rebased<T: PartialEq + Clone>(shown: &[T], value: &[T], current: &[T]) -> Vec<T> {
926    if shown == current {
927        return value.to_vec();
928    }
929    let added: Vec<&T> = value.iter().filter(|v| !shown.contains(v)).collect();
930    let removed: Vec<&T> = shown.iter().filter(|s| !value.contains(s)).collect();
931    let mut out: Vec<T> = value.iter().filter(|v| added.contains(v) || current.contains(v)).cloned().collect();
932    for there in current {
933        if !out.contains(there) && !removed.contains(&there) {
934            out.push(there.clone());
935        }
936    }
937    out
938}
939
940/// `rebased`, for a list of structured elements (days off, hours, routes):
941/// an element the editor changed is its new version in place of the one the
942/// list holds now. Found by its id (`ids`, `real`: a day off's), whatever it
943/// holds now: two devices changing it keep one, the later change. By a key
944/// (`ids`, not `real`: a day's window by its rank, which a window added
945/// elsewhere shifts) only while the element there is the one shown, else by
946/// what it held wherever it is now; not there, nothing is replaced and the
947/// other device's elements stay. Without an id (a route): at its place while
948/// it holds what was shown, else by what it held wherever it is, else at its
949/// place while the list holds as many as shown (the same element, changed
950/// elsewhere: the later change).
951pub fn rebased_by<T: PartialEq + Clone>(shown: &[T], value: &[T], current: &[T], ids: impl Fn(&[T]) -> Vec<Option<String>>, real: bool) -> Vec<T> {
952    if shown == current {
953        return value.to_vec();
954    }
955    let (shown_ids, value_ids, current_ids) = (ids(shown), ids(value), ids(current));
956    let mut current = current.to_vec();
957    for (at_shown, old) in shown.iter().enumerate() {
958        if value.contains(old) {
959            continue;
960        }
961        let identity = shown_ids.get(at_shown).cloned().flatten();
962        // Its new version among what the editor sends: by its identity, else at its place.
963        let edit = match &identity {
964            Some(key) => value.iter().zip(&value_ids).find(|(v, k)| !shown.contains(v) && k.as_ref() == Some(key)).map(|(v, _)| v),
965            None => value.get(at_shown).filter(|v| shown.len() == value.len() && !shown.contains(v) && value_ids.get(at_shown).is_some_and(Option::is_none)),
966        };
967        let Some(edit) = edit else { continue };
968        // Where it is now.
969        let by_value = || current.iter().position(|c| c == old);
970        let at = match &identity {
971            Some(key) if real => current_ids.iter().position(|k| k.as_ref() == Some(key)),
972            Some(key) => current_ids.iter().position(|k| k.as_ref() == Some(key)).filter(|at| current.get(*at) == Some(old)).or_else(by_value),
973            None => (current.get(at_shown) == Some(old)).then_some(at_shown).or_else(by_value).or_else(|| (current.len() == shown.len()).then_some(at_shown)),
974        };
975        if let Some(at) = at {
976            current[at] = edit.clone();
977        }
978    }
979    rebased(shown, value, &current)
980}
981
982/// A day's windows named by their day and their rank in it ("mon#0"): a
983/// day's hours changed are that window changed.
984fn window_ids(windows: &[crate::config::WindowValue]) -> Vec<Option<String>> {
985    let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
986    windows
987        .iter()
988        .map(|w| {
989            let rank = seen.entry(w.day.clone()).or_default();
990            *rank += 1;
991            Some(format!("{}#{}", w.day, *rank - 1))
992        })
993        .collect()
994}
995
996/// The views whose rows `current_value` looks through for a key.
997const VIEWS: &[&str] = &["parameters", "porch", "mail", "senders", "accounts", "agenda", "tasks", "notes", "reading", "sites", "contacts", "lane:people"];
998
999/// A setting changed from the window: `value` as the window sends it, from
1000/// `shown`, the value its row showed. A list (words, days off, hours, senders,
1001/// routes…) is the window's change applied to the list as the files hold it
1002/// now (`rebase`): what the window took out taken out, what it added added,
1003/// the rest as it is, so that an element another device's change brought in
1004/// meanwhile is never taken out. Read, rebased and written under the lock of
1005/// the file the setting is kept in, which the sharing takes too. Without
1006/// `shown` (a caller that just read the files), `value` as it is.
1007pub fn change(config_path: &Path, key: &str, shown: Option<&SettingValue>, value: &SettingValue) -> Result<(), String> {
1008    // A row of attention: only the cells the window changed (`attention::apply_change`).
1009    if key.starts_with("attention.") || key.starts_with("notify.") {
1010        return crate::attention::apply_change(config_path, key, shown, value);
1011    }
1012    let config = || if config_path.exists() { Config::load(config_path) } else { Ok(Config::default()) };
1013    let kept_in = match key {
1014        "known" | "safe" | "neutral" | "restricted" | "blocked" => {
1015            let config = config()?;
1016            match key {
1017                "known" => config.known_senders_path(),
1018                "safe" => config.safe_senders_path(),
1019                "neutral" => config.neutral_senders_path(),
1020                "restricted" => config.restricted_senders_path(),
1021                _ => config.blocked_senders_path(),
1022            }
1023        }
1024        _ if key.starts_with("project.") => config()?.notes_root_path().map_or_else(|| config_path.to_path_buf(), |root| crate::projects::file_in(&root)),
1025        _ => config_path.to_path_buf(),
1026    };
1027    crate::filelock::with_lock(&kept_in, || {
1028        let fresh = config()?;
1029        let value = match shown {
1030            Some(shown) => current_value(&fresh, key).map_or_else(|| value.clone(), |current| rebase(shown, value, &current)),
1031            None => value.clone(),
1032        };
1033        apply(config_path, &fresh, key, &value)
1034    })
1035}
1036
1037/// A setting's value as the files hold it now: its row's, in whichever view shows it.
1038fn current_value(config: &Config, key: &str) -> Option<SettingValue> {
1039    let tr = crate::i18n::Translator::new("en");
1040    let store = config.notes_root_path().and_then(|root| crate::projects::ProjectStore::load(&root).ok());
1041    let own = key.strip_prefix("project.").and_then(|k| k.strip_suffix(".routes")).map(|id| format!("project:{id}")).or_else(|| key.strip_prefix("account.").and_then(|k| k.split('.').next()).map(|id| format!("account:{id}")));
1042    let views: Vec<String> = own.into_iter().chain(VIEWS.iter().map(|v| (*v).to_string())).collect();
1043    views.iter().find_map(|view| for_view(view, config, &tr, &[], store.as_ref()).into_iter().find(|row| row.key == key).map(|row| row.value))
1044}
1045
1046/// The window's change of a list, from what it showed (`shown`) to what it
1047/// sends (`value`), set over the list as it is now (`current`): what it took
1048/// out taken out, what it added added, the rest as `current` holds it; the
1049/// window's order for what it holds. Nothing changed meanwhile: `value`
1050/// itself. A value that is no list: `value`.
1051pub fn rebase(shown: &SettingValue, value: &SettingValue, current: &SettingValue) -> SettingValue {
1052    use SettingValue as V;
1053    // The window's JSON `[]` reads as numbers: an empty list of the kind the other values are.
1054    let like = |v: &SettingValue, other: &SettingValue| match (v, other) {
1055        (V::Ints(none), V::Texts(_)) if none.is_empty() => V::Texts(Vec::new()),
1056        (V::Ints(none), V::Windows(_)) if none.is_empty() => V::Windows(Vec::new()),
1057        (V::Ints(none), V::TimeOff(_)) if none.is_empty() => V::TimeOff(Vec::new()),
1058        (V::Ints(none), V::Routes(_)) if none.is_empty() => V::Routes(Vec::new()),
1059        _ => v.clone(),
1060    };
1061    let (shown, value) = (like(shown, current), like(value, current));
1062    match (&shown, &value, current) {
1063        (V::Texts(s), V::Texts(v), V::Texts(c)) => V::Texts(rebased(s, v, c)),
1064        (V::Ints(s), V::Ints(v), V::Ints(c)) => V::Ints(rebased(s, v, c)),
1065        (V::Windows(s), V::Windows(v), V::Windows(c)) => V::Windows(rebased_by(s, v, c, window_ids, false)),
1066        (V::TimeOff(s), V::TimeOff(v), V::TimeOff(c)) => V::TimeOff(rebased_by(s, v, c, |days| days.iter().map(|d| Some(d.id.clone()).filter(|id| !id.is_empty())).collect(), true)),
1067        (V::Routes(s), V::Routes(v), V::Routes(c)) => V::Routes(rebased_by(s, v, c, |routes| vec![None; routes.len()], false)),
1068        _ => value,
1069    }
1070}
1071
1072pub fn apply(config_path: &Path, config: &Config, key: &str, value: &SettingValue) -> Result<(), String> {
1073    // The window's JSON `[]` reads as the first kind of list it fits, numbers:
1074    // the last entry of a list taken away is an empty list of any kind.
1075    let none = matches!(value, SettingValue::Ints(list) if list.is_empty());
1076    match key {
1077        // The words Sioul looks for (docs/words.md): the languages and countries read;
1078        // a line of the words tab back to the packs; one of its lists as you want it,
1079        // written as its difference from the packs (`words::write_list`, `write_names`).
1080        "words.languages" | "words.countries" => {
1081            let values: &[String] = match value {
1082                SettingValue::Texts(values) => values,
1083                _ if none => &[],
1084                _ => return Err(format!("{key}: a list expected")),
1085            };
1086            if key == "words.languages" && values.is_empty() {
1087                return Err(format!("{key}: Sioul reads at least one language"));
1088            }
1089            crate::words::write_setting(config_path, &key["words.".len()..], values)
1090        }
1091        _ if key.starts_with("words.reset.") => {
1092            let line = &key["words.reset.".len()..];
1093            let (_, _, lists) = WORD_LINES.iter().find(|(_, id, _)| *id == line).ok_or_else(|| format!("{key}: no such line"))?;
1094            if lists.contains(&"senders.automatic") && config.filed_words.is_some() {
1095                set_value(config_path, "filed_words", &SettingValue::Text(String::new()))?;
1096            }
1097            crate::words::reset_lists(config_path, lists)
1098        }
1099        // `filed_words`, the automatic senders' older whole list, as a window
1100        // before the words tab writes it: as the tab writes it now.
1101        _ if key == "filed_words" || key.strip_prefix("words.").is_some_and(word_list) => {
1102            let path = if key == "filed_words" { "senders.automatic" } else { &key["words.".len()..] };
1103            let wanted: &[String] = match value {
1104                SettingValue::Texts(values) => values,
1105                _ if none => &[],
1106                _ => return Err(format!("{key}: a list expected")),
1107            };
1108            // The older whole list goes: the difference says it from now on.
1109            if path == "senders.automatic" && config.filed_words.is_some() {
1110                set_value(config_path, "filed_words", &SettingValue::Text(String::new()))?;
1111            }
1112            let shipped = crate::words::compared(config).shipped;
1113            if crate::words::MAPS.contains(&path) {
1114                crate::words::write_names(config_path, path, &named_entries(path, wanted)?, &names_at(&shipped, path))
1115            } else {
1116                crate::words::write_list(config_path, path, wanted, shipped.list(path))
1117            }
1118        }
1119        // Secrets go to the keyring (the window's to do), never into the configuration.
1120        "ai_key" | "github_token" => Err(format!("{key}: kept in the system keyring, never in the configuration")),
1121        "tasks.kind" => {
1122            let SettingValue::Rename(change) = value else { return Err(format!("{key}: a change expected")) };
1123            crate::config::change_kind(config_path, &change.from, &change.to)
1124        }
1125        // "calendars/<account>/<id>" or "contacts/…": renamed, or deleted when empty.
1126        "collections" => {
1127            let SettingValue::Rename(change) = value else { return Err(format!("{key}: a change expected")) };
1128            let mut parts = change.from.splitn(3, '/');
1129            let (Some(kind), Some(account), Some(id)) = (parts.next(), parts.next(), parts.next()) else { return Err(format!("{}: unknown", change.from)) };
1130            let kind = if kind == "contacts" { crate::vdir::Kind::Contacts } else { crate::vdir::Kind::Calendars };
1131            let collection = crate::vdir::collections(kind).into_iter().find(|c| c.account == account && c.id == id).ok_or_else(|| format!("{}: unknown", change.from))?;
1132            if change.to.trim().is_empty() { crate::vdir::delete_collection(&collection) } else { crate::vdir::rename_collection(&collection, &change.to) }
1133        }
1134        // A category of your contacts on one list, or on none ("").
1135        _ if key.starts_with(crate::porch::CATEGORY) => {
1136            let name = crate::porch::category_of(key).ok_or_else(|| format!("{key}: no category"))?;
1137            let standing = match value {
1138                SettingValue::Text(text) if text.is_empty() => None,
1139                SettingValue::Text(text) => Some(crate::porch::Standing::read(text).ok_or_else(|| format!("{text}: safe, neutral, restricted or blocked"))?),
1140                _ => return Err(format!("{key}: a list expected")),
1141            };
1142            crate::porch::set_category(config, name, standing)
1143        }
1144        "known" | "safe" | "neutral" | "restricted" | "blocked" => {
1145            let path = match key {
1146                "known" => config.known_senders_path(),
1147                "safe" => config.safe_senders_path(),
1148                "neutral" => config.neutral_senders_path(),
1149                "restricted" => config.restricted_senders_path(),
1150                _ => config.blocked_senders_path(),
1151            };
1152            let wanted: &[String] = match value {
1153                SettingValue::Texts(wanted) => wanted,
1154                _ if none => &[],
1155                _ => return Err(format!("{key}: a list expected")),
1156            };
1157            // A category typed here ("category:Friends") goes on this list, out of the others.
1158            let standing = crate::porch::Standing::read(key);
1159            if let Some(standing) = standing {
1160                for name in wanted.iter().filter_map(|e| crate::porch::category_of(e)) {
1161                    crate::porch::set_category(config, name, Some(standing))?;
1162                }
1163            }
1164            // What the editor shows: addresses and patterns, and on the four
1165            // lists numbers too; cards and categories have rows of their own.
1166            use crate::porch::Entry;
1167            let region = crate::reach::region(config);
1168            let read = |e: &String| Entry::read(e, region).filter(|e| matches!(e, Entry::Address(_)) || (standing.is_some() && matches!(e, Entry::Number(_))));
1169            let wanted: Vec<Entry> = wanted.iter().filter_map(read).collect();
1170            let list = SenderList::load(&path);
1171            let current: Vec<Entry> = list.entries().iter().chain(list.numbers()).filter_map(read).collect();
1172            for gone in current.iter().filter(|e| !wanted.iter().any(|w| w.same(e))) {
1173                SenderList::remove_entry(&path, gone, region)?;
1174            }
1175            for new in wanted.iter().filter(|e| !current.iter().any(|c| c.same(e))) {
1176                // The four lists exclude each other: one list per entry.
1177                match standing {
1178                    Some(standing) => crate::porch::set_standing(config, &new.line(), standing)?,
1179                    None => SenderList::add_entry(&path, new, region)?,
1180                }
1181            }
1182            Ok(())
1183        }
1184        // A person placed on a list (their card): on another, or on none; their
1185        // addresses' and numbers' own entries go, the card deciding for them.
1186        _ if key.starts_with(crate::porch::CONTACT) => {
1187            let uid = crate::porch::card_of(key).ok_or_else(|| format!("{key}: no card"))?;
1188            let standing = match value {
1189                SettingValue::Text(text) if text.is_empty() => None,
1190                SettingValue::Text(text) => Some(crate::porch::Standing::read(text).ok_or_else(|| format!("{text}: safe, neutral, restricted or blocked"))?),
1191                _ => return Err(format!("{key}: a list expected")),
1192            };
1193            let card = crate::contacts::all().into_iter().find(|c| c.uid.trim() == uid);
1194            let addresses: Vec<String> = card.iter().flat_map(|c| c.emails.iter().map(|e| e.value.clone())).collect();
1195            let numbers: Vec<String> = card.iter().flat_map(|c| c.phones.iter().map(|p| p.value.clone())).collect();
1196            crate::porch::set_person(config, uid, &addresses, &numbers, standing)
1197        }
1198        // The projects ticked: those not ticked are written, so a new project shows.
1199        "porch.projects" => {
1200            let shown: &[String] = match value {
1201                SettingValue::Texts(shown) => shown,
1202                _ if none => &[],
1203                _ => return Err(format!("{key}: a list expected")),
1204            };
1205            let root = config.notes_root_path().ok_or_else(|| format!("{key}: no notes folder"))?;
1206            let store = crate::projects::ProjectStore::load(&root).map_err(|e| e.to_string())?;
1207            let hidden: Vec<String> = store.projects.iter().map(|c| c.id.clone()).filter(|id| !shown.contains(id)).collect();
1208            set_value(config_path, "porch.hidden_projects", &SettingValue::Texts(hidden))
1209        }
1210        // The projects' file renamed, when its button is pressed (`projects::rename_file`).
1211        "projects.rename" => {
1212            let root = config.notes_root_path().ok_or_else(|| format!("{key}: no notes folder"))?;
1213            crate::projects::rename_file(&root).map(|_| ())
1214        }
1215        // A project opened to AI agents, or closed: `ai` on its entry in the projects' file (docs/ai.md).
1216        _ if key.strip_prefix("project.").and_then(|rest| rest.strip_suffix(".ai")).is_some_and(|id| !id.is_empty()) => {
1217            let id = key.strip_prefix("project.").and_then(|rest| rest.strip_suffix(".ai")).unwrap_or_default();
1218            let SettingValue::Bool(open) = value else { return Err(format!("{key}: on or off expected")) };
1219            let root = config.notes_root_path().ok_or_else(|| format!("{key}: no notes folder"))?;
1220            crate::projects::set_ai(&crate::projects::file_in(&root), id, *open)
1221        }
1222        _ if key.starts_with("project.") && key.ends_with(".routes") => {
1223            let id = key.trim_start_matches("project.").trim_end_matches(".routes");
1224            let routes: &[crate::projects::RouteValue] = match value {
1225                SettingValue::Routes(routes) => routes,
1226                _ if none => &[],
1227                _ => return Err(format!("{key}: routes expected")),
1228            };
1229            let root = config.notes_root_path().ok_or_else(|| format!("{key}: no notes folder"))?;
1230            crate::projects::set_routes(&crate::projects::file_in(&root), id, routes)
1231        }
1232        // A row of the matrix of what reaches you, its words: read, checked, written whole
1233        // (`attention::apply`); the older grids' rows ("notify.<kind>", "reach.<row>") land there too.
1234        _ if key.starts_with("attention.") || key.starts_with("notify.") || key.starts_with("reach.") => crate::attention::apply(config_path, config, key, value),
1235        // Sioul's own spam filter: one row of the matrix, an action it knows;
1236        // the three written (an older `mode` read no more, taken out), so that
1237        // each row keeps what it meant. Thresholds between 0 and 1, the doubt below the spam.
1238        _ if key.starts_with("spam.actions.") => {
1239            use crate::spam::{Action, Class};
1240            let class = Class::read(&key["spam.actions.".len()..]).ok_or_else(|| format!("{key}: spam, unsure or ham"))?;
1241            let action = match value {
1242                SettingValue::Text(action) => Action::read(action),
1243                _ => None,
1244            }
1245            .ok_or_else(|| format!("{key}: move, flag or nothing"))?;
1246            let actions = config.spam.actions().with(class, action);
1247            for class in Class::ALL {
1248                set_value(config_path, &format!("spam.action_{}", class.as_str()), &SettingValue::Text(actions.of(class).as_str().into()))?;
1249            }
1250            set_value(config_path, "spam.mode", &SettingValue::Text(String::new()))
1251        }
1252        "spam.threshold_spam" | "spam.threshold_unsure" => {
1253            let wanted = match value {
1254                SettingValue::Float(f) => *f,
1255                SettingValue::Int(n) => *n as f64,
1256                _ => return Err(format!("{key}: a number between 0 and 1 expected")),
1257            };
1258            if !(wanted.is_finite() && wanted > 0.0 && wanted <= 1.0) {
1259                return Err(format!("{key}: {wanted} is not between 0 and 1"));
1260            }
1261            let (spam, unsure) = config.spam.thresholds();
1262            let (spam, unsure) = if key == "spam.threshold_spam" { (wanted, f64::from(unsure)) } else { (f64::from(spam), wanted) };
1263            if unsure >= spam {
1264                return Err(format!("{key}: unsure ({unsure:.2}) must stay below spam ({spam:.2})"));
1265            }
1266            set_value(config_path, key, &SettingValue::Float(wanted))
1267        }
1268        // The call screening (Settings ▸ This phone ▸ Call screening): a key at a time, its words checked.
1269        _ if key.starts_with("screening.") => crate::screening::set(config_path, key, value),
1270        // "Like the others": the account's own value goes.
1271        _ if key.starts_with("account.") && key.ends_with(".history_weeks") && *value == SettingValue::Int(-1) => set_value(config_path, key, &SettingValue::Text(String::new())),
1272        _ if key.starts_with("account.") && key.ends_with(".fetch_minutes") && *value == SettingValue::Int(0) => set_value(config_path, key, &SettingValue::Text(String::new())),
1273        _ => set_value(config_path, key, value),
1274    }
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279    use super::*;
1280
1281    /// The time kept between two events (22 October 2026): shown in
1282    /// Settings ▸ Agenda, 0 until you set it, and only then counted.
1283    #[test]
1284    fn the_time_between_events_is_yours_and_shown() {
1285        let config = Config::default();
1286        assert_eq!(config.agenda.between(), 0, "none unless set");
1287        let rows = for_view("agenda", &config, &Translator::new("en"), &[], None);
1288        let row = rows.iter().find(|s| s.key == "agenda.between").expect("shown in Settings ▸ Agenda");
1289        assert_eq!(row.value, SettingValue::Int(0));
1290        let file = std::env::temp_dir().join(format!("sioul-agenda-between-{}.toml", std::process::id()));
1291        apply(&file, &config, "agenda.between", &SettingValue::Int(30)).unwrap();
1292        assert_eq!(Config::load(&file).unwrap().agenda.between(), 30);
1293        let _ = std::fs::remove_file(&file);
1294    }
1295
1296    /// Where new events go, in Settings ▸ Agenda (21 October 2026): written
1297    /// there, read by `agenda::default_calendar_of`; a read-only calendar is
1298    /// never where they go.
1299    #[test]
1300    fn new_events_go_where_the_agenda_says() {
1301        let account = "agenda-default";
1302        crate::vdir::prepare(crate::vdir::Kind::Calendars, account, "personnel", "Personnel", None).unwrap();
1303        crate::vdir::prepare(crate::vdir::Kind::Calendars, account, "birthdays", "Birthdays", None).unwrap();
1304        let path = crate::vdir::state_path(account, crate::vdir::Kind::Calendars, "birthdays");
1305        let mut state = crate::vdir::State::load(&path);
1306        state.read_only = true;
1307        state.save(&path).unwrap();
1308        let config = Config::default();
1309        let tr = Translator::new("en");
1310        let rows = for_view("agenda", &config, &tr, &[], None);
1311        let choice = rows.iter().find(|s| s.key == "agenda.calendar").expect("the setting");
1312        assert!(choice.choices.iter().any(|c| c.value == SettingValue::Text(format!("{account}/personnel"))));
1313        assert!(!choice.choices.iter().any(|c| c.value == SettingValue::Text(format!("{account}/birthdays"))), "read only: not offered");
1314        let file = std::env::temp_dir().join(format!("sioul-agenda-default-{}.toml", std::process::id()));
1315        apply(&file, &config, "agenda.calendar", &SettingValue::Text(format!("{account}/personnel"))).unwrap();
1316        assert_eq!(Config::load(&file).unwrap().agenda.calendar.as_deref(), Some(format!("{account}/personnel").as_str()));
1317        assert_eq!(crate::agenda::default_calendar_of(Some(&format!("{account}/personnel"))).map(|c| c.id).as_deref(), Some("personnel"));
1318        assert!(crate::agenda::default_calendar_of(Some(&format!("{account}/birthdays"))).is_some_and(|c| c.id != "birthdays"));
1319        let _ = std::fs::remove_file(&file);
1320        let _ = std::fs::remove_dir_all(crate::vdir::Kind::Calendars.root().join(account));
1321    }
1322
1323    /// A work or school Microsoft account's shield has no AI to allow, said
1324    /// in one sentence; a personal one's keeps its switch. Agents' switch for
1325    /// work accounts shows once there is one, closed.
1326    #[test]
1327    fn microsoft_work_accounts_keep_their_mail_from_ais() {
1328        let config: Config = toml::from_str(
1329            "[[account]]\nid = \"home\"\nkind = \"imap\"\naddress = \"you@outlook.com\"\nauth = \"microsoft\"\nmicrosoft_kind = \"personal\"\nsmtp_host = \"smtp-mail.outlook.com\"\nshield = true\nshield_ai = true\n\
1330             [[account]]\nid = \"work\"\nkind = \"imap\"\naddress = \"you@contoso.example\"\nauth = \"microsoft\"\nmicrosoft_kind = \"work\"\nsmtp_host = \"smtp.office365.com\"\nshield = true\nshield_ai = true\n",
1331        )
1332        .unwrap();
1333        let tr = Translator::new("en");
1334        let rows = |view: &str| for_view(view, &config, &tr, &[], None);
1335        assert!(rows("account:home").iter().any(|s| s.key == "account.home.shield_ai"));
1336        let work = rows("account:work");
1337        assert!(!work.iter().any(|s| s.key == "account.work.shield_ai"), "no AI to allow on a work account");
1338        assert!(work.iter().any(|s| s.kind == Kind::Note && s.label == tr.text("set-account-shield-ai-work", None)));
1339        assert!(config.account("home").unwrap().ai_reads() && !config.account("work").unwrap().ai_reads(), "whatever the file says");
1340        let agents = rows("parameters");
1341        let switch = agents.iter().find(|s| s.key == "mcp.work_accounts").expect("shown once there is a work account");
1342        assert_eq!(switch.value, SettingValue::Bool(false), "closed unless opened");
1343        assert!(!switch.help.is_empty() && !switch.help.starts_with("set-"));
1344    }
1345
1346    #[test]
1347    fn each_page_has_its_own() {
1348        let config: Config = toml::from_str("case_store = \"~/Notes\"\n[[account]]\nid = \"a\"\nkind = \"imap\"\naddress = \"jane@example.org\"\nshield = true\nshield_ai = true\n").unwrap();
1349        let tr = Translator::new("en");
1350        // Calendars, task lists and address books share one key, each page its own kind of them,
1351        // and depend on what this computer holds: left out.
1352        // Your contacts' categories are this computer's: left out too.
1353        let keys = |view: &str| for_view(view, &config, &tr, &[("acct/plan".into(), "Plan".into())], None).into_iter().filter(|s| s.kind != Kind::Note && s.key != "collections" && !s.key.starts_with(crate::porch::CATEGORY) && !s.key.starts_with(crate::porch::CONTACT)).map(|s| s.key).collect::<Vec<_>>();
1354        assert_eq!(keys("notes"), vec!["notes_folder"]);
1355        // Mail's own, then how a message reads: the reading panel's, shown where messages are read.
1356        assert_eq!(keys("mail"), vec!["mail.threads", "fetch_minutes", "mail.filter", "spam.actions", "spam.threshold_spam", "spam.threshold_unsure", "spam.filter", "reading.family", "reading.size", "reading.spacing"]);
1357        // An address's own, on its card in Accounts; what all share, under them.
1358        assert_eq!(keys("account:a"), vec!["account.a.area", "account.a.history_weeks", "account.a.fetch_minutes", "account.a.shield", "account.a.shield_ai"]);
1359        assert_eq!(keys("accounts"), vec!["ai_key"]);
1360        assert_eq!(keys("tasks"), vec!["office_hours", "tasks.kind", "tasks.estimate", "tasks.list", "tasks.blocks", "tasks.block_alarms", "planning.start", "planning.window_days", "planning.even_days", "planning.gain_slots", "quiet.work", "quiet.personal", "github.enabled"]);
1361        assert_eq!(for_view("tasks", &config, &tr, &[("acct/plan".into(), "Plan".into())], None).iter().find(|s| s.key == "tasks.list").unwrap().choices.len(), 2);
1362        assert_eq!(for_view("lane:filed", &config, &tr, &[], None)[0].kind, Kind::Link);
1363        // The Porch: its letters and its own sorting; every lane said once, none with another page's settings.
1364        let porch = for_view("porch", &config, &tr, &[], None);
1365        let notes = porch.iter().filter(|s| s.kind == Kind::Note).count();
1366        // Where the hours went, the order, then public, people, screener, filed, less important, the review queue, set aside, hostile.
1367        assert_eq!(notes, 1 + 1 + 8, "{porch:?}");
1368        assert_eq!(keys("porch"), vec!["letters.inbox", "porch.calls", "reading.family", "reading.size", "reading.spacing", "known", "link.words.senders"]);
1369        assert!(porch.iter().any(|s| s.kind == Kind::Note && s.help.contains("Accounts")), "the shield is said to be in Accounts");
1370        // Sioul as a whole: language and looks, your folder, hours, what reaches you, reminders, pauses, invoices;
1371        // the words tab apart (`the_words_tab`).
1372        // (The projects' file's renaming shows only for a notes folder holding its first name: `the_projects_file_is_renamed_on_demand`.)
1373        let parameters: Vec<String> = keys("parameters").into_iter().filter(|k| !k.starts_with("words.") && k != "projects.rename").collect();
1374        assert_eq!(
1375            parameters,
1376            vec![
1377                // colour: "screen_colours", "calmer_colours".
1378                "language", "theme", "places_named", "passwords_shown", "screen_colours", "calmer_colours", "case_store", "window", "window.admin", "link.needs", "time_off", "reminders.mail", "reminders.mail_newsletters", "reminders.gather", "free_time.nothing",
1379                "dnd.button", "dnd.focus", "dnd.pauses", "dnd.sleep", "reminders.before_event", "reminders.events", "reminders.asked_days", "reminders.waits", "reminders.payment_days", "reminders_closed", "reminders.gathered",
1380                "free_time.moves", "free_time.latest_after", "free_time.movement", "link.attention.pause", "pause.helps", "pause.grounding", "pause.breathing", "pause.pace", "pause.after", "pause.country", "invoice.name", "invoice.address", "invoice.siret", "invoice.vat",
1381                "invoice.prefix", "invoice.currency", "invoice.payment", "invoice.folder", "invoice.rate", "mcp.outside_projects", "mcp.texts", "mcp.spam"
1382            ]
1383        );
1384        // What reaches you: its switches in their own tab; the matrix is the tab's (`attention::grid`), no setting of it here.
1385        let attention: Vec<Setting> = for_view("parameters", &config, &tr, &[], None).into_iter().filter(|s| s.section == "attention").collect();
1386        assert_eq!(attention.iter().map(|s| s.key.as_str()).collect::<Vec<_>>(), ["reminders.mail", "reminders.mail_newsletters", "reminders.gather", "free_time.nothing", "dnd.button", "dnd.focus", "dnd.pauses", "dnd.sleep"]);
1387        assert!(attention.iter().all(|s| !s.label.starts_with("set-") && !s.help.starts_with("set-")));
1388        // Nothing is set in two places: a setting has one owner. The reading
1389        // panel's own ("Aa" in Notes) are shown in Mail's and the Porch's too,
1390        // where messages are read, as each lane's are in the Porch's.
1391        let mut seen: std::collections::BTreeMap<String, &str> = std::collections::BTreeMap::new();
1392        let keys = |view: &str| keys(view).into_iter().filter(|k| view == "reading" || !k.starts_with("reading.")).collect::<Vec<_>>();
1393        assert_eq!(keys("reading"), vec!["reading.family", "reading.size", "reading.spacing"]);
1394        // Who is on which list; when each list reaches you is the matrix's (What reaches you ▸ By person).
1395        assert_eq!(keys("senders"), vec!["safe", "neutral", "restricted", "blocked"]);
1396        assert_eq!(keys("contacts"), vec!["map.geocode", "map.tiles", "contacts.region"]);
1397        let regions = for_view("contacts", &config, &tr, &[], None).into_iter().find(|s| s.key == "contacts.region").unwrap().choices;
1398        assert!(regions.len() > 30 && regions.iter().all(|c| !c.label.starts_with("country-")), "{regions:?}");
1399        for view in ["porch", "mail", "accounts", "account:a", "senders", "agenda", "tasks", "notes", "reading", "sites", "contacts", "parameters"] {
1400            for key in keys(view) {
1401                if let Some(other) = seen.insert(key.clone(), view) {
1402                    panic!("{key} is set in {other} and in {view}");
1403                }
1404            }
1405        }
1406        for view in ["notes", "mail", "account:a", "tasks", "porch", "parameters", "sites", "contacts", "agenda"] {
1407            assert!(for_view(view, &config, &tr, &[], None).iter().all(|s| !s.label.starts_with("set-") && !s.help.starts_with("set-")), "{view}: a sentence is missing");
1408        }
1409    }
1410
1411    /// The words tab: the languages and countries ticked, then each line's
1412    /// note and lists, every word said in both languages; the automatic
1413    /// senders' older whole list shown as it wins, then written as a
1414    /// difference; a named list's chips read back; a line taken back.
1415    #[test]
1416    fn the_words_tab() {
1417        let dir = std::env::temp_dir().join(format!("sioul-words-tab-{}", std::process::id()));
1418        let _ = std::fs::remove_dir_all(&dir);
1419        std::fs::create_dir_all(&dir).unwrap();
1420        let path = dir.join("config.toml");
1421        std::fs::write(&path, "filed_words = [\"noreply\", \"robot\"]\n\n[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n").unwrap();
1422        let config = Config::load(&path).unwrap();
1423        for language in ["en", "fr"] {
1424            let rows: Vec<Setting> = for_view("parameters", &config, &Translator::new(language), &[], None).into_iter().filter(|s| s.section == "words").collect();
1425            assert_eq!((rows[0].key.as_str(), rows[1].key.as_str()), ("words.languages", "words.countries"));
1426            assert!(rows[..2].iter().all(|s| !s.choices.is_empty() && s.choices.iter().all(|c| !c.label.starts_with("words-") && !c.label.starts_with("country-"))), "{language}");
1427            for (_, line, lists) in WORD_LINES {
1428                let note = rows.iter().find(|s| s.key == format!("words.about.{line}")).unwrap_or_else(|| panic!("{line}"));
1429                let said = [&note.label, &note.help, &note.unit, &note.group];
1430                assert!(said.iter().all(|w| !w.is_empty() && !w.starts_with("words-")), "{language}: {line}: {note:?}");
1431                for list in lists.iter() {
1432                    let row = rows.iter().find(|s| s.key == format!("words.{list}")).unwrap_or_else(|| panic!("{list}"));
1433                    assert!(row.kind == Kind::Words && !row.label.starts_with("words-") && !row.help.starts_with("words-"), "{language}: {list}: {row:?}");
1434                }
1435            }
1436        }
1437        let rows = for_view("parameters", &config, &Translator::new("en"), &[], None);
1438        let row = |key: &str| rows.iter().find(|s| s.key == key).cloned().unwrap();
1439        // The older whole list as it wins, your own words first.
1440        assert_eq!(row("words.senders.automatic").value, SettingValue::Texts(vec!["robot".into(), "noreply".into()]));
1441        assert!(row("words.about.senders").help.contains("added"), "{:?}", row("words.about.senders"));
1442        apply(&path, &config, "words.senders.automatic", &SettingValue::Texts(vec!["noreply".into(), "no-reply".into(), "robot".into()])).unwrap();
1443        let text = std::fs::read_to_string(&path).unwrap();
1444        assert!(!text.contains("filed_words") && text.contains("[words.senders.automatic]"), "{text}");
1445        let config = Config::load(&path).unwrap();
1446        let mut automatic = crate::words::Words::of(&config).senders.automatic.clone();
1447        automatic.sort();
1448        assert_eq!(automatic, ["no-reply", "noreply", "robot"]);
1449        // A brand of yours, with its domains; one without them refused.
1450        let SettingValue::Texts(mut chips) = row("words.brands.brands").value else { panic!("brands") };
1451        chips.push("Ma Banque: mabanque.example, mabanque.fr".into());
1452        apply(&path, &config, "words.brands.brands", &SettingValue::Texts(chips.clone())).unwrap();
1453        let config = Config::load(&path).unwrap();
1454        assert!(crate::words::Words::of(&config).brands.brands.iter().any(|(n, d)| n == "Ma Banque" && d.len() == 2));
1455        chips.push("Sans Domaine".into());
1456        assert!(apply(&path, &config, "words.brands.brands", &SettingValue::Texts(chips)).is_err());
1457        // One language at least; then lines taken back to the packs.
1458        assert!(apply(&path, &config, "words.languages", &serde_json::from_str("[]").unwrap()).is_err());
1459        apply(&path, &config, "words.languages", &SettingValue::Texts(vec!["en".into()])).unwrap();
1460        apply(&path, &config, "words.reset.senders", &SettingValue::Bool(true)).unwrap();
1461        apply(&path, &config, "words.reset.brands", &SettingValue::Bool(true)).unwrap();
1462        let config = Config::load(&path).unwrap();
1463        assert_eq!(crate::words::languages(&config), ["en"]);
1464        let text = std::fs::read_to_string(&path).unwrap();
1465        assert!(!text.contains("senders") && !text.contains("brands"), "{text}");
1466        let _ = std::fs::remove_dir_all(&dir);
1467    }
1468
1469    /// A list's change from the window is set over the list as the files hold
1470    /// it now (`change`): an entry another device added since the row was
1471    /// shown stays; the one taken out goes; working hours and senders alike;
1472    /// nothing changed since: the window's list as it sent it, its order kept
1473    /// (third review, T1, T2 and the sweep of the window's editors).
1474    #[test]
1475    fn a_list_changed_from_the_window_keeps_what_arrived_meanwhile() {
1476        let dir = std::env::temp_dir().join(format!("sioul-change-{}", std::process::id()));
1477        let _ = std::fs::remove_dir_all(&dir);
1478        std::fs::create_dir_all(&dir).unwrap();
1479        let path = dir.join("config.toml");
1480        std::fs::write(&path, format!("known_senders = \"{}\"\n", dir.join("known.txt").display().to_string().replace('\\', "/"))).unwrap();
1481        let known = Config::load(&path).unwrap().known_senders_path();
1482        std::fs::write(&known, "jane@example.org\nbob@example.org\n").unwrap();
1483        let texts = |list: &[&str]| SettingValue::Texts(list.iter().map(|s| s.to_string()).collect());
1484        let shown = texts(&["bob@example.org", "jane@example.org"]);
1485        // Another device adds Ada meanwhile; the window takes Bob out and adds Zoé.
1486        std::fs::write(&known, "jane@example.org\nbob@example.org\nada@example.org\n").unwrap();
1487        change(&path, "known", Some(&shown), &texts(&["jane@example.org", "zoe@example.org"])).unwrap();
1488        let mut now = SenderList::load(&known).entries();
1489        now.sort();
1490        assert_eq!(now, ["ada@example.org", "jane@example.org", "zoe@example.org"]);
1491        // The lists of words: the pause's helps, reordered by the window with nothing new: its order.
1492        set_value(&path, "pause.helps", &texts(&["Tea", "A walk"])).unwrap();
1493        change(&path, "pause.helps", Some(&texts(&["Tea", "A walk"])), &texts(&["A walk", "Tea"])).unwrap();
1494        assert_eq!(Config::load(&path).unwrap().pause.helps, ["A walk", "Tea"]);
1495        // Rebased by hand: the window's order for what it holds, what it never showed after.
1496        assert_eq!(rebased(&[1, 2, 3], &[3, 1, 4], &[1, 2, 3, 5]), [3, 1, 4, 5]);
1497        assert_eq!(rebase(&SettingValue::Ints(Vec::new()), &texts(&["a"]), &texts(&["b"])), texts(&["a", "b"]));
1498        let _ = std::fs::remove_dir_all(&dir);
1499    }
1500
1501    /// One element of a structured list changed on two devices at once keeps
1502    /// one version, the later change, never both: a day off by its id (end to
1503    /// end, its table and id kept), a route at its place (fourth review, R4c).
1504    /// A day's window is named by its rank only while it is the one shown
1505    /// (fifth review).
1506    #[test]
1507    fn an_element_changed_on_two_devices_stays_one() {
1508        use crate::config::{TimeOffValue, WindowValue};
1509        let dir = std::env::temp_dir().join(format!("sioul-change-one-{}", std::process::id()));
1510        let _ = std::fs::remove_dir_all(&dir);
1511        std::fs::create_dir_all(&dir).unwrap();
1512        let path = dir.join("config.toml");
1513        let day = |id: &str, label: &str| TimeOffValue { id: id.into(), from: "2026-08-01".into(), until: "2026-08-15".into(), label: label.into() };
1514        std::fs::write(&path, "[[time_off]]\nid = \"a1\"\nfrom = 2026-08-01\nuntil = 2026-08-15\nlabel = \"Summer\"\n").unwrap();
1515        // Another device names it otherwise meanwhile; this one changes its label from what it showed.
1516        std::fs::write(&path, "[[time_off]]\nid = \"a1\"\nfrom = 2026-08-01\nuntil = 2026-08-15\nlabel = \"Summer holidays\"\n").unwrap();
1517        change(&path, "time_off", Some(&SettingValue::TimeOff(vec![day("a1", "Summer")])), &SettingValue::TimeOff(vec![day("a1", "Summer trip")])).unwrap();
1518        let off = Config::load(&path).unwrap().time_off;
1519        assert_eq!(off.iter().map(|t| (t.id.as_str(), t.label.as_str())).collect::<Vec<_>>(), [("a1", "Summer trip")]);
1520        // A Monday's hours: named by their rank, replaced only while the window
1521        // there is the one shown; changed elsewhere too, the other device's stays
1522        // beside this change (nothing of a day's hours is lost to a shifted rank).
1523        let window = |start: &str, end: &str| WindowValue { day: "mon".into(), start: start.into(), end: end.into(), minutes: 0 };
1524        let windows = |list: Vec<WindowValue>| SettingValue::Windows(list);
1525        assert_eq!(rebase(&windows(vec![window("09:00", "17:00")]), &windows(vec![window("09:00", "18:00")]), &windows(vec![window("09:00", "17:00"), window("19:00", "20:00")])), windows(vec![window("09:00", "18:00"), window("19:00", "20:00")]));
1526        assert_eq!(rebase(&windows(vec![window("09:00", "17:00")]), &windows(vec![window("09:00", "18:00")]), &windows(vec![window("08:00", "17:00")])), windows(vec![window("09:00", "18:00"), window("08:00", "17:00")]));
1527        // A route changed at its place while another device added one before it: changed in place, the other kept.
1528        let route = |word: &str| crate::projects::RouteValue { subject_contains: vec![word.into()], ..Default::default() };
1529        let routes = |list: Vec<crate::projects::RouteValue>| SettingValue::Routes(list);
1530        assert_eq!(rebase(&routes(vec![route("a"), route("b")]), &routes(vec![route("a2"), route("b")]), &routes(vec![route("z"), route("a"), route("b")])), routes(vec![route("a2"), route("b"), route("z")]));
1531        let _ = std::fs::remove_dir_all(&dir);
1532    }
1533
1534    /// A day's window changed here while another device added one earlier
1535    /// that day (its rank shifted): the window shown is the one changed, the
1536    /// others stay (fifth review).
1537    #[test]
1538    fn a_window_changed_here_while_another_was_added_that_day() {
1539        use crate::config::WindowValue;
1540        let w = |start: &str, end: &str| WindowValue { day: "mon".into(), start: start.into(), end: end.into(), minutes: 0 };
1541        let shown = SettingValue::Windows(vec![w("09:00", "12:00"), w("14:00", "17:00")]);
1542        let value = SettingValue::Windows(vec![w("09:00", "12:00"), w("14:00", "18:00")]);
1543        let current = SettingValue::Windows(vec![w("07:00", "08:00"), w("09:00", "12:00"), w("14:00", "17:00")]);
1544        let SettingValue::Windows(mut got) = rebase(&shown, &value, &current) else { panic!() };
1545        got.sort_by(|a, b| a.start.cmp(&b.start));
1546        let got: Vec<(&str, &str)> = got.iter().map(|w| (w.start.as_str(), w.end.as_str())).collect();
1547        assert_eq!(got, [("07:00", "08:00"), ("09:00", "12:00"), ("14:00", "18:00")]);
1548    }
1549
1550    /// One route changed on two devices at once (routes have no id): one
1551    /// route, the later change; the others stay (fifth review).
1552    #[test]
1553    fn one_route_changed_on_two_devices_stays_one() {
1554        let route = |word: &str| crate::projects::RouteValue { subject_contains: vec![word.into()], ..Default::default() };
1555        let routes = |list: Vec<crate::projects::RouteValue>| SettingValue::Routes(list);
1556        assert_eq!(rebase(&routes(vec![route("a"), route("b")]), &routes(vec![route("a2"), route("b")]), &routes(vec![route("a3"), route("b")])), routes(vec![route("a2"), route("b")]));
1557        // Moved by another device: found by what it held.
1558        assert_eq!(rebase(&routes(vec![route("a"), route("b")]), &routes(vec![route("a2"), route("b")]), &routes(vec![route("b"), route("c"), route("a")])), routes(vec![route("a2"), route("b"), route("c")]));
1559    }
1560
1561    #[test]
1562    fn lists_emptied_and_secrets_kept_out() {
1563        let dir = std::env::temp_dir().join(format!("sioul-apply-{}", std::process::id()));
1564        let _ = std::fs::remove_dir_all(&dir);
1565        std::fs::create_dir_all(&dir).unwrap();
1566        let path = dir.join("config.toml");
1567        std::fs::write(&path, format!("# Mine.\nknown_senders = \"{}\"\n", dir.join("known.txt").display().to_string().replace('\\', "/"))).unwrap();
1568        let config = Config::load(&path).unwrap();
1569        apply(&path, &config, "known", &SettingValue::Texts(vec!["jane@example.org".into()])).unwrap();
1570        assert_eq!(SenderList::load(&config.known_senders_path()).entries(), ["jane@example.org"]);
1571        // The last one taken away: the window sends `[]`.
1572        apply(&path, &config, "known", &serde_json::from_str("[]").unwrap()).unwrap();
1573        assert!(SenderList::load(&config.known_senders_path()).entries().is_empty());
1574        // The spam filter's thresholds: the doubt stays below the spam, each between 0 and 1.
1575        assert!(apply(&path, &config, "spam.threshold_unsure", &SettingValue::Float(0.97)).is_err());
1576        assert!(apply(&path, &config, "spam.threshold_spam", &SettingValue::Float(1.5)).is_err());
1577        assert!(apply(&path, &config, "spam.actions.spam", &SettingValue::Text("delete".into())).is_err());
1578        assert!(apply(&path, &config, "spam.actions.maybe", &SettingValue::Text("move".into())).is_err());
1579        apply(&path, &config, "spam.threshold_spam", &SettingValue::Float(0.9)).unwrap();
1580        // An older Sioul's mode, "act": spam moved, doubts flagged; a row changed keeps the others as they read.
1581        set_value(&path, "spam.mode", &SettingValue::Text("act".into())).unwrap();
1582        let config = Config::load(&path).unwrap();
1583        use crate::spam::{Action, Actions};
1584        assert_eq!(config.spam.actions(), Actions { spam: Action::Move, unsure: Action::Flag, ham: Action::Nothing });
1585        apply(&path, &config, "spam.actions.ham", &SettingValue::Text("flag".into())).unwrap();
1586        let config = Config::load(&path).unwrap();
1587        assert_eq!((config.spam.actions(), config.spam.thresholds(), config.spam.mode.as_deref()), (Actions { spam: Action::Move, unsure: Action::Flag, ham: Action::Flag }, (0.9, 0.5), None));
1588        let matrix = for_view("mail", &config, &Translator::new("en"), &[], None).into_iter().find(|s| s.key == "spam.actions").unwrap();
1589        assert_eq!((matrix.kind, matrix.rows.len(), matrix.choices.len()), (Kind::Radios, 3, 3));
1590        assert_eq!(matrix.value, SettingValue::Texts(vec!["spam:move".into(), "unsure:flag".into(), "ham:flag".into()]));
1591        assert!(apply(&path, &config, "spam.threshold_unsure", &SettingValue::Float(0.9)).is_err());
1592        // Shown as percentages, each slider stopping a point short of the other.
1593        let rows = for_view("mail", &config, &Translator::new("en"), &[], None);
1594        let range = |key: &str| rows.iter().find(|s| s.key == key).map(|s| ((s.min * 100.0).round(), (s.max * 100.0).round(), s.unit.clone())).unwrap();
1595        assert_eq!((range("spam.threshold_spam"), range("spam.threshold_unsure")), ((51.0, 99.0, "%".to_string()), (5.0, 89.0, "%".to_string())));
1596        assert!(rows.iter().any(|s| s.key == "spam.filter" && s.kind == Kind::Spam));
1597        // A secret is never written into the configuration.
1598        assert!(apply(&path, &config, "github_token", &SettingValue::Int(12345)).is_err());
1599        assert!(!std::fs::read_to_string(&path).unwrap().contains("github_token"));
1600        let _ = std::fs::remove_dir_all(&dir);
1601    }
1602
1603    #[test]
1604    fn the_projects_file_is_renamed_on_demand() {
1605        let dir = std::env::temp_dir().join(format!("sioul-rename-setting-{}", std::process::id()));
1606        let _ = std::fs::remove_dir_all(&dir);
1607        let notes = dir.join("notes");
1608        std::fs::create_dir_all(&notes).unwrap();
1609        let path = dir.join("config.toml");
1610        std::fs::write(&path, format!("case_store = \"{}\"\n", notes.display().to_string().replace('\\', "/"))).unwrap();
1611        let config = Config::load(&path).unwrap();
1612        let tr = Translator::new("en");
1613        let row = || for_view("parameters", &config, &tr, &[], None).into_iter().find(|s| s.section == "files" && (s.key == "projects.rename" || s.kind == Kind::Note));
1614        // No projects' file yet: nothing to say.
1615        assert!(row().is_none());
1616        // Its first name only: a button, never pressed by itself.
1617        std::fs::write(notes.join(crate::projects::OLD_FILE), "[[case]]\nid = \"taxes\"\ntitle = \"Taxes\"\nai = true\n").unwrap();
1618        let rename = row().unwrap();
1619        assert_eq!((rename.key.as_str(), rename.kind), ("projects.rename", Kind::Action));
1620        assert!(rename.label.contains("sioul-projects.toml") && !rename.help.starts_with("set-"), "{rename:?}");
1621        assert!(notes.join(crate::projects::OLD_FILE).exists() && !notes.join(crate::projects::FILE).exists());
1622        apply(&path, &config, "projects.rename", &SettingValue::Bool(true)).unwrap();
1623        assert!(row().is_none());
1624        assert!(crate::projects::ProjectStore::load(&notes).unwrap().get("taxes").unwrap().ai);
1625        assert!(notes.join(crate::projects::BEFORE_RENAME).exists());
1626        // Both names there (an older device wrote its file again): said, and the new one read.
1627        std::fs::write(notes.join(crate::projects::OLD_FILE), "[[case]]\nid = \"old\"\ntitle = \"Old\"\n").unwrap();
1628        let both = row().unwrap();
1629        assert!(both.kind == Kind::Note && both.label.contains("sioul-projects.toml"), "{both:?}");
1630        assert!(apply(&path, &config, "projects.rename", &SettingValue::Bool(true)).is_err());
1631        let _ = std::fs::remove_dir_all(&dir);
1632    }
1633}