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