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) {
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 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 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 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 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
181fn 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
193pub 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 if let Some(lane) = view.strip_prefix("lane:") {
205 match lane {
206 "people" | "screener" => {
207 b.push("known", "known", Kind::Senders, known());
208 }
209 "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 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 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 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 b.note(tr.text("set-hours-elsewhere", None), Vec::new());
250 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 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 b.group = tr.text("set-calls-group", None);
263 b.push("porch.calls", "porch-calls", Kind::Bool, SettingValue::Bool(config.porch.calls));
264 b.group = tr.text("ui-reading", None);
266 reading(&mut b, config);
267 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 b.note(tr.text("set-accounts-elsewhere", None), Vec::new());
290 b.group = tr.text("set-filters-group", None);
292 b.push("mail.filter", "filters", Kind::Filters, SettingValue::Text(String::new()));
293 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 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 b.push("spam.filter", if cfg!(target_os = "android") { "spam-filter-phone" } else { "spam-filter" }, Kind::Spam, SettingValue::Text(String::new()));
310 b.group = tr.text("ui-reading", None);
312 reading(&mut b, config);
313 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 "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 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 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 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 "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 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 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 b.group = tr.text("set-task-areas-group", None);
445 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 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 "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 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 "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 b.push("places_named", "places-named", Kind::Bool, SettingValue::Bool(config.places_named));
487 b.push("passwords_shown", "passwords-shown", Kind::Bool, SettingValue::Bool(false));
489 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 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 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 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 b.section = "attention".into();
522 b.group = String::new();
523 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 b.push("reminders.gather", "reminders-gather", Kind::Bool, SettingValue::Bool(config.reminders.gather));
528 b.push("free_time.nothing", "free-nothing", Kind::Bool, SettingValue::Bool(config.free_time.nothing));
530 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 b.section = "words".into();
537 words_rows(&mut b, config);
538 b.section = "reminders".into();
540 b.group = tr.text("set-reminders-group", None);
541 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 b.push("reminders.gathered", "reminders-gathered", Kind::Words, SettingValue::Texts(config.reminders.gathered_times()));
559 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 b.note(tr.text("set-pause-about", None), Vec::new());
569 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 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 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 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
625pub 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
718fn word_list(path: &str) -> bool {
720 WORD_LINES.iter().any(|(_, _, lists)| lists.contains(&path))
721}
722
723fn 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
728fn 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
752fn 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
766fn 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
776fn 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 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 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 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
854fn 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
879fn 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
885pub 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
894pub 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
914pub 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 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 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, ¤t)
954}
955
956fn 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
970const VIEWS: &[&str] = &["parameters", "porch", "mail", "senders", "accounts", "agenda", "tasks", "notes", "reading", "sites", "contacts", "lane:people"];
972
973pub fn change(config_path: &Path, key: &str, shown: Option<&SettingValue>, value: &SettingValue) -> Result<(), String> {
982 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, ¤t)),
1005 None => value.clone(),
1006 };
1007 apply(config_path, &fresh, key, &value)
1008 })
1009}
1010
1011fn 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
1020pub fn rebase(shown: &SettingValue, value: &SettingValue, current: &SettingValue) -> SettingValue {
1026 use SettingValue as V;
1027 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 let none = matches!(value, SettingValue::Ints(list) if list.is_empty());
1050 match key {
1051 "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 _ 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 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 "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 "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 _ 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 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 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 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 _ 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 "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 "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 _ 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 _ if key.starts_with("attention.") || key.starts_with("notify.") || key.starts_with("reach.") => crate::attention::apply(config_path, config, key, value),
1209 _ 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 _ 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 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 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 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 let porch = for_view("porch", &config, &tr, &[], None);
1272 let notes = porch.iter().filter(|s| s.kind == Kind::Note).count();
1273 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 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 "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 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 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 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 #[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 = [¬e.label, ¬e.help, ¬e.unit, ¬e.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 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 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 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 #[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 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 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 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 #[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 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 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 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 #[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, ¤t) 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 #[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 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 apply(&path, &config, "known", &serde_json::from_str("[]").unwrap()).unwrap();
1480 assert!(SenderList::load(&config.known_senders_path()).entries().is_empty());
1481 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 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 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 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(¬es).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 assert!(row().is_none());
1523 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(¬es).unwrap().get("taxes").unwrap().ai);
1532 assert!(notes.join(crate::projects::BEFORE_RENAME).exists());
1533 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}