1use crate::config::{Config, SettingValue, WindowValue, set_value};
11use crate::i18n::Translator;
12use crate::porch::SenderList;
13use serde::Serialize;
14use std::path::Path;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Kind {
20 Bool,
22 Int,
24 Float,
26 Choice,
28 Picks,
30 Text,
31 Long,
33 Folder,
35 Font,
37 Week,
39 Windows,
41 #[serde(rename = "timeoff")]
43 TimeOff,
44 Senders,
46 Words,
48 Areas,
50 Routes,
52 Kinds,
54 Categories,
56 Collections,
58 Note,
60 Secret,
62 Link,
64 Action,
68 Radios,
72 Spam,
76 Filters,
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize)]
84pub struct Choice {
85 pub value: SettingValue,
86 pub label: String,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize)]
91pub struct Setting {
92 pub key: String,
94 pub kind: Kind,
95 pub label: String,
96 pub help: String,
98 pub value: SettingValue,
99 #[serde(skip_serializing_if = "Vec::is_empty")]
100 pub choices: Vec<Choice>,
101 #[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 pub unit: String,
109 pub group: String,
111 #[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 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 fn collections(&mut self, config: &Config, id: &str, kind: crate::vdir::Kind, holding: &str) {
137 use crate::capabilities::{Provider, provider_of_collection};
138 let named: Vec<crate::config::NamedValue> = crate::vdir::collections(kind)
139 .into_iter()
140 .filter(|c| holding.is_empty() || c.holds(holding))
141 .map(|c| crate::config::NamedValue {
142 id: format!("{}/{}/{}", if kind == crate::vdir::Kind::Contacts { "contacts" } else { "calendars" }, c.account, c.id),
143 locked: if c.read_only {
145 self.tr.text("collection-read-only", None)
146 } else if provider_of_collection(config.account(&c.account), &c) == Provider::Google {
147 self.tr.text("google-fixed-collection", None)
148 } else {
149 String::new()
150 },
151 detail: { let place = c.place(config); if place.is_empty() { self.tr.text("collection-here", None) } else { place } },
152 label: c.name,
153 })
154 .collect();
155 if !named.is_empty() {
156 self.push("collections", id, Kind::Collections, SettingValue::Named(named));
157 }
158 }
159
160 fn ai_key(&mut self, config: &Config) {
162 if config.accounts.iter().any(|a| a.ai_reads()) {
163 self.push("ai_key", "ai-key", Kind::Secret, SettingValue::Bool(false));
164 }
165 }
166
167 fn said(&mut self, key: &str, kind: Kind, label: String, help: String, value: SettingValue) -> &mut Setting {
169 self.out.push(Setting { key: key.to_string(), kind, label, help, value, choices: Vec::new(), rows: Vec::new(), min: 0.0, max: 0.0, step: 1.0, unit: String::new(), group: self.group.clone(), section: self.section.clone() });
170 let last = self.out.len() - 1;
171 &mut self.out[last]
172 }
173
174 fn note(&mut self, about: String, lines: Vec<String>) {
176 let key = format!("note.{}", self.out.len());
177 self.out.push(Setting { key, kind: Kind::Note, label: about, help: lines.join("\n"), value: SettingValue::Text(String::new()), choices: Vec::new(), rows: Vec::new(), min: 0.0, max: 0.0, step: 1.0, unit: String::new(), group: self.group.clone(), section: self.section.clone() });
178 }
179
180 fn range(setting: &mut Setting, min: f64, max: f64, step: f64, unit: &str) {
181 setting.min = min;
182 setting.max = max;
183 setting.step = step;
184 setting.unit = unit.to_string();
185 }
186}
187
188fn history_choices(tr: &Translator, with_default: bool) -> Vec<Choice> {
190 let mut out = Vec::new();
191 if with_default {
192 out.push(Choice { value: SettingValue::Int(-1), label: tr.text("set-like-others", None) });
193 }
194 for weeks in [1, 2, 4, 13, 26, 52, 0] {
195 out.push(Choice { value: SettingValue::Int(weeks), label: tr.text(&format!("history-{weeks}"), None) });
196 }
197 out
198}
199
200pub fn for_view(view: &str, config: &Config, tr: &Translator, lists: &[(String, String)], store: Option<&crate::projects::ProjectStore>) -> Vec<Setting> {
204 let mut b = Builder { tr, out: Vec::new(), group: String::new(), section: String::new() };
205 let int = |n: Option<u32>, default: u32| SettingValue::Int(i64::from(n.unwrap_or(default)));
206 let known = || SettingValue::Texts(SenderList::load(&config.known_senders_path()).entries());
207 if let Some(lane) = view.strip_prefix("lane:") {
212 match lane {
213 "people" | "screener" => {
214 b.push("known", "known", Kind::Senders, known());
215 }
216 "filed" => {
218 b.push("link.words.senders", "filed-words", Kind::Link, SettingValue::Text("settings:words.senders.automatic".into()));
219 }
220 _ => {}
221 }
222 return b.out;
223 }
224 if let Some(id) = view.strip_prefix("project:") {
226 if let Some(project) = store.and_then(|s| s.get(id)) {
227 let routes = project.routes.iter().map(crate::projects::RouteValue::from).collect();
228 b.push(&format!("project.{}.routes", project.id), "routes", Kind::Routes, SettingValue::Routes(routes));
229 }
230 return b.out;
231 }
232 if let Some(id) = view.strip_prefix("account:") {
236 if let Some(account) = config.account(id).filter(|a| a.kind == crate::config::AccountKind::Imap) {
237 let key = |field: &str| format!("account.{}.{field}", account.id);
238 let area = account.area.as_deref().and_then(crate::areas::Area::parse).unwrap_or(crate::areas::Area::WORK);
240 b.push(&key("area"), "account-area", Kind::Areas, SettingValue::Text(area.id()));
241 let own = raw_account_history(config, &account.id);
242 let s = b.push(&key("history_weeks"), "account-history", Kind::Choice, SettingValue::Int(own.map_or(-1, i64::from)));
243 s.choices = history_choices(tr, true);
244 let s = b.push(&key("fetch_minutes"), "account-fetch", Kind::Int, SettingValue::Int(account.fetch_minutes.map_or(0, i64::from)));
245 Builder::range(s, 0.0, 1440.0, 5.0, "min");
246 b.push(&key("shield"), "account-shield", Kind::Bool, SettingValue::Bool(account.shield));
247 if account.shield && account.is_microsoft_work() {
250 b.note(tr.text("set-account-shield-ai-work", None), Vec::new());
251 } else if account.shield {
252 b.push(&key("shield_ai"), "account-shield-ai", Kind::Bool, SettingValue::Bool(account.shield_ai));
253 }
254 }
255 return b.out;
256 }
257 match view {
258 "porch" => {
259 b.note(tr.text("set-hours-elsewhere", None), Vec::new());
261 if let Some(store) = store.filter(|s| !s.projects.is_empty()) {
263 b.group = tr.text("set-projects-group", None);
264 let shown = store.projects.iter().filter(|c| !config.porch.hidden_projects.contains(&c.id)).map(|c| c.id.clone()).collect();
265 let s = b.push("porch.projects", "porch-projects", Kind::Picks, SettingValue::Texts(shown));
266 s.choices = store.projects.iter().map(|c| Choice { value: SettingValue::Text(c.id.clone()), label: c.title.clone() }).collect();
267 }
268 b.group = tr.text("set-letters-group", None);
270 let inbox = config.letters.inbox.clone().unwrap_or_else(|| config.notes_root_path().map(|r| crate::letters::Letters::folder(&r).join("inbox").display().to_string()).unwrap_or_default());
271 b.push("letters.inbox", "letters-inbox", Kind::Folder, SettingValue::Text(inbox));
272 b.group = tr.text("set-calls-group", None);
274 b.push("porch.calls", "porch-calls", Kind::Bool, SettingValue::Bool(config.porch.calls));
275 b.group = tr.text("ui-reading", None);
277 reading(&mut b, config);
278 b.group = tr.text("set-sorting", None);
282 b.note(tr.text("rule-order", None), Vec::new());
283 for lane in crate::view::lanes(config, store, tr).into_iter().filter(|l| !l.key.starts_with("project:")) {
284 b.group = lane.title.clone();
285 b.note(lane.about, lane.rules);
286 for mut setting in for_view(&format!("lane:{}", lane.key), config, tr, lists, store) {
287 if b.out.iter().any(|s| s.key == setting.key) {
288 continue;
289 }
290 setting.group = lane.title.clone();
291 b.out.push(setting);
292 }
293 }
294 }
295 "mail" => {
296 b.push("mail.threads", "mail-threads", Kind::Bool, SettingValue::Bool(config.mail.threads));
297 let s = b.push("fetch_minutes", "fetch-minutes", Kind::Int, int(config.fetch_minutes, crate::config::DEFAULT_FETCH_MINUTES));
298 Builder::range(s, 1.0, 240.0, 1.0, "min");
299 b.note(tr.text("set-accounts-elsewhere", None), Vec::new());
301 b.group = tr.text("set-filters-group", None);
303 b.push("mail.filter", "filters", Kind::Filters, SettingValue::Text(String::new()));
304 b.group = tr.text("set-spam-group", None);
307 let actions = config.spam.actions();
308 let cells = crate::spam::Class::ALL.iter().map(|c| format!("{}:{}", c.as_str(), actions.of(*c).as_str())).collect();
309 let s = b.push("spam.actions", "spam-actions", Kind::Radios, SettingValue::Texts(cells));
310 s.rows = crate::spam::Class::ALL.iter().map(|c| Choice { value: SettingValue::Text(c.as_str().into()), label: tr.text(&format!("set-spam-class-{}", c.as_str()), None) }).collect();
311 s.choices = crate::spam::Action::ALL.iter().map(|a| Choice { value: SettingValue::Text(a.as_str().into()), label: tr.text(&format!("set-spam-action-{}", a.as_str()), None) }).collect();
312 let (spam, unsure) = config.spam.thresholds();
313 let hundredths = |t: f32| (f64::from(t) * 100.0).round() / 100.0;
314 let s = b.push("spam.threshold_spam", "spam-threshold", Kind::Float, SettingValue::Float(hundredths(spam)));
316 Builder::range(s, (hundredths(unsure) + 0.01).max(0.5), 0.99, 0.01, "%");
317 let s = b.push("spam.threshold_unsure", "spam-unsure", Kind::Float, SettingValue::Float(hundredths(unsure)));
318 Builder::range(s, 0.05, (hundredths(spam) - 0.01).min(0.95), 0.01, "%");
319 b.push("spam.filter", if cfg!(target_os = "android") { "spam-filter-phone" } else { "spam-filter" }, Kind::Spam, SettingValue::Text(String::new()));
321 b.group = tr.text("ui-reading", None);
323 reading(&mut b, config);
324 let mut left = crate::unsubscribe::Record::load(&crate::unsubscribe::Record::default_path()).lists;
326 if !left.is_empty() {
327 left.sort_by_key(|l| std::cmp::Reverse(l.at));
328 let zone = jiff::tz::TimeZone::system();
329 let today = jiff::Timestamp::now().to_zoned(zone.clone()).date();
330 let lines = left
331 .iter()
332 .map(|l| {
333 let mut args = crate::i18n::args();
334 args.set("list", l.name.clone());
335 args.set("date", jiff::Timestamp::from_second(l.at).map(|t| tr.day_in(t.to_zoned(zone.clone()).date(), today)).unwrap_or_default());
336 args.set("way", tr.text(&format!("unsubscribe-way-{}", l.way), None));
337 tr.text("unsubscribed-line", Some(&args))
338 })
339 .collect();
340 b.group = tr.text("unsubscribed-group", None);
341 b.note(tr.text("unsubscribed-note", None), lines);
342 }
343 }
344 "senders" => {
348 for (key, id, path) in [
349 ("safe", "safe", config.safe_senders_path()),
350 ("neutral", "neutral", config.neutral_senders_path()),
351 ("restricted", "restricted", config.restricted_senders_path()),
352 ("blocked", "blocked-all", config.blocked_senders_path()),
353 ] {
354 let list = SenderList::load(&path);
356 b.push(key, id, Kind::Senders, SettingValue::Texts(list.entries().into_iter().chain(list.numbers().iter().cloned()).collect()));
357 }
358 let senders = crate::porch::Senders::load(config);
359 let choices = |none: &str| -> Vec<Choice> {
360 std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text(none, None) })
361 .chain(crate::porch::Standing::ALL.iter().map(|l| Choice { value: SettingValue::Text(l.as_str().into()), label: tr.text(&format!("sender-one-{}", l.as_str()), None) }))
362 .collect()
363 };
364 let mut cards: Vec<String> = Vec::new();
366 for list in [&senders.safe, &senders.neutral, &senders.restricted, &senders.blocked] {
367 for uid in list.cards() {
368 if !cards.contains(uid) {
369 cards.push(uid.clone());
370 }
371 }
372 }
373 if !cards.is_empty() {
374 b.group = tr.text("set-sender-people-group", None);
375 b.note(tr.text("set-sender-people-note", None), Vec::new());
376 let mut named: Vec<(String, String)> = cards
377 .into_iter()
378 .map(|uid| {
379 let name = senders.contacts.find(&uid).and_then(|i| senders.contacts.card(i)).map(|c| c.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| tr.text("sender-card-elsewhere", None));
380 (name, uid)
381 })
382 .collect();
383 named.sort_by_key(|(name, _)| crate::text::fold(name).into_iter().collect::<String>().to_lowercase());
384 for (name, uid) in named {
385 let standing = senders.card_standing(&uid).map_or("", |s| s.as_str());
386 let s = b.push(&format!("{}{uid}", crate::porch::CONTACT), "sender-person", Kind::Choice, SettingValue::Text(standing.into()));
387 s.label = name;
388 s.help = String::new();
389 s.choices = choices("sender-no-choice");
390 }
391 }
392 let mut names = senders.contacts.names();
394 for list in [&senders.safe, &senders.neutral, &senders.restricted, &senders.blocked] {
395 for name in list.categories() {
396 if !names.iter().any(|n| crate::porch::category_key(n) == crate::porch::category_key(name)) {
397 names.push(name.clone());
398 }
399 }
400 }
401 if !names.is_empty() {
402 b.group = tr.text("set-sender-categories-group", None);
403 b.note(tr.text("set-sender-categories-note", None), Vec::new());
404 let choices = choices("sender-no-list");
405 for name in names {
406 let standing = senders.category_standing(&name).map_or("", |s| s.as_str());
407 let s = b.push(&format!("{}{name}", crate::porch::CATEGORY), "sender-category", Kind::Choice, SettingValue::Text(standing.into()));
408 s.label = name;
409 s.help = String::new();
410 s.choices = choices.clone();
411 }
412 }
413 }
414 "accounts" => {
416 b.group = tr.text("set-ai-group", None);
417 b.ai_key(config);
418 }
419 "agenda" => {
420 b.collections(config, "calendars", crate::vdir::Kind::Calendars, "VEVENT");
421 let writable: Vec<Choice> = crate::vdir::collections(crate::vdir::Kind::Calendars)
423 .into_iter()
424 .filter(|c| !c.read_only && c.holds("VEVENT"))
425 .map(|c| Choice { value: SettingValue::Text(format!("{}/{}", c.account, c.id)), label: c.label(config, tr) })
426 .collect();
427 let s = b.push("agenda.calendar", "agenda-calendar", Kind::Choice, SettingValue::Text(config.agenda.calendar.clone().unwrap_or_default()));
428 s.choices = std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text("set-agenda-calendar-first", None) }).chain(writable).collect();
429 let s = b.push("agenda.day_start", "day-start", Kind::Int, int(config.agenda.day_start, 7));
430 Builder::range(s, 0.0, 12.0, 1.0, "h");
431 let s = b.push("agenda.between", "agenda-between", Kind::Int, int(config.agenda.between, 0));
433 Builder::range(s, 0.0, 120.0, 5.0, "min");
434 }
435 "tasks" => {
436 let office = config.office_hours().iter().map(|w| WindowValue { day: w.day.clone(), start: w.start.clone(), end: w.end_text(), minutes: 0 }).collect();
437 b.push("office_hours", "office-hours", Kind::Windows, SettingValue::Windows(office));
438 let kinds = config.task_kinds(tr).into_iter().map(|(id, label)| crate::config::NamedValue { id, label, ..Default::default() }).collect();
439 b.push("tasks.kind", "task-kinds", Kind::Kinds, SettingValue::Named(kinds));
440 b.collections(config, "task-lists", crate::vdir::Kind::Calendars, "VTODO");
441 let s = b.push("tasks.estimate", "task-estimate", Kind::Int, int(config.tasks.estimate, crate::plan::Settings::default().default_estimate));
442 Builder::range(s, 5.0, 240.0, 5.0, "min");
443 let s = b.push("tasks.list", "task-list", Kind::Choice, SettingValue::Text(config.tasks.list.clone().unwrap_or_default()));
444 s.choices = std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text("set-task-list-first", None) })
445 .chain(lists.iter().map(|(id, name)| Choice { value: SettingValue::Text(id.clone()), label: name.clone() }))
446 .collect();
447 let calendars: Vec<Choice> = crate::vdir::collections(crate::vdir::Kind::Calendars)
449 .into_iter()
450 .filter(|c| !c.read_only && c.holds("VEVENT"))
451 .map(|c| Choice { value: SettingValue::Text(format!("{}/{}", c.account, c.id)), label: c.label(config, tr) })
452 .collect();
453 let s = b.push("tasks.blocks", "task-blocks", Kind::Choice, SettingValue::Text(config.tasks.blocks.clone().unwrap_or_default()));
454 s.choices = std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text("set-task-blocks-own", None) }).chain(calendars).collect();
455 b.push("tasks.block_alarms", "task-block-alarms", Kind::Bool, SettingValue::Bool(config.tasks.block_alarms));
456 b.group = tr.text("set-planning-group", None);
458 let s = b.push("planning.start", "planning-start", Kind::Choice, SettingValue::Text(config.planning.start.clone().unwrap_or_default()));
459 s.choices = [("", "set-planning-start-as-now"), ("lighter", "set-planning-start-lighter"), ("much-lighter", "set-planning-start-much-lighter")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
460 let s = b.push("planning.window_days", "planning-window", Kind::Int, SettingValue::Int(i64::from(config.planning.window())));
461 Builder::range(s, f64::from(crate::capacity::WINDOW_LEAST), f64::from(crate::capacity::WINDOW_MOST), 1.0, "");
462 s.unit = tr.text("set-planning-window-unit", None);
463 b.push("planning.even_days", "planning-even", Kind::Bool, SettingValue::Bool(config.planning.even_days));
464 b.push("planning.gain_slots", "planning-gain-slots", Kind::Bool, SettingValue::Bool(config.planning.gain_slots()));
465 b.group = tr.text("set-task-areas-group", None);
467 let tasks = &crate::words::Words::of(config).tasks;
469 b.push("quiet.work", "quiet-work", Kind::Words, SettingValue::Texts(tasks.work.clone()));
470 b.push("quiet.personal", "quiet-personal", Kind::Words, SettingValue::Texts(tasks.personal.clone()));
471 b.group = tr.text("set-code-group", None);
473 b.push("github.enabled", "github", Kind::Bool, SettingValue::Bool(config.github.enabled));
474 if config.github.enabled {
475 b.push("github_token", "github-token", Kind::Secret, SettingValue::Bool(false));
476 b.push("github.assigned", "github-assigned", Kind::Bool, SettingValue::Bool(config.github.assigned));
477 b.push("github.reviews", "github-reviews", Kind::Bool, SettingValue::Bool(config.github.reviews));
478 b.push("github.created", "github-created", Kind::Bool, SettingValue::Bool(config.github.created));
479 b.push("github.mentioned", "github-mentioned", Kind::Bool, SettingValue::Bool(config.github.mentioned));
480 }
481 }
482 "notes" => {
483 b.push("notes_folder", "notes-folder", Kind::Text, SettingValue::Text(config.notes_folder.clone().unwrap_or_else(|| "notes".into())));
484 }
485 "reading" => reading(&mut b, config),
487 "sites" => {
488 b.push("bitwarden.email", "bitwarden-email", Kind::Text, SettingValue::Text(config.bitwarden.email.clone().unwrap_or_default()));
489 b.push("bitwarden.server", "bitwarden-server", Kind::Text, SettingValue::Text(config.bitwarden.server.clone().unwrap_or_default()));
490 }
491 "contacts" => {
492 b.collections(config, "address-books", crate::vdir::Kind::Contacts, "");
493 b.push("map.geocode", "map-geocode", Kind::Bool, SettingValue::Bool(config.map.geocode));
494 b.push("map.tiles", "map-tiles", Kind::Text, SettingValue::Text(config.map.tiles.clone().unwrap_or_default()));
495 let s = b.push("contacts.region", "phone-region", Kind::Choice, SettingValue::Text(config.contacts.region.clone().unwrap_or_default()));
497 s.choices = phone_regions(tr);
498 }
499 "parameters" | "general" => {
501 b.section = "look".into();
502 b.group = tr.text("set-look-group", None);
503 let s = b.push("language", "language", Kind::Choice, SettingValue::Text(config.language.clone().unwrap_or_default()));
504 s.choices = [("", "set-language-system"), ("fr", "set-language-fr"), ("en", "set-language-en")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
505 let s = b.push("theme", "theme", Kind::Choice, SettingValue::Text(config.theme.clone().unwrap_or_default()));
506 s.choices = [("", "set-theme-system"), ("light", "set-theme-light"), ("dark", "set-theme-dark")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
507 b.push("places_named", "places-named", Kind::Bool, SettingValue::Bool(config.places_named));
509 b.push("passwords_shown", "passwords-shown", Kind::Bool, SettingValue::Bool(false));
511 b.push("screen_colours", "screen-colours", Kind::Bool, SettingValue::Bool(config.screen_colours.unwrap_or(true)));
514 let s = b.push("calmer_colours", "calmer-colours", Kind::Choice, SettingValue::Text(config.calmer_colours.clone().unwrap_or_default()));
515 s.choices = [("", "set-calmer-colours-off"), ("little", "set-calmer-colours-little"), ("more", "set-calmer-colours-more")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
516 b.section = "files".into();
519 b.group = tr.text("set-files-group", None);
520 b.push("case_store", "notes-root", Kind::Folder, SettingValue::Text(config.notes_root.clone().unwrap_or_default()));
521 if let Some(root) = config.notes_root_path() {
524 if crate::projects::only_old_in(&root) {
525 b.push("projects.rename", "projects-rename", Kind::Action, SettingValue::Bool(false));
526 } else if crate::projects::both_in(&root) {
527 b.note(tr.text("set-projects-both", None), Vec::new());
528 }
529 }
530 b.section = "hours".into();
531 b.group = tr.text("set-hours-group", None);
532 let week = |windows: &[crate::window::AdminWindow]| SettingValue::Windows(windows.iter().map(|w| WindowValue { day: w.day.clone(), start: w.start.clone(), end: w.end_text(), minutes: 0 }).collect());
533 let of = |kind: &str| config.windows.iter().filter(|w| w.kind() == kind).cloned().collect::<Vec<_>>();
536 b.push("window", "windows", Kind::Windows, week(&of("work")));
537 b.push("window.admin", "windows-admin", Kind::Windows, week(&of("admin")));
538 b.push("link.needs", "hours-leisure", Kind::Link, SettingValue::Text("needs".into()));
539 let off = config.time_off.iter().map(|t| crate::config::TimeOffValue { id: t.id.clone(), from: t.from.to_string(), until: t.until.to_string(), label: t.label.clone() }).collect();
540 b.push("time_off", "time-off", Kind::TimeOff, SettingValue::TimeOff(off));
541 b.section = "attention".into();
544 b.group = String::new();
545 b.push("reminders.mail", "reminders-mail", Kind::Bool, SettingValue::Bool(config.reminders.mail));
547 b.push("reminders.mail_newsletters", "reminders-mail-newsletters", Kind::Bool, SettingValue::Bool(config.reminders.mail_newsletters));
548 b.push("reminders.gather", "reminders-gather", Kind::Bool, SettingValue::Bool(config.reminders.gather));
550 b.push("free_time.nothing", "free-nothing", Kind::Bool, SettingValue::Bool(config.free_time.nothing));
552 b.push("dnd.button", "dnd-button", Kind::Bool, SettingValue::Bool(config.dnd.button));
554 b.push("dnd.focus", "dnd-focus", Kind::Bool, SettingValue::Bool(config.dnd.focus));
555 b.push("dnd.pauses", "dnd-pauses", Kind::Bool, SettingValue::Bool(config.dnd.pauses));
556 b.push("dnd.sleep", "dnd-sleep", Kind::Bool, SettingValue::Bool(config.dnd.sleep));
557 b.section = "words".into();
559 words_rows(&mut b, config);
560 b.section = "reminders".into();
562 b.group = tr.text("set-reminders-group", None);
563 let lead = config.reminders.before_event;
565 let mut leads = crate::reminders::LEADS.to_vec();
566 if !leads.contains(&lead) {
567 leads.push(lead);
568 leads.sort_unstable();
569 }
570 let s = b.push("reminders.before_event", "reminders-before", Kind::Choice, SettingValue::Int(i64::from(lead)));
571 s.choices = leads.iter().map(|m| Choice { value: SettingValue::Int(i64::from(*m)), label: if *m == 0 { tr.text("set-reminders-before-none", None) } else { crate::reminders::lead_text(tr, *m) } }).collect();
572 b.push("reminders.events", "reminders-events", Kind::Bool, SettingValue::Bool(config.reminders.events));
573 let s = b.push("reminders.asked_days", "reminders-asked", Kind::Int, SettingValue::Int(i64::from(config.reminders.asked_days)));
574 Builder::range(s, 0.0, 10.0, 1.0, "");
575 b.push("reminders.waits", "reminders-waits", Kind::Bool, SettingValue::Bool(config.reminders.waits));
576 let s = b.push("reminders.payment_days", "reminders-payment", Kind::Int, SettingValue::Int(i64::from(config.reminders.payment_days)));
577 Builder::range(s, 0.0, 10.0, 1.0, "");
578 b.push("reminders_closed", "reminders-closed", Kind::Bool, SettingValue::Bool(false));
579 b.push("reminders.gathered", "reminders-gathered", Kind::Words, SettingValue::Texts(config.reminders.gathered_times()));
581 b.section = "pauses".into();
583 b.group = tr.text("set-free-time-group", None);
584 b.push("free_time.moves", "free-moves", Kind::Bool, SettingValue::Bool(config.free_time.moves));
585 let s = b.push("free_time.latest_after", "free-latest", Kind::Int, SettingValue::Int(i64::from(config.free_time.latest_after)));
586 Builder::range(s, 30.0, 360.0, 15.0, "min");
587 b.push("free_time.movement", "free-movement", Kind::Bool, SettingValue::Bool(config.free_time.movement(&config.planning)));
588 b.group = tr.text("set-pause-group", None);
589 b.note(tr.text("set-pause-about", None), Vec::new());
591 b.push("link.attention.pause", "pause-attention", Kind::Link, SettingValue::Text("settings:attention.pause".into()));
593 b.push("pause.helps", "pause-helps", Kind::Words, SettingValue::Texts(config.pause.helps.clone()));
594 b.push("pause.grounding", "pause-grounding", Kind::Text, SettingValue::Text(config.pause.grounding.clone()));
595 b.push("pause.breathing", "pause-breathing", Kind::Bool, SettingValue::Bool(config.pause.breathing));
596 let s = b.push("pause.pace", "pause-pace", Kind::Int, SettingValue::Int(i64::from(config.pause.pace())));
597 Builder::range(s, 3.0, 10.0, 1.0, "");
598 let s = b.push("pause.after", "pause-after", Kind::Choice, SettingValue::Text(config.pause.after().id().to_string()));
599 s.choices = [("lighter", "set-pause-after-lighter"), ("rest", "set-pause-after-rest"), ("as-is", "set-pause-after-as-is")].iter().map(|(v, l)| Choice { value: SettingValue::Text(v.to_string()), label: tr.text(l, None) }).collect();
600 let s = b.push("pause.country", "pause-country", Kind::Choice, SettingValue::Text(config.pause.country.clone().unwrap_or_default()));
602 let mut countries: Vec<Choice> = crate::pause::CrisisLines::built_in()
603 .country
604 .iter()
605 .map(|c| Choice { value: SettingValue::Text(c.code.clone()), label: tr.text(&format!("set-pause-country-{}", c.code.to_ascii_lowercase()), None) })
606 .collect();
607 countries.sort_by_cached_key(|c| crate::text::fold(&c.label).into_iter().collect::<String>());
608 s.choices = std::iter::once(Choice { value: SettingValue::Text(String::new()), label: tr.text("set-pause-country-usual", None) }).chain(countries).collect();
609 b.section = "invoices".into();
611 b.group = tr.text("set-invoice-group", None);
612 let invoice = &config.invoice;
613 for (field, kind, value) in [
614 ("name", Kind::Text, invoice.name.clone()),
615 ("address", Kind::Long, invoice.address.clone()),
616 ("siret", Kind::Text, invoice.siret.clone()),
617 ("vat", Kind::Text, invoice.vat.clone()),
618 ("prefix", Kind::Text, invoice.prefix.clone()),
619 ("currency", Kind::Text, if invoice.currency.is_empty() { "EUR".into() } else { invoice.currency.clone() }),
620 ("payment", Kind::Long, invoice.payment.clone()),
621 ] {
622 b.push(&format!("invoice.{field}"), &format!("invoice-{field}"), kind, SettingValue::Text(value));
623 }
624 b.push("invoice.folder", "invoice-folder", Kind::Folder, SettingValue::Text(invoice.folder.clone()));
625 let s = b.push("invoice.rate", "invoice-rate", Kind::Float, SettingValue::Float(invoice.rate));
626 Builder::range(s, 0.0, 2000.0, 5.0, "/h");
627 b.section = "ai".into();
630 b.group = tr.text("set-ai-agents-group", None);
631 b.note(tr.text("set-ai-agents-note", None), Vec::new());
632 b.push("mcp.outside_projects", "mcp-outside-projects", Kind::Bool, SettingValue::Bool(config.mcp.outside_projects));
633 b.push("mcp.texts", "mcp-texts", Kind::Bool, SettingValue::Bool(config.mcp.texts));
634 b.push("mcp.spam", "mcp-spam", Kind::Bool, SettingValue::Bool(config.mcp.spam));
635 if config.every_account().any(|a| a.is_microsoft_work()) {
637 b.push("mcp.work_accounts", "mcp-work-accounts", Kind::Bool, SettingValue::Bool(config.mcp.work_accounts));
638 }
639 b.group = tr.text("set-ai-projects-group", None);
640 let projects = config.notes_root_path().and_then(|root| crate::projects::ProjectStore::load(&root).ok()).map(|store| store.projects).unwrap_or_default();
641 b.note(tr.text(if projects.is_empty() { "set-ai-projects-none" } else { "set-ai-projects-note" }, None), Vec::new());
642 for project in projects {
643 b.said(&format!("project.{}.ai", project.id), Kind::Bool, project.title.clone(), String::new(), SettingValue::Bool(project.ai));
644 }
645 }
646 _ => {}
647 }
648 b.out
649}
650
651pub const WORD_LINES: &[(&str, &str, &[&str])] = &[
658 ("mail", "codes", &["codes.code", "codes.password", "codes.reset", "codes.link", "codes.confirm", "codes.not_yours", "codes.promo"]),
659 ("mail", "senders", &["senders.automatic"]),
660 ("mail", "replies", &["replies.reply", "replies.forward", "quotes.openings", "quotes.wrote"]),
661 ("mail", "folders", &["folders.sent", "folders.drafts", "folders.junk", "folders.trash", "folders.archive"]),
662 ("mail", "brands", &["brands.brands", "brands.everyday", "brands.shared"]),
663 ("money", "payments", &["payments.bill", "payments.paid", "payments.received", "payments.order", "payments.refund", "payments.not_payments"]),
664 ("money", "bank", &["bank.filler", "accounts.between"]),
665 (
666 "money",
667 "letters",
668 &[
669 "letters.senders",
670 "letters.kinds.formal_notice",
671 "letters.kinds.tax_notice",
672 "letters.kinds.decision",
673 "letters.kinds.reminder",
674 "letters.kinds.appointment",
675 "letters.kinds.bill",
676 "letters.kinds.acknowledgment",
677 "letters.kinds.attestation",
678 "letters.kinds.contract",
679 ],
680 ),
681 (
682 "money",
683 "papers",
684 &[
685 "papers.kinds.passport",
686 "papers.kinds.identity",
687 "papers.kinds.residence",
688 "papers.kinds.driving",
689 "papers.kinds.health_cover",
690 "papers.kinds.health_card",
691 "papers.kinds.tax_notice",
692 "papers.kinds.rent_receipt",
693 "papers.kinds.bank_details",
694 "papers.kinds.payslip",
695 "papers.kinds.warranty",
696 "papers.kinds.insurance",
697 "papers.kinds.certificate",
698 ],
699 ),
700 (
701 "money",
702 "contracts",
703 &[
704 "contracts.kinds.rent",
705 "contracts.kinds.energy",
706 "contracts.kinds.health",
707 "contracts.kinds.insurance",
708 "contracts.kinds.telecom",
709 "contracts.kinds.hosting",
710 "contracts.kinds.subscription",
711 "contracts.kinds.bank",
712 ],
713 ),
714 ("phone", "approvals", &["approvals.phrases", "approvals.asks", "approvals.asked_about", "approvals.channels"]),
715 ("phone", "calls", &["calls.ringing", "calls.missed"]),
716 ("phone", "voicemail", &["voicemail.operators"]),
717 (
718 "tasks",
719 "tasks",
720 &[
721 "tasks.optional",
722 "tasks.joy",
723 "tasks.movement",
724 "capture.kind_verbs.call",
725 "capture.kind_verbs.write",
726 "capture.kind_verbs.online",
727 "capture.kind_verbs.out",
728 "capture.kind_verbs.read",
729 "capture.kind_verbs.think",
730 "capture.kind_verbs.make",
731 "capture.kind_words.call",
732 "capture.kind_words.write",
733 "capture.kind_words.online",
734 "capture.kind_words.out",
735 "capture.kind_words.read",
736 "capture.kind_words.think",
737 "capture.kind_words.make",
738 ],
739 ),
740 ("tasks", "spam", &["spam.provider_tags"]),
741 ("tasks", "shield", &["shield.threats", "shield.insults", "shield.rude", "shield.topics.work", "shield.topics.support", "shield.topics.press", "shield.topics.thanks", "shield.topics.donation"]),
742];
743
744fn word_list(path: &str) -> bool {
746 WORD_LINES.iter().any(|(_, _, lists)| lists.contains(&path))
747}
748
749fn named_chips(entries: &[(String, Vec<String>)]) -> Vec<String> {
751 entries.iter().map(|(name, words)| if words.is_empty() { name.clone() } else { format!("{name}: {}", words.join(", ")) }).collect()
752}
753
754fn named_entries(path: &str, chips: &[String]) -> Result<Vec<(String, Vec<String>)>, String> {
759 let mut out = Vec::new();
760 for chip in chips {
761 let (name, words) = match chip.split_once(':') {
762 Some((name, words)) => (name.trim().to_string(), words.split(',').map(|w| w.trim().to_string()).filter(|w| !w.is_empty()).collect::<Vec<_>>()),
763 None => (chip.trim().to_string(), Vec::new()),
764 };
765 if name.is_empty() {
766 continue;
767 }
768 let words = match (words.is_empty(), path) {
769 (true, "brands.brands") => return Err(format!("{name}: write its domains after a colon (\"{name}: example.org\")")),
770 (true, _) => vec![crate::words::folded(&name)],
771 (false, _) => words,
772 };
773 out.push((name, words));
774 }
775 Ok(out)
776}
777
778fn names_at(tree: &crate::words::Node, path: &str) -> Vec<(String, Vec<String>)> {
780 match tree.get(path) {
781 Some(crate::words::Node::Table(entries)) => entries
782 .iter()
783 .filter_map(|(name, node)| match node {
784 crate::words::Node::List(words) => Some((name.clone(), words.clone())),
785 crate::words::Node::Table(_) => None,
786 })
787 .collect(),
788 _ => Vec::new(),
789 }
790}
791
792fn and_list(tr: &Translator, names: &[String]) -> String {
794 let and = tr.text("word-and", None);
795 match names {
796 [] => String::new(),
797 [one] => one.clone(),
798 [first @ .., last] => format!("{} {and} {last}", first.join(", ")),
799 }
800}
801
802fn words_rows(b: &mut Builder, config: &Config) {
810 let tr = b.tr;
811 let words = crate::words::Words::of(config);
812 let compared = crate::words::compared(config);
813 b.group = String::new();
814 let s = b.push("words.languages", "words-languages", Kind::Picks, SettingValue::Texts(words.languages.clone()));
815 s.choices = crate::words::available_languages().iter().map(|code| Choice { value: SettingValue::Text(code.to_string()), label: tr.text(&format!("words-language-{code}"), None) }).collect();
816 let s = b.push("words.countries", "words-countries", Kind::Picks, SettingValue::Texts(words.countries.clone()));
817 s.choices = crate::words::available_countries()
818 .iter()
819 .map(|code| Choice { value: SettingValue::Text(code.to_string()), label: tr.text(&format!("country-{}", code.to_ascii_lowercase()), None) })
820 .collect();
821 let languages: Vec<String> = words.languages.iter().map(|code| tr.text(&format!("words-language-{code}-in"), None)).collect();
822 let languages = and_list(tr, &languages);
823 let same = |a: &str, b: &str| crate::words::folded(a) == crate::words::folded(b);
824 for (family, line, lists) in WORD_LINES {
825 b.group = tr.text(&format!("words-line-{line}"), None);
826 let (mut count, mut added, mut removed) = (0usize, 0usize, 0usize);
827 let mut rows: Vec<(&str, Vec<String>, bool)> = Vec::new();
828 for path in lists.iter().copied() {
829 if crate::words::MAPS.contains(&path) {
830 let now = names_at(&compared.yours, path);
831 let before = names_at(&compared.shipped, path);
832 count += now.len();
833 for (name, words) in &now {
834 match before.iter().find(|(n, _)| same(n, name)) {
835 None => added += 1,
836 Some((_, shipped)) => {
837 added += usize::from(words.iter().any(|w| !shipped.iter().any(|s| same(s, w))));
838 removed += usize::from(shipped.iter().any(|s| !words.iter().any(|w| same(s, w))));
839 }
840 }
841 }
842 removed += before.iter().filter(|(n, _)| !now.iter().any(|(name, _)| same(n, name))).count();
843 let (yours, shipped): (Vec<(String, Vec<String>)>, Vec<(String, Vec<String>)>) = now.into_iter().partition(|(name, _)| !before.iter().any(|(n, _)| same(n, name)));
845 rows.push((path, named_chips(&yours.into_iter().chain(shipped).collect::<Vec<_>>()), true));
846 } else {
847 let now = if path == "senders.automatic" { words.senders.automatic.clone() } else { compared.yours.list(path).to_vec() };
849 let before = compared.shipped.list(path);
850 count += now.len();
851 added += now.iter().filter(|w| !before.iter().any(|s| same(s, w))).count();
852 removed += before.iter().filter(|s| !now.iter().any(|w| same(s, w))).count();
853 let (yours, shipped): (Vec<String>, Vec<String>) = now.into_iter().partition(|w| !before.iter().any(|s| same(s, w)));
855 rows.push((path, yours.into_iter().chain(shipped).collect(), false));
856 }
857 }
858 let mut args = tr.counted(count);
859 args.set("languages", languages.clone());
860 let mut said = vec![tr.text("words-line-count", Some(&args))];
861 if added > 0 {
862 said.push(tr.text("words-line-added", Some(&tr.counted(added))));
863 }
864 if removed > 0 {
865 said.push(tr.text("words-line-removed", Some(&tr.counted(removed))));
866 }
867 let about = format!("words.about.{line}");
868 let help = tr.text(&format!("words-line-{line}-help"), None);
869 let family = tr.text(&format!("words-family-{family}"), None);
870 let s = b.said(&about, Kind::Note, help, said.join(" · "), SettingValue::Text(String::new()));
871 s.unit = family;
872 for (path, now, named) in rows {
873 let label = tr.text(&format!("words-list-{}", path.replace(['.', '_'], "-")), None);
874 let help = if named { tr.text(&format!("words-named-{}", path.replace(['.', '_'], "-")), None) } else { String::new() };
875 b.said(&format!("words.{path}"), Kind::Words, label, help, SettingValue::Texts(now));
876 }
877 }
878}
879
880fn phone_regions(tr: &Translator) -> Vec<Choice> {
883 let name = |code: &str| tr.text(&format!("country-{}", code.to_ascii_lowercase()), None);
884 let usual = match crate::phones::usual_region(&tr.text("qt-locale", None)) {
885 Some(region) => {
886 let mut args = crate::i18n::args();
887 args.set("country", name(region.code));
888 tr.text("set-phone-region-usual", Some(&args))
889 }
890 None => tr.text("set-phone-region-none", None),
891 };
892 let mut countries: Vec<Choice> = crate::phones::REGIONS.iter().map(|r| Choice { value: SettingValue::Text(r.code.to_string()), label: name(r.code) }).collect();
893 countries.sort_by_cached_key(|c| crate::text::fold(&c.label).into_iter().collect::<String>());
894 std::iter::once(Choice { value: SettingValue::Text(String::new()), label: usual }).chain(countries).collect()
895}
896
897fn reading(b: &mut Builder, config: &Config) {
898 b.push("reading.family", "reading-family", Kind::Font, SettingValue::Text(config.reading.family.clone()));
899 let s = b.push("reading.size", "reading-size", Kind::Int, SettingValue::Int(i64::from(config.reading.size)));
900 Builder::range(s, 10.0, 30.0, 1.0, "px");
901 let s = b.push("reading.spacing", "reading-spacing", Kind::Float, SettingValue::Float(config.reading.spacing));
902 Builder::range(s, 1.0, 2.4, 0.1, "×");
903}
904
905fn raw_account_history(config: &Config, id: &str) -> Option<u32> {
907 let account = config.accounts.iter().find(|a| a.id == id)?;
908 account.history_weeks.filter(|w| Some(*w) != config.history_weeks)
909}
910
911pub fn categories(tr: &Translator, names: Vec<String>) -> Setting {
915 let mut b = Builder { tr, out: Vec::new(), group: String::new(), section: String::new() };
916 b.push("task_categories", "task-categories", Kind::Categories, SettingValue::Named(names.into_iter().map(|n| crate::config::NamedValue { id: n.clone(), label: n, ..Default::default() }).collect()));
917 b.out.remove(0)
918}
919
920pub fn rebased<T: PartialEq + Clone>(shown: &[T], value: &[T], current: &[T]) -> Vec<T> {
926 if shown == current {
927 return value.to_vec();
928 }
929 let added: Vec<&T> = value.iter().filter(|v| !shown.contains(v)).collect();
930 let removed: Vec<&T> = shown.iter().filter(|s| !value.contains(s)).collect();
931 let mut out: Vec<T> = value.iter().filter(|v| added.contains(v) || current.contains(v)).cloned().collect();
932 for there in current {
933 if !out.contains(there) && !removed.contains(&there) {
934 out.push(there.clone());
935 }
936 }
937 out
938}
939
940pub fn rebased_by<T: PartialEq + Clone>(shown: &[T], value: &[T], current: &[T], ids: impl Fn(&[T]) -> Vec<Option<String>>, real: bool) -> Vec<T> {
952 if shown == current {
953 return value.to_vec();
954 }
955 let (shown_ids, value_ids, current_ids) = (ids(shown), ids(value), ids(current));
956 let mut current = current.to_vec();
957 for (at_shown, old) in shown.iter().enumerate() {
958 if value.contains(old) {
959 continue;
960 }
961 let identity = shown_ids.get(at_shown).cloned().flatten();
962 let edit = match &identity {
964 Some(key) => value.iter().zip(&value_ids).find(|(v, k)| !shown.contains(v) && k.as_ref() == Some(key)).map(|(v, _)| v),
965 None => value.get(at_shown).filter(|v| shown.len() == value.len() && !shown.contains(v) && value_ids.get(at_shown).is_some_and(Option::is_none)),
966 };
967 let Some(edit) = edit else { continue };
968 let by_value = || current.iter().position(|c| c == old);
970 let at = match &identity {
971 Some(key) if real => current_ids.iter().position(|k| k.as_ref() == Some(key)),
972 Some(key) => current_ids.iter().position(|k| k.as_ref() == Some(key)).filter(|at| current.get(*at) == Some(old)).or_else(by_value),
973 None => (current.get(at_shown) == Some(old)).then_some(at_shown).or_else(by_value).or_else(|| (current.len() == shown.len()).then_some(at_shown)),
974 };
975 if let Some(at) = at {
976 current[at] = edit.clone();
977 }
978 }
979 rebased(shown, value, ¤t)
980}
981
982fn window_ids(windows: &[crate::config::WindowValue]) -> Vec<Option<String>> {
985 let mut seen: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
986 windows
987 .iter()
988 .map(|w| {
989 let rank = seen.entry(w.day.clone()).or_default();
990 *rank += 1;
991 Some(format!("{}#{}", w.day, *rank - 1))
992 })
993 .collect()
994}
995
996const VIEWS: &[&str] = &["parameters", "porch", "mail", "senders", "accounts", "agenda", "tasks", "notes", "reading", "sites", "contacts", "lane:people"];
998
999pub fn change(config_path: &Path, key: &str, shown: Option<&SettingValue>, value: &SettingValue) -> Result<(), String> {
1008 if key.starts_with("attention.") || key.starts_with("notify.") {
1010 return crate::attention::apply_change(config_path, key, shown, value);
1011 }
1012 let config = || if config_path.exists() { Config::load(config_path) } else { Ok(Config::default()) };
1013 let kept_in = match key {
1014 "known" | "safe" | "neutral" | "restricted" | "blocked" => {
1015 let config = config()?;
1016 match key {
1017 "known" => config.known_senders_path(),
1018 "safe" => config.safe_senders_path(),
1019 "neutral" => config.neutral_senders_path(),
1020 "restricted" => config.restricted_senders_path(),
1021 _ => config.blocked_senders_path(),
1022 }
1023 }
1024 _ if key.starts_with("project.") => config()?.notes_root_path().map_or_else(|| config_path.to_path_buf(), |root| crate::projects::file_in(&root)),
1025 _ => config_path.to_path_buf(),
1026 };
1027 crate::filelock::with_lock(&kept_in, || {
1028 let fresh = config()?;
1029 let value = match shown {
1030 Some(shown) => current_value(&fresh, key).map_or_else(|| value.clone(), |current| rebase(shown, value, ¤t)),
1031 None => value.clone(),
1032 };
1033 apply(config_path, &fresh, key, &value)
1034 })
1035}
1036
1037fn current_value(config: &Config, key: &str) -> Option<SettingValue> {
1039 let tr = crate::i18n::Translator::new("en");
1040 let store = config.notes_root_path().and_then(|root| crate::projects::ProjectStore::load(&root).ok());
1041 let own = key.strip_prefix("project.").and_then(|k| k.strip_suffix(".routes")).map(|id| format!("project:{id}")).or_else(|| key.strip_prefix("account.").and_then(|k| k.split('.').next()).map(|id| format!("account:{id}")));
1042 let views: Vec<String> = own.into_iter().chain(VIEWS.iter().map(|v| (*v).to_string())).collect();
1043 views.iter().find_map(|view| for_view(view, config, &tr, &[], store.as_ref()).into_iter().find(|row| row.key == key).map(|row| row.value))
1044}
1045
1046pub fn rebase(shown: &SettingValue, value: &SettingValue, current: &SettingValue) -> SettingValue {
1052 use SettingValue as V;
1053 let like = |v: &SettingValue, other: &SettingValue| match (v, other) {
1055 (V::Ints(none), V::Texts(_)) if none.is_empty() => V::Texts(Vec::new()),
1056 (V::Ints(none), V::Windows(_)) if none.is_empty() => V::Windows(Vec::new()),
1057 (V::Ints(none), V::TimeOff(_)) if none.is_empty() => V::TimeOff(Vec::new()),
1058 (V::Ints(none), V::Routes(_)) if none.is_empty() => V::Routes(Vec::new()),
1059 _ => v.clone(),
1060 };
1061 let (shown, value) = (like(shown, current), like(value, current));
1062 match (&shown, &value, current) {
1063 (V::Texts(s), V::Texts(v), V::Texts(c)) => V::Texts(rebased(s, v, c)),
1064 (V::Ints(s), V::Ints(v), V::Ints(c)) => V::Ints(rebased(s, v, c)),
1065 (V::Windows(s), V::Windows(v), V::Windows(c)) => V::Windows(rebased_by(s, v, c, window_ids, false)),
1066 (V::TimeOff(s), V::TimeOff(v), V::TimeOff(c)) => V::TimeOff(rebased_by(s, v, c, |days| days.iter().map(|d| Some(d.id.clone()).filter(|id| !id.is_empty())).collect(), true)),
1067 (V::Routes(s), V::Routes(v), V::Routes(c)) => V::Routes(rebased_by(s, v, c, |routes| vec![None; routes.len()], false)),
1068 _ => value,
1069 }
1070}
1071
1072pub fn apply(config_path: &Path, config: &Config, key: &str, value: &SettingValue) -> Result<(), String> {
1073 let none = matches!(value, SettingValue::Ints(list) if list.is_empty());
1076 match key {
1077 "words.languages" | "words.countries" => {
1081 let values: &[String] = match value {
1082 SettingValue::Texts(values) => values,
1083 _ if none => &[],
1084 _ => return Err(format!("{key}: a list expected")),
1085 };
1086 if key == "words.languages" && values.is_empty() {
1087 return Err(format!("{key}: Sioul reads at least one language"));
1088 }
1089 crate::words::write_setting(config_path, &key["words.".len()..], values)
1090 }
1091 _ if key.starts_with("words.reset.") => {
1092 let line = &key["words.reset.".len()..];
1093 let (_, _, lists) = WORD_LINES.iter().find(|(_, id, _)| *id == line).ok_or_else(|| format!("{key}: no such line"))?;
1094 if lists.contains(&"senders.automatic") && config.filed_words.is_some() {
1095 set_value(config_path, "filed_words", &SettingValue::Text(String::new()))?;
1096 }
1097 crate::words::reset_lists(config_path, lists)
1098 }
1099 _ if key == "filed_words" || key.strip_prefix("words.").is_some_and(word_list) => {
1102 let path = if key == "filed_words" { "senders.automatic" } else { &key["words.".len()..] };
1103 let wanted: &[String] = match value {
1104 SettingValue::Texts(values) => values,
1105 _ if none => &[],
1106 _ => return Err(format!("{key}: a list expected")),
1107 };
1108 if path == "senders.automatic" && config.filed_words.is_some() {
1110 set_value(config_path, "filed_words", &SettingValue::Text(String::new()))?;
1111 }
1112 let shipped = crate::words::compared(config).shipped;
1113 if crate::words::MAPS.contains(&path) {
1114 crate::words::write_names(config_path, path, &named_entries(path, wanted)?, &names_at(&shipped, path))
1115 } else {
1116 crate::words::write_list(config_path, path, wanted, shipped.list(path))
1117 }
1118 }
1119 "ai_key" | "github_token" => Err(format!("{key}: kept in the system keyring, never in the configuration")),
1121 "tasks.kind" => {
1122 let SettingValue::Rename(change) = value else { return Err(format!("{key}: a change expected")) };
1123 crate::config::change_kind(config_path, &change.from, &change.to)
1124 }
1125 "collections" => {
1127 let SettingValue::Rename(change) = value else { return Err(format!("{key}: a change expected")) };
1128 let mut parts = change.from.splitn(3, '/');
1129 let (Some(kind), Some(account), Some(id)) = (parts.next(), parts.next(), parts.next()) else { return Err(format!("{}: unknown", change.from)) };
1130 let kind = if kind == "contacts" { crate::vdir::Kind::Contacts } else { crate::vdir::Kind::Calendars };
1131 let collection = crate::vdir::collections(kind).into_iter().find(|c| c.account == account && c.id == id).ok_or_else(|| format!("{}: unknown", change.from))?;
1132 if change.to.trim().is_empty() { crate::vdir::delete_collection(&collection) } else { crate::vdir::rename_collection(&collection, &change.to) }
1133 }
1134 _ if key.starts_with(crate::porch::CATEGORY) => {
1136 let name = crate::porch::category_of(key).ok_or_else(|| format!("{key}: no category"))?;
1137 let standing = match value {
1138 SettingValue::Text(text) if text.is_empty() => None,
1139 SettingValue::Text(text) => Some(crate::porch::Standing::read(text).ok_or_else(|| format!("{text}: safe, neutral, restricted or blocked"))?),
1140 _ => return Err(format!("{key}: a list expected")),
1141 };
1142 crate::porch::set_category(config, name, standing)
1143 }
1144 "known" | "safe" | "neutral" | "restricted" | "blocked" => {
1145 let path = match key {
1146 "known" => config.known_senders_path(),
1147 "safe" => config.safe_senders_path(),
1148 "neutral" => config.neutral_senders_path(),
1149 "restricted" => config.restricted_senders_path(),
1150 _ => config.blocked_senders_path(),
1151 };
1152 let wanted: &[String] = match value {
1153 SettingValue::Texts(wanted) => wanted,
1154 _ if none => &[],
1155 _ => return Err(format!("{key}: a list expected")),
1156 };
1157 let standing = crate::porch::Standing::read(key);
1159 if let Some(standing) = standing {
1160 for name in wanted.iter().filter_map(|e| crate::porch::category_of(e)) {
1161 crate::porch::set_category(config, name, Some(standing))?;
1162 }
1163 }
1164 use crate::porch::Entry;
1167 let region = crate::reach::region(config);
1168 let read = |e: &String| Entry::read(e, region).filter(|e| matches!(e, Entry::Address(_)) || (standing.is_some() && matches!(e, Entry::Number(_))));
1169 let wanted: Vec<Entry> = wanted.iter().filter_map(read).collect();
1170 let list = SenderList::load(&path);
1171 let current: Vec<Entry> = list.entries().iter().chain(list.numbers()).filter_map(read).collect();
1172 for gone in current.iter().filter(|e| !wanted.iter().any(|w| w.same(e))) {
1173 SenderList::remove_entry(&path, gone, region)?;
1174 }
1175 for new in wanted.iter().filter(|e| !current.iter().any(|c| c.same(e))) {
1176 match standing {
1178 Some(standing) => crate::porch::set_standing(config, &new.line(), standing)?,
1179 None => SenderList::add_entry(&path, new, region)?,
1180 }
1181 }
1182 Ok(())
1183 }
1184 _ if key.starts_with(crate::porch::CONTACT) => {
1187 let uid = crate::porch::card_of(key).ok_or_else(|| format!("{key}: no card"))?;
1188 let standing = match value {
1189 SettingValue::Text(text) if text.is_empty() => None,
1190 SettingValue::Text(text) => Some(crate::porch::Standing::read(text).ok_or_else(|| format!("{text}: safe, neutral, restricted or blocked"))?),
1191 _ => return Err(format!("{key}: a list expected")),
1192 };
1193 let card = crate::contacts::all().into_iter().find(|c| c.uid.trim() == uid);
1194 let addresses: Vec<String> = card.iter().flat_map(|c| c.emails.iter().map(|e| e.value.clone())).collect();
1195 let numbers: Vec<String> = card.iter().flat_map(|c| c.phones.iter().map(|p| p.value.clone())).collect();
1196 crate::porch::set_person(config, uid, &addresses, &numbers, standing)
1197 }
1198 "porch.projects" => {
1200 let shown: &[String] = match value {
1201 SettingValue::Texts(shown) => shown,
1202 _ if none => &[],
1203 _ => return Err(format!("{key}: a list expected")),
1204 };
1205 let root = config.notes_root_path().ok_or_else(|| format!("{key}: no notes folder"))?;
1206 let store = crate::projects::ProjectStore::load(&root).map_err(|e| e.to_string())?;
1207 let hidden: Vec<String> = store.projects.iter().map(|c| c.id.clone()).filter(|id| !shown.contains(id)).collect();
1208 set_value(config_path, "porch.hidden_projects", &SettingValue::Texts(hidden))
1209 }
1210 "projects.rename" => {
1212 let root = config.notes_root_path().ok_or_else(|| format!("{key}: no notes folder"))?;
1213 crate::projects::rename_file(&root).map(|_| ())
1214 }
1215 _ if key.strip_prefix("project.").and_then(|rest| rest.strip_suffix(".ai")).is_some_and(|id| !id.is_empty()) => {
1217 let id = key.strip_prefix("project.").and_then(|rest| rest.strip_suffix(".ai")).unwrap_or_default();
1218 let SettingValue::Bool(open) = value else { return Err(format!("{key}: on or off expected")) };
1219 let root = config.notes_root_path().ok_or_else(|| format!("{key}: no notes folder"))?;
1220 crate::projects::set_ai(&crate::projects::file_in(&root), id, *open)
1221 }
1222 _ if key.starts_with("project.") && key.ends_with(".routes") => {
1223 let id = key.trim_start_matches("project.").trim_end_matches(".routes");
1224 let routes: &[crate::projects::RouteValue] = match value {
1225 SettingValue::Routes(routes) => routes,
1226 _ if none => &[],
1227 _ => return Err(format!("{key}: routes expected")),
1228 };
1229 let root = config.notes_root_path().ok_or_else(|| format!("{key}: no notes folder"))?;
1230 crate::projects::set_routes(&crate::projects::file_in(&root), id, routes)
1231 }
1232 _ if key.starts_with("attention.") || key.starts_with("notify.") || key.starts_with("reach.") => crate::attention::apply(config_path, config, key, value),
1235 _ if key.starts_with("spam.actions.") => {
1239 use crate::spam::{Action, Class};
1240 let class = Class::read(&key["spam.actions.".len()..]).ok_or_else(|| format!("{key}: spam, unsure or ham"))?;
1241 let action = match value {
1242 SettingValue::Text(action) => Action::read(action),
1243 _ => None,
1244 }
1245 .ok_or_else(|| format!("{key}: move, flag or nothing"))?;
1246 let actions = config.spam.actions().with(class, action);
1247 for class in Class::ALL {
1248 set_value(config_path, &format!("spam.action_{}", class.as_str()), &SettingValue::Text(actions.of(class).as_str().into()))?;
1249 }
1250 set_value(config_path, "spam.mode", &SettingValue::Text(String::new()))
1251 }
1252 "spam.threshold_spam" | "spam.threshold_unsure" => {
1253 let wanted = match value {
1254 SettingValue::Float(f) => *f,
1255 SettingValue::Int(n) => *n as f64,
1256 _ => return Err(format!("{key}: a number between 0 and 1 expected")),
1257 };
1258 if !(wanted.is_finite() && wanted > 0.0 && wanted <= 1.0) {
1259 return Err(format!("{key}: {wanted} is not between 0 and 1"));
1260 }
1261 let (spam, unsure) = config.spam.thresholds();
1262 let (spam, unsure) = if key == "spam.threshold_spam" { (wanted, f64::from(unsure)) } else { (f64::from(spam), wanted) };
1263 if unsure >= spam {
1264 return Err(format!("{key}: unsure ({unsure:.2}) must stay below spam ({spam:.2})"));
1265 }
1266 set_value(config_path, key, &SettingValue::Float(wanted))
1267 }
1268 _ if key.starts_with("screening.") => crate::screening::set(config_path, key, value),
1270 _ if key.starts_with("account.") && key.ends_with(".history_weeks") && *value == SettingValue::Int(-1) => set_value(config_path, key, &SettingValue::Text(String::new())),
1272 _ if key.starts_with("account.") && key.ends_with(".fetch_minutes") && *value == SettingValue::Int(0) => set_value(config_path, key, &SettingValue::Text(String::new())),
1273 _ => set_value(config_path, key, value),
1274 }
1275}
1276
1277#[cfg(test)]
1278mod tests {
1279 use super::*;
1280
1281 #[test]
1284 fn the_time_between_events_is_yours_and_shown() {
1285 let config = Config::default();
1286 assert_eq!(config.agenda.between(), 0, "none unless set");
1287 let rows = for_view("agenda", &config, &Translator::new("en"), &[], None);
1288 let row = rows.iter().find(|s| s.key == "agenda.between").expect("shown in Settings ▸ Agenda");
1289 assert_eq!(row.value, SettingValue::Int(0));
1290 let file = std::env::temp_dir().join(format!("sioul-agenda-between-{}.toml", std::process::id()));
1291 apply(&file, &config, "agenda.between", &SettingValue::Int(30)).unwrap();
1292 assert_eq!(Config::load(&file).unwrap().agenda.between(), 30);
1293 let _ = std::fs::remove_file(&file);
1294 }
1295
1296 #[test]
1300 fn new_events_go_where_the_agenda_says() {
1301 let account = "agenda-default";
1302 crate::vdir::prepare(crate::vdir::Kind::Calendars, account, "personnel", "Personnel", None).unwrap();
1303 crate::vdir::prepare(crate::vdir::Kind::Calendars, account, "birthdays", "Birthdays", None).unwrap();
1304 let path = crate::vdir::state_path(account, crate::vdir::Kind::Calendars, "birthdays");
1305 let mut state = crate::vdir::State::load(&path);
1306 state.read_only = true;
1307 state.save(&path).unwrap();
1308 let config = Config::default();
1309 let tr = Translator::new("en");
1310 let rows = for_view("agenda", &config, &tr, &[], None);
1311 let choice = rows.iter().find(|s| s.key == "agenda.calendar").expect("the setting");
1312 assert!(choice.choices.iter().any(|c| c.value == SettingValue::Text(format!("{account}/personnel"))));
1313 assert!(!choice.choices.iter().any(|c| c.value == SettingValue::Text(format!("{account}/birthdays"))), "read only: not offered");
1314 let file = std::env::temp_dir().join(format!("sioul-agenda-default-{}.toml", std::process::id()));
1315 apply(&file, &config, "agenda.calendar", &SettingValue::Text(format!("{account}/personnel"))).unwrap();
1316 assert_eq!(Config::load(&file).unwrap().agenda.calendar.as_deref(), Some(format!("{account}/personnel").as_str()));
1317 assert_eq!(crate::agenda::default_calendar_of(Some(&format!("{account}/personnel"))).map(|c| c.id).as_deref(), Some("personnel"));
1318 assert!(crate::agenda::default_calendar_of(Some(&format!("{account}/birthdays"))).is_some_and(|c| c.id != "birthdays"));
1319 let _ = std::fs::remove_file(&file);
1320 let _ = std::fs::remove_dir_all(crate::vdir::Kind::Calendars.root().join(account));
1321 }
1322
1323 #[test]
1327 fn microsoft_work_accounts_keep_their_mail_from_ais() {
1328 let config: Config = toml::from_str(
1329 "[[account]]\nid = \"home\"\nkind = \"imap\"\naddress = \"you@outlook.com\"\nauth = \"microsoft\"\nmicrosoft_kind = \"personal\"\nsmtp_host = \"smtp-mail.outlook.com\"\nshield = true\nshield_ai = true\n\
1330 [[account]]\nid = \"work\"\nkind = \"imap\"\naddress = \"you@contoso.example\"\nauth = \"microsoft\"\nmicrosoft_kind = \"work\"\nsmtp_host = \"smtp.office365.com\"\nshield = true\nshield_ai = true\n",
1331 )
1332 .unwrap();
1333 let tr = Translator::new("en");
1334 let rows = |view: &str| for_view(view, &config, &tr, &[], None);
1335 assert!(rows("account:home").iter().any(|s| s.key == "account.home.shield_ai"));
1336 let work = rows("account:work");
1337 assert!(!work.iter().any(|s| s.key == "account.work.shield_ai"), "no AI to allow on a work account");
1338 assert!(work.iter().any(|s| s.kind == Kind::Note && s.label == tr.text("set-account-shield-ai-work", None)));
1339 assert!(config.account("home").unwrap().ai_reads() && !config.account("work").unwrap().ai_reads(), "whatever the file says");
1340 let agents = rows("parameters");
1341 let switch = agents.iter().find(|s| s.key == "mcp.work_accounts").expect("shown once there is a work account");
1342 assert_eq!(switch.value, SettingValue::Bool(false), "closed unless opened");
1343 assert!(!switch.help.is_empty() && !switch.help.starts_with("set-"));
1344 }
1345
1346 #[test]
1347 fn each_page_has_its_own() {
1348 let config: Config = toml::from_str("case_store = \"~/Notes\"\n[[account]]\nid = \"a\"\nkind = \"imap\"\naddress = \"jane@example.org\"\nshield = true\nshield_ai = true\n").unwrap();
1349 let tr = Translator::new("en");
1350 let keys = |view: &str| for_view(view, &config, &tr, &[("acct/plan".into(), "Plan".into())], None).into_iter().filter(|s| s.kind != Kind::Note && s.key != "collections" && !s.key.starts_with(crate::porch::CATEGORY) && !s.key.starts_with(crate::porch::CONTACT)).map(|s| s.key).collect::<Vec<_>>();
1354 assert_eq!(keys("notes"), vec!["notes_folder"]);
1355 assert_eq!(keys("mail"), vec!["mail.threads", "fetch_minutes", "mail.filter", "spam.actions", "spam.threshold_spam", "spam.threshold_unsure", "spam.filter", "reading.family", "reading.size", "reading.spacing"]);
1357 assert_eq!(keys("account:a"), vec!["account.a.area", "account.a.history_weeks", "account.a.fetch_minutes", "account.a.shield", "account.a.shield_ai"]);
1359 assert_eq!(keys("accounts"), vec!["ai_key"]);
1360 assert_eq!(keys("tasks"), vec!["office_hours", "tasks.kind", "tasks.estimate", "tasks.list", "tasks.blocks", "tasks.block_alarms", "planning.start", "planning.window_days", "planning.even_days", "planning.gain_slots", "quiet.work", "quiet.personal", "github.enabled"]);
1361 assert_eq!(for_view("tasks", &config, &tr, &[("acct/plan".into(), "Plan".into())], None).iter().find(|s| s.key == "tasks.list").unwrap().choices.len(), 2);
1362 assert_eq!(for_view("lane:filed", &config, &tr, &[], None)[0].kind, Kind::Link);
1363 let porch = for_view("porch", &config, &tr, &[], None);
1365 let notes = porch.iter().filter(|s| s.kind == Kind::Note).count();
1366 assert_eq!(notes, 1 + 1 + 8, "{porch:?}");
1368 assert_eq!(keys("porch"), vec!["letters.inbox", "porch.calls", "reading.family", "reading.size", "reading.spacing", "known", "link.words.senders"]);
1369 assert!(porch.iter().any(|s| s.kind == Kind::Note && s.help.contains("Accounts")), "the shield is said to be in Accounts");
1370 let parameters: Vec<String> = keys("parameters").into_iter().filter(|k| !k.starts_with("words.") && k != "projects.rename").collect();
1374 assert_eq!(
1375 parameters,
1376 vec![
1377 "language", "theme", "places_named", "passwords_shown", "screen_colours", "calmer_colours", "case_store", "window", "window.admin", "link.needs", "time_off", "reminders.mail", "reminders.mail_newsletters", "reminders.gather", "free_time.nothing",
1379 "dnd.button", "dnd.focus", "dnd.pauses", "dnd.sleep", "reminders.before_event", "reminders.events", "reminders.asked_days", "reminders.waits", "reminders.payment_days", "reminders_closed", "reminders.gathered",
1380 "free_time.moves", "free_time.latest_after", "free_time.movement", "link.attention.pause", "pause.helps", "pause.grounding", "pause.breathing", "pause.pace", "pause.after", "pause.country", "invoice.name", "invoice.address", "invoice.siret", "invoice.vat",
1381 "invoice.prefix", "invoice.currency", "invoice.payment", "invoice.folder", "invoice.rate", "mcp.outside_projects", "mcp.texts", "mcp.spam"
1382 ]
1383 );
1384 let attention: Vec<Setting> = for_view("parameters", &config, &tr, &[], None).into_iter().filter(|s| s.section == "attention").collect();
1386 assert_eq!(attention.iter().map(|s| s.key.as_str()).collect::<Vec<_>>(), ["reminders.mail", "reminders.mail_newsletters", "reminders.gather", "free_time.nothing", "dnd.button", "dnd.focus", "dnd.pauses", "dnd.sleep"]);
1387 assert!(attention.iter().all(|s| !s.label.starts_with("set-") && !s.help.starts_with("set-")));
1388 let mut seen: std::collections::BTreeMap<String, &str> = std::collections::BTreeMap::new();
1392 let keys = |view: &str| keys(view).into_iter().filter(|k| view == "reading" || !k.starts_with("reading.")).collect::<Vec<_>>();
1393 assert_eq!(keys("reading"), vec!["reading.family", "reading.size", "reading.spacing"]);
1394 assert_eq!(keys("senders"), vec!["safe", "neutral", "restricted", "blocked"]);
1396 assert_eq!(keys("contacts"), vec!["map.geocode", "map.tiles", "contacts.region"]);
1397 let regions = for_view("contacts", &config, &tr, &[], None).into_iter().find(|s| s.key == "contacts.region").unwrap().choices;
1398 assert!(regions.len() > 30 && regions.iter().all(|c| !c.label.starts_with("country-")), "{regions:?}");
1399 for view in ["porch", "mail", "accounts", "account:a", "senders", "agenda", "tasks", "notes", "reading", "sites", "contacts", "parameters"] {
1400 for key in keys(view) {
1401 if let Some(other) = seen.insert(key.clone(), view) {
1402 panic!("{key} is set in {other} and in {view}");
1403 }
1404 }
1405 }
1406 for view in ["notes", "mail", "account:a", "tasks", "porch", "parameters", "sites", "contacts", "agenda"] {
1407 assert!(for_view(view, &config, &tr, &[], None).iter().all(|s| !s.label.starts_with("set-") && !s.help.starts_with("set-")), "{view}: a sentence is missing");
1408 }
1409 }
1410
1411 #[test]
1416 fn the_words_tab() {
1417 let dir = std::env::temp_dir().join(format!("sioul-words-tab-{}", std::process::id()));
1418 let _ = std::fs::remove_dir_all(&dir);
1419 std::fs::create_dir_all(&dir).unwrap();
1420 let path = dir.join("config.toml");
1421 std::fs::write(&path, "filed_words = [\"noreply\", \"robot\"]\n\n[words]\nlanguages = [\"fr\", \"en\"]\ncountries = [\"FR\"]\n").unwrap();
1422 let config = Config::load(&path).unwrap();
1423 for language in ["en", "fr"] {
1424 let rows: Vec<Setting> = for_view("parameters", &config, &Translator::new(language), &[], None).into_iter().filter(|s| s.section == "words").collect();
1425 assert_eq!((rows[0].key.as_str(), rows[1].key.as_str()), ("words.languages", "words.countries"));
1426 assert!(rows[..2].iter().all(|s| !s.choices.is_empty() && s.choices.iter().all(|c| !c.label.starts_with("words-") && !c.label.starts_with("country-"))), "{language}");
1427 for (_, line, lists) in WORD_LINES {
1428 let note = rows.iter().find(|s| s.key == format!("words.about.{line}")).unwrap_or_else(|| panic!("{line}"));
1429 let said = [¬e.label, ¬e.help, ¬e.unit, ¬e.group];
1430 assert!(said.iter().all(|w| !w.is_empty() && !w.starts_with("words-")), "{language}: {line}: {note:?}");
1431 for list in lists.iter() {
1432 let row = rows.iter().find(|s| s.key == format!("words.{list}")).unwrap_or_else(|| panic!("{list}"));
1433 assert!(row.kind == Kind::Words && !row.label.starts_with("words-") && !row.help.starts_with("words-"), "{language}: {list}: {row:?}");
1434 }
1435 }
1436 }
1437 let rows = for_view("parameters", &config, &Translator::new("en"), &[], None);
1438 let row = |key: &str| rows.iter().find(|s| s.key == key).cloned().unwrap();
1439 assert_eq!(row("words.senders.automatic").value, SettingValue::Texts(vec!["robot".into(), "noreply".into()]));
1441 assert!(row("words.about.senders").help.contains("added"), "{:?}", row("words.about.senders"));
1442 apply(&path, &config, "words.senders.automatic", &SettingValue::Texts(vec!["noreply".into(), "no-reply".into(), "robot".into()])).unwrap();
1443 let text = std::fs::read_to_string(&path).unwrap();
1444 assert!(!text.contains("filed_words") && text.contains("[words.senders.automatic]"), "{text}");
1445 let config = Config::load(&path).unwrap();
1446 let mut automatic = crate::words::Words::of(&config).senders.automatic.clone();
1447 automatic.sort();
1448 assert_eq!(automatic, ["no-reply", "noreply", "robot"]);
1449 let SettingValue::Texts(mut chips) = row("words.brands.brands").value else { panic!("brands") };
1451 chips.push("Ma Banque: mabanque.example, mabanque.fr".into());
1452 apply(&path, &config, "words.brands.brands", &SettingValue::Texts(chips.clone())).unwrap();
1453 let config = Config::load(&path).unwrap();
1454 assert!(crate::words::Words::of(&config).brands.brands.iter().any(|(n, d)| n == "Ma Banque" && d.len() == 2));
1455 chips.push("Sans Domaine".into());
1456 assert!(apply(&path, &config, "words.brands.brands", &SettingValue::Texts(chips)).is_err());
1457 assert!(apply(&path, &config, "words.languages", &serde_json::from_str("[]").unwrap()).is_err());
1459 apply(&path, &config, "words.languages", &SettingValue::Texts(vec!["en".into()])).unwrap();
1460 apply(&path, &config, "words.reset.senders", &SettingValue::Bool(true)).unwrap();
1461 apply(&path, &config, "words.reset.brands", &SettingValue::Bool(true)).unwrap();
1462 let config = Config::load(&path).unwrap();
1463 assert_eq!(crate::words::languages(&config), ["en"]);
1464 let text = std::fs::read_to_string(&path).unwrap();
1465 assert!(!text.contains("senders") && !text.contains("brands"), "{text}");
1466 let _ = std::fs::remove_dir_all(&dir);
1467 }
1468
1469 #[test]
1475 fn a_list_changed_from_the_window_keeps_what_arrived_meanwhile() {
1476 let dir = std::env::temp_dir().join(format!("sioul-change-{}", std::process::id()));
1477 let _ = std::fs::remove_dir_all(&dir);
1478 std::fs::create_dir_all(&dir).unwrap();
1479 let path = dir.join("config.toml");
1480 std::fs::write(&path, format!("known_senders = \"{}\"\n", dir.join("known.txt").display().to_string().replace('\\', "/"))).unwrap();
1481 let known = Config::load(&path).unwrap().known_senders_path();
1482 std::fs::write(&known, "jane@example.org\nbob@example.org\n").unwrap();
1483 let texts = |list: &[&str]| SettingValue::Texts(list.iter().map(|s| s.to_string()).collect());
1484 let shown = texts(&["bob@example.org", "jane@example.org"]);
1485 std::fs::write(&known, "jane@example.org\nbob@example.org\nada@example.org\n").unwrap();
1487 change(&path, "known", Some(&shown), &texts(&["jane@example.org", "zoe@example.org"])).unwrap();
1488 let mut now = SenderList::load(&known).entries();
1489 now.sort();
1490 assert_eq!(now, ["ada@example.org", "jane@example.org", "zoe@example.org"]);
1491 set_value(&path, "pause.helps", &texts(&["Tea", "A walk"])).unwrap();
1493 change(&path, "pause.helps", Some(&texts(&["Tea", "A walk"])), &texts(&["A walk", "Tea"])).unwrap();
1494 assert_eq!(Config::load(&path).unwrap().pause.helps, ["A walk", "Tea"]);
1495 assert_eq!(rebased(&[1, 2, 3], &[3, 1, 4], &[1, 2, 3, 5]), [3, 1, 4, 5]);
1497 assert_eq!(rebase(&SettingValue::Ints(Vec::new()), &texts(&["a"]), &texts(&["b"])), texts(&["a", "b"]));
1498 let _ = std::fs::remove_dir_all(&dir);
1499 }
1500
1501 #[test]
1507 fn an_element_changed_on_two_devices_stays_one() {
1508 use crate::config::{TimeOffValue, WindowValue};
1509 let dir = std::env::temp_dir().join(format!("sioul-change-one-{}", std::process::id()));
1510 let _ = std::fs::remove_dir_all(&dir);
1511 std::fs::create_dir_all(&dir).unwrap();
1512 let path = dir.join("config.toml");
1513 let day = |id: &str, label: &str| TimeOffValue { id: id.into(), from: "2026-08-01".into(), until: "2026-08-15".into(), label: label.into() };
1514 std::fs::write(&path, "[[time_off]]\nid = \"a1\"\nfrom = 2026-08-01\nuntil = 2026-08-15\nlabel = \"Summer\"\n").unwrap();
1515 std::fs::write(&path, "[[time_off]]\nid = \"a1\"\nfrom = 2026-08-01\nuntil = 2026-08-15\nlabel = \"Summer holidays\"\n").unwrap();
1517 change(&path, "time_off", Some(&SettingValue::TimeOff(vec![day("a1", "Summer")])), &SettingValue::TimeOff(vec![day("a1", "Summer trip")])).unwrap();
1518 let off = Config::load(&path).unwrap().time_off;
1519 assert_eq!(off.iter().map(|t| (t.id.as_str(), t.label.as_str())).collect::<Vec<_>>(), [("a1", "Summer trip")]);
1520 let window = |start: &str, end: &str| WindowValue { day: "mon".into(), start: start.into(), end: end.into(), minutes: 0 };
1524 let windows = |list: Vec<WindowValue>| SettingValue::Windows(list);
1525 assert_eq!(rebase(&windows(vec![window("09:00", "17:00")]), &windows(vec![window("09:00", "18:00")]), &windows(vec![window("09:00", "17:00"), window("19:00", "20:00")])), windows(vec![window("09:00", "18:00"), window("19:00", "20:00")]));
1526 assert_eq!(rebase(&windows(vec![window("09:00", "17:00")]), &windows(vec![window("09:00", "18:00")]), &windows(vec![window("08:00", "17:00")])), windows(vec![window("09:00", "18:00"), window("08:00", "17:00")]));
1527 let route = |word: &str| crate::projects::RouteValue { subject_contains: vec![word.into()], ..Default::default() };
1529 let routes = |list: Vec<crate::projects::RouteValue>| SettingValue::Routes(list);
1530 assert_eq!(rebase(&routes(vec![route("a"), route("b")]), &routes(vec![route("a2"), route("b")]), &routes(vec![route("z"), route("a"), route("b")])), routes(vec![route("a2"), route("b"), route("z")]));
1531 let _ = std::fs::remove_dir_all(&dir);
1532 }
1533
1534 #[test]
1538 fn a_window_changed_here_while_another_was_added_that_day() {
1539 use crate::config::WindowValue;
1540 let w = |start: &str, end: &str| WindowValue { day: "mon".into(), start: start.into(), end: end.into(), minutes: 0 };
1541 let shown = SettingValue::Windows(vec![w("09:00", "12:00"), w("14:00", "17:00")]);
1542 let value = SettingValue::Windows(vec![w("09:00", "12:00"), w("14:00", "18:00")]);
1543 let current = SettingValue::Windows(vec![w("07:00", "08:00"), w("09:00", "12:00"), w("14:00", "17:00")]);
1544 let SettingValue::Windows(mut got) = rebase(&shown, &value, ¤t) else { panic!() };
1545 got.sort_by(|a, b| a.start.cmp(&b.start));
1546 let got: Vec<(&str, &str)> = got.iter().map(|w| (w.start.as_str(), w.end.as_str())).collect();
1547 assert_eq!(got, [("07:00", "08:00"), ("09:00", "12:00"), ("14:00", "18:00")]);
1548 }
1549
1550 #[test]
1553 fn one_route_changed_on_two_devices_stays_one() {
1554 let route = |word: &str| crate::projects::RouteValue { subject_contains: vec![word.into()], ..Default::default() };
1555 let routes = |list: Vec<crate::projects::RouteValue>| SettingValue::Routes(list);
1556 assert_eq!(rebase(&routes(vec![route("a"), route("b")]), &routes(vec![route("a2"), route("b")]), &routes(vec![route("a3"), route("b")])), routes(vec![route("a2"), route("b")]));
1557 assert_eq!(rebase(&routes(vec![route("a"), route("b")]), &routes(vec![route("a2"), route("b")]), &routes(vec![route("b"), route("c"), route("a")])), routes(vec![route("a2"), route("b"), route("c")]));
1559 }
1560
1561 #[test]
1562 fn lists_emptied_and_secrets_kept_out() {
1563 let dir = std::env::temp_dir().join(format!("sioul-apply-{}", std::process::id()));
1564 let _ = std::fs::remove_dir_all(&dir);
1565 std::fs::create_dir_all(&dir).unwrap();
1566 let path = dir.join("config.toml");
1567 std::fs::write(&path, format!("# Mine.\nknown_senders = \"{}\"\n", dir.join("known.txt").display().to_string().replace('\\', "/"))).unwrap();
1568 let config = Config::load(&path).unwrap();
1569 apply(&path, &config, "known", &SettingValue::Texts(vec!["jane@example.org".into()])).unwrap();
1570 assert_eq!(SenderList::load(&config.known_senders_path()).entries(), ["jane@example.org"]);
1571 apply(&path, &config, "known", &serde_json::from_str("[]").unwrap()).unwrap();
1573 assert!(SenderList::load(&config.known_senders_path()).entries().is_empty());
1574 assert!(apply(&path, &config, "spam.threshold_unsure", &SettingValue::Float(0.97)).is_err());
1576 assert!(apply(&path, &config, "spam.threshold_spam", &SettingValue::Float(1.5)).is_err());
1577 assert!(apply(&path, &config, "spam.actions.spam", &SettingValue::Text("delete".into())).is_err());
1578 assert!(apply(&path, &config, "spam.actions.maybe", &SettingValue::Text("move".into())).is_err());
1579 apply(&path, &config, "spam.threshold_spam", &SettingValue::Float(0.9)).unwrap();
1580 set_value(&path, "spam.mode", &SettingValue::Text("act".into())).unwrap();
1582 let config = Config::load(&path).unwrap();
1583 use crate::spam::{Action, Actions};
1584 assert_eq!(config.spam.actions(), Actions { spam: Action::Move, unsure: Action::Flag, ham: Action::Nothing });
1585 apply(&path, &config, "spam.actions.ham", &SettingValue::Text("flag".into())).unwrap();
1586 let config = Config::load(&path).unwrap();
1587 assert_eq!((config.spam.actions(), config.spam.thresholds(), config.spam.mode.as_deref()), (Actions { spam: Action::Move, unsure: Action::Flag, ham: Action::Flag }, (0.9, 0.5), None));
1588 let matrix = for_view("mail", &config, &Translator::new("en"), &[], None).into_iter().find(|s| s.key == "spam.actions").unwrap();
1589 assert_eq!((matrix.kind, matrix.rows.len(), matrix.choices.len()), (Kind::Radios, 3, 3));
1590 assert_eq!(matrix.value, SettingValue::Texts(vec!["spam:move".into(), "unsure:flag".into(), "ham:flag".into()]));
1591 assert!(apply(&path, &config, "spam.threshold_unsure", &SettingValue::Float(0.9)).is_err());
1592 let rows = for_view("mail", &config, &Translator::new("en"), &[], None);
1594 let range = |key: &str| rows.iter().find(|s| s.key == key).map(|s| ((s.min * 100.0).round(), (s.max * 100.0).round(), s.unit.clone())).unwrap();
1595 assert_eq!((range("spam.threshold_spam"), range("spam.threshold_unsure")), ((51.0, 99.0, "%".to_string()), (5.0, 89.0, "%".to_string())));
1596 assert!(rows.iter().any(|s| s.key == "spam.filter" && s.kind == Kind::Spam));
1597 assert!(apply(&path, &config, "github_token", &SettingValue::Int(12345)).is_err());
1599 assert!(!std::fs::read_to_string(&path).unwrap().contains("github_token"));
1600 let _ = std::fs::remove_dir_all(&dir);
1601 }
1602
1603 #[test]
1604 fn the_projects_file_is_renamed_on_demand() {
1605 let dir = std::env::temp_dir().join(format!("sioul-rename-setting-{}", std::process::id()));
1606 let _ = std::fs::remove_dir_all(&dir);
1607 let notes = dir.join("notes");
1608 std::fs::create_dir_all(¬es).unwrap();
1609 let path = dir.join("config.toml");
1610 std::fs::write(&path, format!("case_store = \"{}\"\n", notes.display().to_string().replace('\\', "/"))).unwrap();
1611 let config = Config::load(&path).unwrap();
1612 let tr = Translator::new("en");
1613 let row = || for_view("parameters", &config, &tr, &[], None).into_iter().find(|s| s.section == "files" && (s.key == "projects.rename" || s.kind == Kind::Note));
1614 assert!(row().is_none());
1616 std::fs::write(notes.join(crate::projects::OLD_FILE), "[[case]]\nid = \"taxes\"\ntitle = \"Taxes\"\nai = true\n").unwrap();
1618 let rename = row().unwrap();
1619 assert_eq!((rename.key.as_str(), rename.kind), ("projects.rename", Kind::Action));
1620 assert!(rename.label.contains("sioul-projects.toml") && !rename.help.starts_with("set-"), "{rename:?}");
1621 assert!(notes.join(crate::projects::OLD_FILE).exists() && !notes.join(crate::projects::FILE).exists());
1622 apply(&path, &config, "projects.rename", &SettingValue::Bool(true)).unwrap();
1623 assert!(row().is_none());
1624 assert!(crate::projects::ProjectStore::load(¬es).unwrap().get("taxes").unwrap().ai);
1625 assert!(notes.join(crate::projects::BEFORE_RENAME).exists());
1626 std::fs::write(notes.join(crate::projects::OLD_FILE), "[[case]]\nid = \"old\"\ntitle = \"Old\"\n").unwrap();
1628 let both = row().unwrap();
1629 assert!(both.kind == Kind::Note && both.label.contains("sioul-projects.toml"), "{both:?}");
1630 assert!(apply(&path, &config, "projects.rename", &SettingValue::Bool(true)).is_err());
1631 let _ = std::fs::remove_dir_all(&dir);
1632 }
1633}