1use crate::window::AdminWindow;
14use serde::Deserialize;
15use std::path::{Path, PathBuf};
16use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, value};
17
18#[derive(Debug, Default, Clone, Deserialize)]
19pub struct Config {
20 pub language: Option<String>,
22 #[serde(default)]
24 pub theme: Option<String>,
25 #[serde(default)]
28 pub places_named: bool,
29 #[serde(default)]
32 pub screen_colours: Option<bool>,
33 #[serde(default)]
35 pub calmer_colours: Option<String>,
36 #[serde(rename = "case_store")]
40 pub notes_root: Option<String>,
41 #[serde(default)]
46 pub share_projects: bool,
47 pub known_senders: Option<String>,
49 pub blocked_senders: Option<String>,
51 #[serde(default)]
54 pub history_weeks: Option<u32>,
55 #[serde(default)]
57 pub fetch_minutes: Option<u32>,
58 #[serde(default)]
60 pub notes_folder: Option<String>,
61 #[serde(default)]
65 pub filed_words: Option<Vec<String>>,
66 #[serde(default)]
68 pub reading: Reading,
69 #[serde(default)]
70 pub mail: MailSettings,
71 #[serde(default)]
73 pub spam: SpamSettings,
74 #[serde(default)]
75 pub tasks: TaskSettings,
76 #[serde(default)]
78 pub planning: PlanningSettings,
79 #[serde(default)]
80 pub agenda: AgendaSettings,
81 #[serde(default)]
82 pub map: MapSettings,
83 #[serde(default)]
85 pub contacts: ContactSettings,
86 #[serde(default)]
90 pub words: toml::Table,
91 #[serde(default)]
93 pub reminders: ReminderSettings,
94 #[serde(default)]
97 pub notify: crate::attention::NotifySettings,
98 #[serde(default)]
102 pub attention: Option<crate::attention::Settings>,
103 #[serde(default)]
105 pub free_time: crate::pause::FreeTimeSettings,
106 #[serde(default)]
108 pub pause: crate::pause::PauseSettings,
109 #[serde(default)]
111 pub dnd: crate::everywhere::DndSettings,
112 #[serde(default)]
116 pub screening: Option<crate::screening::Settings>,
117 #[serde(default)]
119 pub letters: LetterSettings,
120 #[serde(rename = "routine", default)]
122 pub routines: Vec<crate::routines::Routine>,
123 #[serde(default)]
125 pub weather: WeatherSettings,
126 #[serde(default)]
128 pub bitwarden: BitwardenSettings,
129 #[serde(default)]
131 pub github: GithubSettings,
132 #[serde(default)]
134 pub invoice: InvoiceSettings,
135 #[serde(default)]
137 pub mcp: McpSettings,
138 #[serde(rename = "account", default)]
140 pub accounts: Vec<Account>,
141 #[serde(rename = "site", default)]
145 pub sites: Vec<Account>,
146 #[serde(default)]
151 pub site_order: Vec<String>,
152 #[serde(skip)]
154 pub accounts_off: Vec<Account>,
155 #[serde(rename = "window", default)]
156 pub windows: Vec<AdminWindow>,
157 #[serde(rename = "office_hours", default)]
159 pub office_hours: Vec<AdminWindow>,
160 #[serde(rename = "time_off", default)]
162 pub time_off: Vec<TimeOff>,
163 #[serde(default)]
164 pub quiet: QuietSettings,
165 #[serde(default)]
167 pub porch: PorchSettings,
168 #[serde(default)]
171 pub reach: ReachSettings,
172 #[serde(default)]
174 pub calls: CallSettings,
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Deserialize, serde::Serialize)]
179pub struct CallSettings {
180 #[serde(default)]
181 pub camera: Option<String>,
182 #[serde(default)]
183 pub microphone: Option<String>,
184 #[serde(default)]
185 pub speaker: Option<String>,
186}
187
188#[derive(Debug, Clone, PartialEq, Deserialize)]
190pub struct PorchSettings {
191 #[serde(default)]
193 pub hidden_projects: Vec<String>,
194 #[serde(default = "yes")]
198 pub calls: bool,
199}
200
201impl Default for PorchSettings {
202 fn default() -> PorchSettings {
203 PorchSettings { hidden_projects: Vec::new(), calls: true }
204 }
205}
206
207#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
214pub struct ReachSettings {
215 #[serde(default)]
216 pub safe: Option<Vec<String>>,
217 #[serde(default)]
218 pub neutral: Option<Vec<String>>,
219 #[serde(default)]
220 pub restricted: Option<Vec<String>>,
221 #[serde(default)]
224 pub stranger: Option<Vec<String>>,
225 #[serde(default)]
226 pub calls: RowSettings,
227 #[serde(default)]
228 pub messages: RowSettings,
229}
230
231#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
233pub struct RowSettings {
234 #[serde(default)]
235 pub safe: Option<Vec<String>>,
236 #[serde(default)]
237 pub neutral: Option<Vec<String>>,
238 #[serde(default)]
239 pub restricted: Option<Vec<String>>,
240 #[serde(default)]
241 pub stranger: Option<Vec<String>>,
242 #[serde(default)]
244 pub hidden: Option<Vec<String>>,
245}
246
247#[derive(Debug, Clone, PartialEq, Deserialize)]
249pub struct TimeOff {
250 #[serde(default)]
252 pub id: String,
253 #[serde(deserialize_with = "crate::budget::dates::required")]
254 pub from: jiff::civil::Date,
255 #[serde(deserialize_with = "crate::budget::dates::required")]
256 pub until: jiff::civil::Date,
257 #[serde(default)]
258 pub label: String,
259}
260
261#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
265pub struct QuietSettings {
266 #[serde(default)]
269 pub personal: Option<Vec<String>>,
270 #[serde(default)]
273 pub work: Option<Vec<String>>,
274}
275
276impl Config {
277 pub fn working_hours(&self) -> Vec<AdminWindow> {
281 self.windows.iter().filter(|w| w.end.is_some() && w.kind() == "work").cloned().collect()
282 }
283
284 pub fn week_hours(&self) -> Vec<AdminWindow> {
288 self.windows.iter().filter(|w| w.end.is_some() && w.kind() != "leisure").cloned().collect()
289 }
290
291 pub fn office_hours(&self) -> Vec<AdminWindow> {
293 if self.office_hours.is_empty() { crate::window::default_office_hours() } else { self.office_hours.clone() }
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Deserialize)]
299pub struct Reading {
300 #[serde(default)]
302 pub family: String,
303 #[serde(default = "Reading::default_size")]
304 pub size: u32,
305 #[serde(default = "Reading::default_spacing")]
307 pub spacing: f64,
308}
309
310impl Reading {
311 fn default_size() -> u32 {
312 16
313 }
314
315 fn default_spacing() -> f64 {
316 1.5
317 }
318}
319
320impl Default for Reading {
321 fn default() -> Reading {
322 Reading { family: String::new(), size: Reading::default_size(), spacing: Reading::default_spacing() }
323 }
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
329pub struct SpamSettings {
330 #[serde(default)]
333 pub mode: Option<String>,
334 #[serde(default)]
338 pub action_spam: Option<String>,
339 #[serde(default)]
340 pub action_unsure: Option<String>,
341 #[serde(default)]
342 pub action_ham: Option<String>,
343 #[serde(default)]
345 pub threshold_spam: Option<f32>,
346 #[serde(default)]
348 pub threshold_unsure: Option<f32>,
349 #[serde(default)]
353 pub train_by_itself: Option<bool>,
354}
355
356impl SpamSettings {
357 pub fn trains_by_itself(&self) -> bool {
359 self.train_by_itself.unwrap_or(true)
360 }
361
362 pub fn actions(&self) -> crate::spam::Actions {
365 use crate::spam::{Action, Actions, Class};
366 let base = self.mode.as_deref().and_then(Actions::of_mode).unwrap_or_default();
367 [(Class::Spam, &self.action_spam), (Class::Unsure, &self.action_unsure), (Class::Ham, &self.action_ham)]
368 .into_iter()
369 .fold(base, |actions, (class, written)| match written.as_deref().and_then(Action::read) {
370 Some(action) => actions.with(class, action),
371 None => actions,
372 })
373 }
374
375 pub fn thresholds(&self) -> (f32, f32) {
378 let valid = |t: Option<f32>| t.filter(|t| t.is_finite() && (0.0..=1.0).contains(t));
379 let spam = valid(self.threshold_spam).unwrap_or(crate::spam::THRESHOLD_SPAM);
380 let unsure = valid(self.threshold_unsure).unwrap_or(crate::spam::THRESHOLD_UNSURE);
381 (spam, unsure.min(spam))
382 }
383}
384
385#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
386pub struct MailSettings {
387 #[serde(default)]
389 pub threads: bool,
390 #[serde(default, rename = "filter", deserialize_with = "crate::rules::lenient")]
394 pub filters: Vec<crate::rules::Filter>,
395}
396
397#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
398pub struct TaskSettings {
399 #[serde(default)]
401 pub list: Option<String>,
402 #[serde(default)]
404 pub estimate: Option<u32>,
405 #[serde(rename = "kind", default)]
407 pub kinds: Option<Vec<TaskKind>>,
408 #[serde(default)]
411 pub blocks: Option<String>,
412 #[serde(default)]
415 pub block_alarms: bool,
416}
417
418#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
420pub struct PlanningSettings {
421 #[serde(default)]
423 pub window_days: Option<u32>,
424 #[serde(default)]
426 pub even_days: bool,
427 #[serde(default)]
429 pub start: Option<String>,
430 #[serde(default)]
432 pub gain_slots: Option<bool>,
433}
434
435impl PlanningSettings {
436 pub fn window(&self) -> u32 {
437 self.window_days.unwrap_or(crate::capacity::WINDOW_DEFAULT).clamp(crate::capacity::WINDOW_LEAST, crate::capacity::WINDOW_MOST)
438 }
439
440 pub fn start(&self) -> crate::capacity::Start {
441 crate::capacity::Start::parse(self.start.as_deref().unwrap_or(""))
442 }
443
444 pub fn gain_slots(&self) -> bool {
445 self.gain_slots.unwrap_or(true)
446 }
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
451pub struct TaskKind {
452 pub id: String,
454 #[serde(default)]
456 pub label: String,
457}
458
459fn default_kinds() -> Vec<TaskKind> {
461 crate::tasks::KINDS.iter().map(|k| TaskKind { id: k.to_string(), label: String::new() }).collect()
462}
463
464impl Config {
465 pub fn task_kinds(&self, tr: &crate::i18n::Translator) -> Vec<(String, String)> {
467 self.tasks
468 .kinds
469 .clone()
470 .unwrap_or_else(default_kinds)
471 .into_iter()
472 .filter(|k| crate::tasks::is_kind_id(&k.id))
473 .map(|k| {
474 let label = if k.label.trim().is_empty() { tr.text(&format!("task-kind-{}", k.id), None) } else { k.label.trim().to_string() };
475 (k.id, label)
476 })
477 .collect()
478 }
479}
480
481pub fn change_kind(path: &Path, from: &str, to: &str) -> Result<(), String> {
487 crate::filelock::with_lock(path, || {
488 let config = if path.exists() { Config::load(path)? } else { Config::default() };
489 let mut kinds = config.tasks.kinds.clone().unwrap_or_else(default_kinds);
490 let to = to.trim();
491 if from.is_empty() {
492 if to.is_empty() {
493 return Ok(());
494 }
495 let taken: Vec<String> = kinds.iter().map(|k| k.id.clone()).collect();
496 kinds.push(TaskKind { id: crate::projects::new_id(to, &taken), label: to.to_string() });
497 } else if to.is_empty() {
498 kinds.retain(|k| k.id != from);
499 } else {
500 let kind = kinds.iter_mut().find(|k| k.id == from).ok_or_else(|| format!("{from}: no such kind"))?;
501 kind.label = to.to_string();
502 }
503 let mut doc = read_document(path)?;
504 if !doc.contains_key("tasks") {
505 doc["tasks"] = toml_edit::table();
506 }
507 let tasks = doc["tasks"].as_table_mut().ok_or_else(|| format!("{}: tasks is not a table", path.display()))?;
508 if kinds.is_empty() {
509 tasks["kind"] = toml_edit::value(toml_edit::Array::new());
510 } else {
511 let mut list = toml_edit::ArrayOfTables::new();
512 for kind in &kinds {
513 let mut table = toml_edit::Table::new();
514 table["id"] = toml_edit::value(kind.id.as_str());
515 if !kind.label.is_empty() {
516 table["label"] = toml_edit::value(kind.label.as_str());
517 }
518 list.push(table);
519 }
520 tasks["kind"] = Item::ArrayOfTables(list);
521 }
522 write_document(path, &doc)
523 })
524}
525
526#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
527pub struct AgendaSettings {
528 #[serde(default)]
530 pub day_start: Option<u32>,
531 #[serde(default)]
534 pub calendar: Option<String>,
535 #[serde(default)]
538 pub between: Option<u32>,
539}
540
541impl AgendaSettings {
542 pub fn between(&self) -> u32 {
544 self.between.unwrap_or(0).min(24 * 60)
545 }
546}
547
548#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
549pub struct MapSettings {
550 #[serde(default)]
552 pub tiles: Option<String>,
553 #[serde(default)]
555 pub geocode: bool,
556}
557
558#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
560pub struct ContactSettings {
561 #[serde(default)]
565 pub region: Option<String>,
566}
567
568#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
570pub struct LetterSettings {
571 #[serde(default)]
573 pub inbox: Option<String>,
574}
575
576#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
578pub struct ReminderSettings {
579 #[serde(default = "yes")]
581 pub events: bool,
582 #[serde(default = "two")]
584 pub asked_days: u32,
585 #[serde(default = "two")]
587 pub payment_days: u32,
588 #[serde(default = "yes")]
590 pub waits: bool,
591 #[serde(default = "yes")]
594 pub gather: bool,
595 #[serde(default)]
597 pub gathered: Option<Vec<String>>,
598 #[serde(default = "yes")]
603 pub doses_in_sleep: bool,
604 #[serde(default = "fifteen")]
608 pub before_event: u32,
609 #[serde(default = "yes")]
611 pub mail: bool,
612 #[serde(default)]
614 pub mail_newsletters: bool,
615}
616
617impl ReminderSettings {
618 pub fn gathered_times(&self) -> Vec<String> {
621 self.gathered.clone().filter(|t| !t.is_empty()).unwrap_or_else(|| ["09:00", "13:00", "18:00"].iter().map(|t| t.to_string()).collect())
622 }
623}
624
625impl Default for ReminderSettings {
626 fn default() -> Self {
627 ReminderSettings { events: true, asked_days: 2, payment_days: 2, waits: true, gather: true, gathered: None, doses_in_sleep: true, before_event: 15, mail: true, mail_newsletters: false }
628 }
629}
630
631fn two() -> u32 {
632 2
633}
634
635fn fifteen() -> u32 {
636 15
637}
638
639#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
641pub struct GithubSettings {
642 #[serde(default)]
643 pub enabled: bool,
644 #[serde(default = "yes")]
646 pub assigned: bool,
647 #[serde(default = "yes")]
649 pub reviews: bool,
650 #[serde(default)]
652 pub created: bool,
653 #[serde(default)]
655 pub mentioned: bool,
656}
657
658fn yes() -> bool {
659 true
660}
661
662#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
665pub struct McpSettings {
666 #[serde(default = "yes")]
670 pub spam: bool,
671 #[serde(default = "yes")]
676 pub outside_projects: bool,
677 #[serde(default)]
681 pub texts: bool,
682 #[serde(default)]
686 pub work_accounts: bool,
687}
688
689impl Default for McpSettings {
690 fn default() -> Self {
691 McpSettings { spam: true, outside_projects: true, texts: false, work_accounts: false }
692 }
693}
694
695impl Default for GithubSettings {
696 fn default() -> Self {
697 GithubSettings { enabled: false, assigned: true, reviews: true, created: false, mentioned: false }
698 }
699}
700
701#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
702pub struct BitwardenSettings {
703 #[serde(default)]
705 pub email: Option<String>,
706 #[serde(default)]
708 pub server: Option<String>,
709}
710
711#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
712pub struct WeatherSettings {
713 #[serde(default)]
715 pub place: Option<String>,
716 #[serde(default)]
717 pub latitude: Option<f64>,
718 #[serde(default)]
719 pub longitude: Option<f64>,
720}
721
722#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
723pub struct InvoiceSettings {
724 #[serde(default)]
725 pub name: String,
726 #[serde(default)]
728 pub address: String,
729 #[serde(default)]
731 pub siret: String,
732 #[serde(default)]
734 pub vat: String,
735 #[serde(default)]
737 pub prefix: String,
738 #[serde(default)]
740 pub rate: f64,
741 #[serde(default)]
742 pub currency: String,
743 #[serde(default)]
745 pub folder: String,
746 #[serde(default)]
748 pub payment: String,
749}
750
751#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
752#[serde(rename_all = "lowercase")]
753pub enum AccountKind {
754 Imap,
755 Jmap,
756 #[default]
760 Portal,
761 Dav,
763}
764
765#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
767#[serde(rename_all = "lowercase")]
768pub enum Security {
769 #[default]
771 Tls,
772 Starttls,
774}
775
776impl Security {
777 pub fn as_str(self) -> &'static str {
778 match self {
779 Security::Tls => "tls",
780 Security::Starttls => "starttls",
781 }
782 }
783}
784
785#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
787#[serde(rename_all = "lowercase")]
788pub enum Priority {
789 Above,
791 #[default]
792 Average,
793 Below,
796}
797
798impl Priority {
799 pub fn as_str(self) -> &'static str {
800 match self {
801 Priority::Above => "above",
802 Priority::Average => "average",
803 Priority::Below => "below",
804 }
805 }
806
807 pub fn parse(text: &str) -> Option<Priority> {
808 match text.trim().to_ascii_lowercase().as_str() {
809 "above" => Some(Priority::Above),
810 "average" => Some(Priority::Average),
811 "below" => Some(Priority::Below),
812 _ => None,
813 }
814 }
815}
816
817pub const DEFAULT_SYNC_DAYS: u32 = 14;
819
820pub const MICROSOFT_IMAP: &str = "outlook.office365.com";
823pub const MICROSOFT_SMTP_PERSONAL: &str = "smtp-mail.outlook.com";
825pub const MICROSOFT_SMTP_WORK: &str = "smtp.office365.com";
827
828#[derive(Debug, Clone, Deserialize)]
829pub struct Account {
830 pub id: String,
831 #[serde(default)]
832 pub kind: AccountKind,
833 #[serde(default = "yes")]
835 pub enabled: bool,
836 #[serde(default)]
837 pub address: Option<String>,
838 #[serde(default)]
840 pub maildir: Option<String>,
841 #[serde(default)]
843 pub url: Option<String>,
844 #[serde(default)]
846 pub site: Option<String>,
847 #[serde(default)]
849 pub realtime: bool,
850 #[serde(default)]
852 pub muted: bool,
853 #[serde(default)]
855 pub background: Option<bool>,
856 #[serde(default)]
859 pub microphone: Option<bool>,
860 #[serde(default)]
861 pub camera: Option<bool>,
862 #[serde(default)]
863 pub screen: Option<bool>,
864 #[serde(default)]
866 pub categories: Vec<String>,
867 #[serde(default)]
870 pub announced_by: Vec<String>,
871 #[serde(default)]
873 pub area: Option<String>,
874 #[serde(default)]
876 pub trusted_authserv_ids: Vec<String>,
877 #[serde(default)]
879 pub host: Option<String>,
880 #[serde(default)]
881 pub port: Option<u16>,
882 #[serde(default)]
883 pub security: Security,
884 #[serde(default)]
886 pub username: Option<String>,
887 #[serde(default)]
889 pub sync_days: Option<u32>,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub auth: Option<String>,
896 #[serde(default, skip_serializing_if = "Vec::is_empty")]
898 pub skip_folders: Vec<String>,
899 #[serde(default)]
902 pub history_weeks: Option<u32>,
903 #[serde(default)]
905 pub priority: Priority,
906 #[serde(default)]
908 pub name: Option<String>,
909 #[serde(default)]
911 pub smtp_host: Option<String>,
912 #[serde(default)]
913 pub smtp_port: Option<u16>,
914 #[serde(default)]
915 pub smtp_security: Security,
916 #[serde(default)]
918 pub signature: Option<String>,
919 #[serde(default)]
921 pub fetch_minutes: Option<u32>,
922 #[serde(default)]
924 pub shield: bool,
925 #[serde(default)]
927 pub shield_ai: bool,
928 #[serde(default)]
935 pub microsoft_kind: Option<String>,
936}
937
938impl Account {
939 pub fn history_days(&self) -> Option<i64> {
941 history_days(self.history_weeks)
942 }
943
944 pub fn imap(id: &str, address: &str, host: &str, port: u16, security: Security, username: Option<&str>) -> Account {
946 Account {
947 id: id.to_string(),
948 kind: AccountKind::Imap,
949 enabled: true,
950 address: Some(address.to_string()),
951 maildir: None,
952 url: None,
953 site: None,
954 realtime: false,
955 muted: false,
956 background: None,
957 microphone: None,
958 camera: None,
959 screen: None,
960 categories: Vec::new(),
961 announced_by: Vec::new(),
962 area: None,
963 auth: None,
964 skip_folders: Vec::new(),
965 trusted_authserv_ids: Vec::new(),
966 host: Some(host.to_string()),
967 port: Some(port),
968 security,
969 username: username.filter(|u| *u != address).map(str::to_string),
970 sync_days: None,
971 history_weeks: None,
972 priority: Priority::Average,
973 name: None,
974 smtp_host: None,
975 smtp_port: None,
976 smtp_security: Security::Tls,
977 signature: None,
978 fetch_minutes: None,
979 shield: false,
980 shield_ai: false,
981 microsoft_kind: None,
982 }
983 }
984
985 pub fn maildir_path(&self) -> PathBuf {
987 self.maildir.as_deref().map_or_else(|| data_dir().join("mail").join(&self.id), expand_home)
988 }
989
990 pub fn login(&self) -> Option<&str> {
992 self.username.as_deref().or(self.address.as_deref())
993 }
994
995 pub fn is_microsoft(&self) -> bool {
998 self.kind == AccountKind::Imap && self.auth.as_deref() == Some("microsoft")
999 }
1000
1001 pub fn is_microsoft_work(&self) -> bool {
1010 self.is_microsoft() && self.microsoft_kind.as_deref() != Some("personal")
1011 }
1012
1013 pub fn ai_reads(&self) -> bool {
1017 self.shield && self.shield_ai && !self.is_microsoft_work()
1018 }
1019
1020 pub fn port_or_default(&self) -> u16 {
1022 self.port.unwrap_or(match self.security {
1023 Security::Tls => 993,
1024 Security::Starttls => 143,
1025 })
1026 }
1027
1028 pub fn smtp_port_or_default(&self) -> u16 {
1030 self.smtp_port.unwrap_or(match self.smtp_security {
1031 Security::Tls => 465,
1032 Security::Starttls => 587,
1033 })
1034 }
1035
1036 pub fn syncs(&self) -> bool {
1038 self.kind == AccountKind::Imap && self.host.is_some()
1039 }
1040
1041 pub fn dav(id: &str, address: &str, host: &str, url: Option<&str>, username: Option<&str>) -> Account {
1043 Account {
1044 kind: AccountKind::Dav,
1045 url: url.map(str::to_string),
1046 port: None,
1047 ..Account::imap(id, address, host, 0, Security::Tls, username)
1048 }
1049 }
1050
1051 pub fn is_dav(&self) -> bool {
1053 self.kind == AccountKind::Dav && self.host.is_some()
1054 }
1055}
1056
1057#[derive(Debug, Clone)]
1059pub struct Source {
1060 pub account: Option<String>,
1061 pub address: Option<String>,
1063 pub folder: PathBuf,
1064 pub trusted_ids: Vec<String>,
1065 pub priority: Priority,
1066 pub shielded: bool,
1068 pub words: std::sync::Arc<crate::words::Words>,
1070 pub spam: Option<crate::spam::Filter>,
1072}
1073
1074impl Config {
1075 pub fn account_of(&self, file: &Path) -> Option<&Account> {
1077 let syncing = || self.accounts.iter().filter(|a| a.syncs());
1078 syncing().find(|a| file.starts_with(a.maildir_path())).or_else(|| {
1082 let file = std::fs::canonicalize(file).ok()?;
1083 syncing().find(|a| std::fs::canonicalize(a.maildir_path()).is_ok_and(|root| file.starts_with(root)))
1084 })
1085 }
1086
1087 pub fn load(path: &Path) -> Result<Config, String> {
1091 let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
1092 let mut config: Config = toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
1093 config.settle();
1094 Ok(config)
1095 }
1096
1097 pub fn settle(&mut self) {
1099 let (sites, accounts): (Vec<Account>, Vec<Account>) = std::mem::take(&mut self.accounts).into_iter().partition(|a| a.kind == AccountKind::Portal);
1100 let mut all_sites = sites;
1101 all_sites.append(&mut self.sites);
1102 for site in &mut all_sites {
1103 site.kind = AccountKind::Portal;
1104 }
1105 if !self.site_order.is_empty() {
1106 all_sites.sort_by_key(|site| self.site_order.iter().position(|id| *id == site.id).unwrap_or(usize::MAX));
1107 }
1108 self.sites = all_sites;
1109 let (on, off): (Vec<Account>, Vec<Account>) = accounts.into_iter().partition(|a| a.enabled);
1110 self.accounts = on;
1111 self.accounts_off = off;
1112 let history = self.history_weeks;
1113 for account in self.accounts.iter_mut().chain(self.accounts_off.iter_mut()) {
1114 account.history_weeks = account.history_weeks.or(history);
1115 }
1116 }
1117
1118 pub fn site(&self, id: &str) -> Option<&Account> {
1120 self.sites.iter().find(|s| s.id == id)
1121 }
1122
1123 pub fn every_account(&self) -> impl Iterator<Item = &Account> {
1125 self.accounts.iter().chain(self.accounts_off.iter())
1126 }
1127
1128 pub fn history_days(&self) -> Option<i64> {
1130 history_days(self.history_weeks)
1131 }
1132
1133 pub fn notes_root_path(&self) -> Option<PathBuf> {
1136 self.notes_root.as_deref().map(expand_home).or_else(|| cfg!(target_os = "android").then(|| expand_home("~/Notes")))
1137 }
1138
1139 pub fn known_senders_path(&self) -> PathBuf {
1141 self.known_senders.as_deref().map_or_else(|| config_dir().join("known-senders.txt"), expand_home)
1142 }
1143
1144 pub fn blocked_senders_path(&self) -> PathBuf {
1146 self.blocked_senders.as_deref().map_or_else(|| config_dir().join("blocked-senders.txt"), expand_home)
1147 }
1148
1149 pub fn safe_senders_path(&self) -> PathBuf {
1151 config_dir().join("safe-senders.txt")
1152 }
1153
1154 pub fn restricted_senders_path(&self) -> PathBuf {
1156 config_dir().join("restricted-senders.txt")
1157 }
1158
1159 pub fn neutral_senders_path(&self) -> PathBuf {
1161 config_dir().join("neutral-senders.txt")
1162 }
1163
1164 pub fn all_trusted_ids(&self) -> Vec<String> {
1166 self.accounts.iter().flat_map(|a| a.trusted_authserv_ids.iter().cloned()).collect()
1167 }
1168
1169 pub fn account(&self, id: &str) -> Option<&Account> {
1171 self.accounts.iter().find(|a| a.id == id)
1172 }
1173
1174 pub fn mail_sources(&self) -> Vec<Source> {
1177 let own = sioul_authserv_id();
1178 let spam = crate::spam::Filter::of(self);
1179 let words = crate::words::Words::of(self);
1180 self.accounts
1181 .iter()
1182 .filter(|a| a.kind != AccountKind::Portal)
1183 .map(|a| Source {
1184 account: Some(a.id.clone()),
1185 address: a.address.clone(),
1186 folder: a.maildir_path(),
1187 trusted_ids: std::iter::once(own.clone()).chain(a.trusted_authserv_ids.iter().cloned()).collect(),
1188 priority: a.priority,
1189 shielded: a.shield,
1190 words: words.clone(),
1191 spam: Some(spam.clone()),
1192 })
1193 .collect()
1194 }
1195
1196 pub fn free_id(&self, address: &str) -> String {
1198 let (local, domain) = address.rsplit_once('@').unwrap_or((address, address));
1199 let label = domain.split('.').next().unwrap_or(domain);
1200 [label.to_string(), format!("{label}-{local}")]
1201 .into_iter()
1202 .chain((2..).map(|n| format!("{label}-{n}")))
1203 .map(|id| slug(&id))
1204 .find(|id| self.every_account().all(|a| &a.id != id))
1205 .unwrap_or_default()
1206 }
1207}
1208
1209pub fn slug(text: &str) -> String {
1211 let mapped: String = text.to_lowercase().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
1212 mapped.split('-').filter(|p| !p.is_empty()).collect::<Vec<_>>().join("-")
1213}
1214
1215pub fn add_imap_account(path: &Path, account: &Account) -> Result<(), String> {
1217 let host = account.host.as_deref().ok_or_else(|| format!("{}: no server", account.id))?;
1218 let mut table = Table::new();
1219 table["id"] = value(account.id.as_str());
1220 table["kind"] = value("imap");
1221 if let Some(address) = &account.address {
1222 table["address"] = value(address.as_str());
1223 }
1224 table["host"] = value(host);
1225 table["port"] = value(i64::from(account.port_or_default()));
1226 table["security"] = value(account.security.as_str());
1227 if let Some(username) = &account.username {
1228 table["username"] = value(username.as_str());
1229 }
1230 if let Some(days) = account.sync_days {
1231 table["sync_days"] = value(i64::from(days));
1232 }
1233 if let Some(auth) = &account.auth {
1235 table["auth"] = value(auth.as_str());
1236 }
1237 table["trusted_authserv_ids"] = value(account.trusted_authserv_ids.iter().map(String::as_str).collect::<Array>());
1238 append_account(path, table)
1239}
1240
1241pub fn add_dav_account(path: &Path, account: &Account) -> Result<(), String> {
1243 let host = account.host.as_deref().ok_or_else(|| format!("{}: no server", account.id))?;
1244 let mut table = Table::new();
1245 table["id"] = value(account.id.as_str());
1246 table["kind"] = value("dav");
1247 if let Some(address) = &account.address {
1248 table["address"] = value(address.as_str());
1249 }
1250 table["host"] = value(host);
1251 if let Some(url) = &account.url {
1252 table["url"] = value(url.as_str());
1253 }
1254 if let Some(username) = &account.username {
1255 table["username"] = value(username.as_str());
1256 }
1257 if let Some(auth) = &account.auth {
1258 table["auth"] = value(auth.as_str());
1259 }
1260 append_account(path, table)
1261}
1262
1263pub fn add_portal(path: &Path, id: &str, url: &str) -> Result<(), String> {
1265 add_site(path, id, "", url)
1266}
1267
1268pub fn add_site_as(path: &Path, id: &str, name: &str, url: &str, kind: &str, area: &str, announced_by: &[String], categories: &[String]) -> Result<(), String> {
1272 let mut table = Table::new();
1273 table["id"] = value(id);
1274 if !name.trim().is_empty() {
1275 table["name"] = value(name.trim());
1276 }
1277 table["url"] = value(url);
1278 table["site"] = value(crate::sites::type_of(kind));
1279 if let Some(area) = crate::areas::Area::parse(area) {
1280 table["area"] = value(area.id());
1281 }
1282 if !announced_by.is_empty() {
1283 let mut list = toml_edit::Array::new();
1284 for domain in announced_by.iter().map(|d| d.trim().trim_start_matches('@').to_lowercase()).filter(|d| !d.is_empty()) {
1285 list.push(domain);
1286 }
1287 table["announced_by"] = value(list);
1288 }
1289 let categories: Vec<&str> = categories.iter().map(|c| c.trim()).filter(|c| !c.is_empty()).collect();
1290 if !categories.is_empty() {
1291 let mut list = toml_edit::Array::new();
1292 for category in categories {
1293 list.push(category);
1294 }
1295 table["categories"] = value(list);
1296 }
1297 append_site(path, table)
1298}
1299
1300pub fn add_site(path: &Path, id: &str, name: &str, url: &str) -> Result<(), String> {
1302 let mut table = Table::new();
1303 table["id"] = value(id);
1304 if !name.trim().is_empty() {
1305 table["name"] = value(name.trim());
1306 }
1307 table["url"] = value(url);
1308 append_site(path, table)
1309}
1310
1311pub fn migrate_sites(path: &Path) -> Result<bool, String> {
1315 crate::filelock::with_lock(path, || {
1317 let mut doc = read_document(path)?;
1318 let Some(accounts) = doc.get_mut("account").and_then(Item::as_array_of_tables_mut) else { return Ok(false) };
1319 let is_site = |t: &Table| t.get("kind").and_then(Item::as_str) == Some("portal");
1320 let moving: Vec<Table> = accounts.iter().filter(|t| is_site(t)).cloned().collect();
1321 if moving.is_empty() {
1322 return Ok(false);
1323 }
1324 accounts.retain(|t| !is_site(t));
1325 if accounts.is_empty() {
1326 doc.remove("account");
1327 }
1328 let sites = doc.entry("site").or_insert(Item::ArrayOfTables(ArrayOfTables::new())).as_array_of_tables_mut().ok_or_else(|| format!("{}: `site` is not a list of tables", path.display()))?;
1329 for mut table in moving {
1330 table.remove("kind");
1331 sites.push(table);
1332 }
1333 write_document(path, &doc)?;
1334 Ok(true)
1335 })
1336}
1337
1338pub fn before_sites_path(path: &Path) -> PathBuf {
1340 path.with_file_name("config-before-sites.toml")
1341}
1342
1343pub fn migrate_sites_at_start(path: &Path) -> Result<bool, String> {
1349 if !std::fs::read_to_string(path).is_ok_and(|text| text.contains("kind = \"portal\"")) {
1350 return Ok(false);
1351 }
1352 let copy = before_sites_path(path);
1353 if !copy.exists() {
1354 std::fs::copy(path, ©).map_err(|e| format!("{}: {e}", copy.display()))?;
1355 }
1356 migrate_sites(path)
1357}
1358
1359fn append_site(path: &Path, mut table: Table) -> Result<(), String> {
1360 crate::filelock::with_lock(path, || {
1362 migrate_sites(path)?;
1363 let mut doc = read_document(path)?;
1364 let trailing = doc.trailing().as_str().unwrap_or("").trim_end().to_string();
1365 table.decor_mut().set_prefix(if trailing.trim().is_empty() { "\n".to_string() } else { format!("{trailing}\n\n") });
1366 doc.set_trailing("");
1367 let sites = doc.entry("site").or_insert(Item::ArrayOfTables(ArrayOfTables::new()));
1368 let sites = sites.as_array_of_tables_mut().ok_or_else(|| format!("{}: `site` is not a list of tables", path.display()))?;
1369 sites.push(table);
1370 write_document(path, &doc)
1371 })
1372}
1373
1374pub fn move_site(path: &Path, id: &str, delta: i64, order: bool) -> Result<(), String> {
1382 crate::filelock::with_lock(path, || {
1384 migrate_sites(path)?;
1385 let mut doc = read_document(path)?;
1386 let chosen: Vec<String> = doc.get("site_order").and_then(Item::as_array).map(|list| list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
1387 let Some(sites) = doc.get_mut("site").and_then(Item::as_array_of_tables_mut) else { return Ok(()) };
1388 let id_of = |table: &Table| table.get("id").and_then(Item::as_str).unwrap_or_default().to_string();
1389 let mut tables: Vec<Table> = sites.iter().cloned().collect();
1390 let slots: Vec<Option<isize>> = tables.iter().map(Table::position).collect();
1391 tables.sort_by_key(|table| chosen.iter().position(|chosen| *chosen == id_of(table)).unwrap_or(usize::MAX));
1392 let Some(from) = tables.iter().position(|t| id_of(t) == id) else { return Ok(()) };
1393 let Some(to) = usize::try_from(from as i64 + delta).ok().filter(|to| *to < tables.len()) else { return Ok(()) };
1394 tables.swap(from, to);
1395 let (with_order, order): (bool, Array) = (order, tables.iter().map(id_of).collect());
1397 let mut moved = ArrayOfTables::new();
1398 for (mut table, slot) in tables.into_iter().zip(slots) {
1399 table.set_position(slot);
1400 moved.push(table);
1401 }
1402 *sites = moved;
1403 if with_order || doc.contains_key("site_order") {
1404 doc["site_order"] = value(order);
1405 }
1406 write_document(path, &doc)
1407 })
1408}
1409
1410fn swap_tables(tables: &mut [Table], from: usize, to: usize) {
1414 let (a, b) = (tables[from].position(), tables[to].position());
1415 tables.swap(from, to);
1416 tables[from].set_position(a);
1417 tables[to].set_position(b);
1418}
1419
1420pub fn remove_site(path: &Path, id: &str) -> Result<(), String> {
1422 crate::filelock::with_lock(path, || {
1424 migrate_sites(path)?;
1425 let mut doc = read_document(path)?;
1426 let Some(sites) = doc.get_mut("site").and_then(Item::as_array_of_tables_mut) else { return Ok(()) };
1427 let before = sites.len();
1428 sites.retain(|t| t.get("id").and_then(Item::as_str) != Some(id));
1429 if sites.len() == before {
1430 return Err(format!("{}: no site {id}", path.display()));
1431 }
1432 write_document(path, &doc)
1433 })
1434}
1435
1436pub fn set_enabled(path: &Path, id: &str, on: bool) -> Result<(), String> {
1438 crate::filelock::with_lock(path, || {
1440 let mut doc = read_document(path)?;
1441 let table = doc
1442 .get_mut("account")
1443 .and_then(Item::as_array_of_tables_mut)
1444 .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1445 .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1446 if on {
1447 table.remove("enabled");
1448 } else {
1449 table["enabled"] = value(false);
1450 }
1451 write_document(path, &doc)
1452 })
1453}
1454
1455fn append_account(path: &Path, mut table: Table) -> Result<(), String> {
1456 crate::filelock::with_lock(path, || {
1458 let mut doc = read_document(path)?;
1459 let trailing = doc.trailing().as_str().unwrap_or("").trim_end().to_string();
1462 table.decor_mut().set_prefix(if trailing.trim().is_empty() { "\n".to_string() } else { format!("{trailing}\n\n") });
1463 doc.set_trailing("");
1464 let accounts = doc.entry("account").or_insert(Item::ArrayOfTables(ArrayOfTables::new()));
1465 let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| format!("{}: `account` is not a list of tables", path.display()))?;
1466 accounts.push(table);
1467 write_document(path, &doc)
1468 })
1469}
1470
1471pub fn move_account(path: &Path, id: &str, delta: i64) -> Result<(), String> {
1474 crate::filelock::with_lock(path, || {
1476 let mut doc = read_document(path)?;
1477 let Some(accounts) = doc.get_mut("account").and_then(Item::as_array_of_tables_mut) else { return Ok(()) };
1478 let mut tables: Vec<Table> = accounts.iter().cloned().collect();
1479 let kind_of = |t: &Table| t.get("kind").and_then(Item::as_str).unwrap_or("").to_string();
1480 let Some(from) = tables.iter().position(|t| t.get("id").and_then(Item::as_str) == Some(id)) else { return Ok(()) };
1481 let kind = kind_of(&tables[from]);
1482 let same: Vec<usize> = tables.iter().enumerate().filter(|(_, t)| kind_of(t) == kind).map(|(i, _)| i).collect();
1483 let at = same.iter().position(|&i| i == from).unwrap_or(0) as i64;
1484 let Some(&to) = usize::try_from(at + delta).ok().and_then(|t| same.get(t)) else { return Ok(()) };
1485 swap_tables(&mut tables, from, to);
1487 let mut moved = ArrayOfTables::new();
1488 for table in tables {
1489 moved.push(table);
1490 }
1491 *accounts = moved;
1492 write_document(path, &doc)
1493 })
1494}
1495
1496pub fn remove_account(path: &Path, id: &str) -> Result<(), String> {
1498 crate::filelock::with_lock(path, || {
1500 let mut doc = read_document(path)?;
1501 let accounts = doc.get_mut("account").and_then(Item::as_array_of_tables_mut);
1502 let Some(accounts) = accounts else { return Ok(()) };
1503 let index = accounts.iter().position(|t| t.get("id").and_then(Item::as_str) == Some(id));
1504 if let Some(index) = index {
1505 accounts.remove(index);
1506 }
1507 write_document(path, &doc)
1508 })
1509}
1510
1511pub fn set_trusted_ids(path: &Path, id: &str, ids: &[String]) -> Result<(), String> {
1513 set_account_item(path, id, "trusted_authserv_ids", value(ids.iter().map(String::as_str).collect::<Array>()))
1514}
1515
1516pub fn set_auth(path: &Path, id: &str, auth: Option<&str>) -> Result<(), String> {
1520 crate::filelock::with_lock(path, || {
1522 match auth {
1523 Some(auth) => set_account_item(path, id, "auth", value(auth)),
1524 None => {
1525 let mut doc = read_document(path)?;
1526 let table = doc
1527 .get_mut("account")
1528 .and_then(Item::as_array_of_tables_mut)
1529 .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1530 .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1531 table.remove("auth");
1532 write_document(path, &doc)
1533 }
1534 }
1535 })
1536}
1537
1538pub fn set_sign_in(path: &Path, account: &Account) -> Result<(), String> {
1542 crate::filelock::with_lock(path, || {
1544 let mut doc = read_document(path)?;
1545 let id = account.id.as_str();
1546 let table = doc
1547 .get_mut("account")
1548 .and_then(Item::as_array_of_tables_mut)
1549 .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1550 .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1551 match &account.auth {
1552 Some(auth) => table["auth"] = value(auth.as_str()),
1553 None => drop(table.remove("auth")),
1554 }
1555 if let Some(host) = &account.host {
1556 table["host"] = value(host.as_str());
1557 }
1558 table["port"] = value(i64::from(account.port_or_default()));
1559 table["security"] = value(account.security.as_str());
1560 match &account.username {
1561 Some(username) => table["username"] = value(username.as_str()),
1562 None => drop(table.remove("username")),
1563 }
1564 match &account.microsoft_kind {
1565 Some(kind) => table["microsoft_kind"] = value(kind.as_str()),
1566 None => drop(table.remove("microsoft_kind")),
1567 }
1568 if let Some(host) = &account.smtp_host {
1569 table["smtp_host"] = value(host.as_str());
1570 table["smtp_port"] = value(i64::from(account.smtp_port_or_default()));
1571 table["smtp_security"] = value(account.smtp_security.as_str());
1572 }
1573 write_document(path, &doc)
1574 })
1575}
1576
1577pub fn set_smtp(path: &Path, id: &str, host: &str, port: u16, security: Security) -> Result<(), String> {
1579 set_account_item(path, id, "smtp_host", value(host))?;
1580 set_account_item(path, id, "smtp_port", value(i64::from(port)))?;
1581 set_account_item(path, id, "smtp_security", value(security.as_str()))
1582}
1583
1584pub fn history_days(weeks: Option<u32>) -> Option<i64> {
1586 match weeks.unwrap_or(DEFAULT_HISTORY_WEEKS) {
1587 0 => None,
1588 weeks => Some(i64::from(weeks) * 7),
1589 }
1590}
1591
1592pub const DEFAULT_HISTORY_WEEKS: u32 = 2;
1594
1595pub const DEFAULT_FETCH_MINUTES: u32 = 5;
1597
1598pub fn set_history(path: &Path, weeks: u32) -> Result<(), String> {
1600 crate::filelock::with_lock(path, || {
1602 let mut doc = read_document(path)?;
1603 doc["history_weeks"] = value(i64::from(weeks));
1604 write_document(path, &doc)
1605 })
1606}
1607
1608pub fn set_priority(path: &Path, id: &str, priority: Priority) -> Result<(), String> {
1610 set_account_item(path, id, "priority", value(priority.as_str()))
1611}
1612
1613pub fn set_writing(path: &Path, id: &str, name: &str, signature: &str) -> Result<(), String> {
1615 crate::filelock::with_lock(path, || {
1617 let mut doc = read_document(path)?;
1618 let table = doc
1619 .get_mut("account")
1620 .and_then(Item::as_array_of_tables_mut)
1621 .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1622 .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1623 for (key, text) in [("name", name.trim()), ("signature", signature.trim())] {
1624 if text.is_empty() {
1625 table.remove(key);
1626 } else {
1627 table[key] = value(text);
1628 }
1629 }
1630 write_document(path, &doc)
1631 })
1632}
1633
1634#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1636#[serde(untagged)]
1637pub enum SettingValue {
1638 Bool(bool),
1639 Int(i64),
1640 Float(f64),
1641 Text(String),
1642 Ints(Vec<i64>),
1643 Texts(Vec<String>),
1644 Windows(Vec<WindowValue>),
1645 TimeOff(Vec<TimeOffValue>),
1646 Routes(Vec<crate::projects::RouteValue>),
1647 Named(Vec<NamedValue>),
1649 Rename(RenameValue),
1651}
1652
1653#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1655pub struct NamedValue {
1656 pub id: String,
1657 pub label: String,
1658 #[serde(default, skip_serializing_if = "String::is_empty")]
1660 pub locked: String,
1661 #[serde(default, skip_serializing_if = "String::is_empty")]
1663 pub detail: String,
1664}
1665
1666#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1668pub struct RenameValue {
1669 pub from: String,
1670 pub to: String,
1671}
1672
1673#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1675pub struct TimeOffValue {
1676 #[serde(default, skip_serializing_if = "String::is_empty")]
1678 pub id: String,
1679 pub from: String,
1681 pub until: String,
1682 #[serde(default)]
1683 pub label: String,
1684}
1685
1686#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1688pub struct WindowValue {
1689 pub day: String,
1690 pub start: String,
1691 #[serde(default)]
1693 pub end: String,
1694 #[serde(default)]
1696 pub minutes: u32,
1697}
1698
1699impl SettingValue {
1700 fn item(&self) -> Option<Item> {
1702 Some(match self {
1703 SettingValue::Bool(b) => value(*b),
1704 SettingValue::Int(n) => value(*n),
1705 SettingValue::Float(f) => value(*f),
1706 SettingValue::Text(t) if t.trim().is_empty() => return None,
1707 SettingValue::Text(t) => value(t.trim()),
1708 SettingValue::Ints(list) if list.is_empty() => return None,
1709 SettingValue::Ints(list) => value(list.iter().copied().collect::<Array>()),
1710 SettingValue::Texts(list) if list.is_empty() => return None,
1711 SettingValue::Texts(list) => value(list.iter().map(String::as_str).collect::<Array>()),
1712 SettingValue::Windows(_) | SettingValue::TimeOff(_) | SettingValue::Routes(_) | SettingValue::Named(_) | SettingValue::Rename(_) => return None,
1713 })
1714 }
1715}
1716
1717pub fn set_value(path: &Path, key: &str, setting: &SettingValue) -> Result<(), String> {
1722 crate::filelock::with_lock(path, || {
1724 let mut doc = read_document(path)?;
1725 if key == "time_off" {
1726 let days: &[TimeOffValue] = match setting {
1727 SettingValue::TimeOff(days) => days,
1728 SettingValue::Ints(none) if none.is_empty() => &[],
1730 _ => return Err(format!("{key}: days off expected")),
1731 };
1732 let date = |text: &str| -> Result<toml_edit::Datetime, String> {
1733 let d: jiff::civil::Date = text.trim().parse().map_err(|_| format!("{text}: a day as 2026-12-24"))?;
1734 Ok(toml_edit::Datetime { date: Some(toml_edit::Date { year: d.year() as u16, month: d.month() as u8, day: d.day() as u8 }), time: None, offset: None })
1735 };
1736 let mut there: Vec<Table> = doc.get(key).and_then(Item::as_array_of_tables).map(|list| list.iter().cloned().collect()).unwrap_or_default();
1740 let with_ids = there.iter().any(|t| t.get("id").and_then(Item::as_str).is_some_and(|id| !id.is_empty()));
1741 let same = |t: &Table, from: &toml_edit::Datetime, until: &toml_edit::Datetime, label: &str| {
1742 t.get("from").and_then(Item::as_datetime) == Some(from) && t.get("until").and_then(Item::as_datetime) == Some(until) && t.get("label").and_then(Item::as_str).unwrap_or_default() == label
1743 };
1744 let mut tables = ArrayOfTables::new();
1745 for off in days {
1746 let (from, until, label) = (date(&off.from)?, date(&off.until)?, off.label.trim());
1747 if let Some(at) = there.iter().position(|t| !off.id.is_empty() && t.get("id").and_then(Item::as_str) == Some(off.id.as_str())) {
1749 let mut table = there.remove(at);
1750 table["from"] = value(from);
1751 table["until"] = value(until);
1752 if label.is_empty() {
1753 table.remove("label");
1754 } else {
1755 table["label"] = value(label);
1756 }
1757 tables.push(table);
1758 continue;
1759 }
1760 if let Some(at) = there.iter().position(|t| same(t, &from, &until, label)) {
1761 tables.push(there.remove(at));
1762 continue;
1763 }
1764 let mut table = Table::new();
1765 if with_ids {
1766 table["id"] = value(crate::ids::new());
1767 }
1768 table["from"] = value(from);
1769 table["until"] = value(until);
1770 if !label.is_empty() {
1771 table["label"] = value(label);
1772 }
1773 tables.push(table);
1774 }
1775 if days.is_empty() {
1776 doc.remove(key);
1777 } else {
1778 doc[key] = Item::ArrayOfTables(tables);
1779 }
1780 return write_document(path, &doc);
1781 }
1782 if key == "window" || key == "window.admin" || key == "window.leisure" || key == "office_hours" {
1783 let windows: &[WindowValue] = match setting {
1784 SettingValue::Windows(windows) => windows,
1785 SettingValue::Ints(none) if none.is_empty() => &[],
1787 _ => return Err(format!("{key}: windows expected")),
1788 };
1789 let (table_key, kind) = match key {
1791 "window.admin" => ("window", "admin"),
1792 "window.leisure" => ("window", "leisure"),
1793 "window" => ("window", "work"),
1794 other => (other, ""),
1795 };
1796 let kind_of = |t: &Table| match t.get("kind").and_then(Item::as_str) {
1797 Some("admin") => "admin",
1798 Some("leisure") => "leisure",
1799 _ => "work",
1800 };
1801 let mut tables = ArrayOfTables::new();
1802 if !kind.is_empty()
1803 && let Some(old) = doc.get(table_key).and_then(Item::as_array_of_tables)
1804 {
1805 for table in old.iter().filter(|t| kind_of(t) != kind) {
1806 tables.push(table.clone());
1807 }
1808 }
1809 let mut seen: Vec<String> = Vec::new();
1811 for w in windows {
1812 if seen.contains(&w.day) {
1813 continue;
1814 }
1815 seen.push(w.day.clone());
1816 let mut table = Table::new();
1817 table["day"] = value(w.day.as_str());
1818 table["start"] = value(w.start.as_str());
1819 if w.end.is_empty() {
1820 table["minutes"] = value(i64::from(w.minutes));
1821 } else {
1822 table["end"] = value(w.end.as_str());
1823 }
1824 if kind == "admin" || kind == "leisure" {
1825 table["kind"] = value(kind);
1826 }
1827 tables.push(table);
1828 }
1829 if tables.is_empty() {
1830 doc.remove(table_key);
1831 } else {
1832 doc[table_key] = Item::ArrayOfTables(tables);
1833 }
1834 return write_document(path, &doc);
1835 }
1836 if let Some(rest) = key.strip_prefix("site.") {
1838 let (id, field) = rest.rsplit_once('.').ok_or_else(|| format!("{key}: site.<id>.<field> expected"))?;
1839 drop(doc);
1840 migrate_sites(path)?;
1841 let mut doc = read_document(path)?;
1842 let table = doc
1843 .get_mut("site")
1844 .and_then(Item::as_array_of_tables_mut)
1845 .and_then(|sites| sites.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1846 .ok_or_else(|| format!("{}: no site {id}", path.display()))?;
1847 match setting.item() {
1848 Some(item) => table[field] = item,
1849 None => {
1850 table.remove(field);
1851 }
1852 }
1853 return write_document(path, &doc);
1854 }
1855 if let Some(rest) = key.strip_prefix("account.") {
1856 let (id, field) = rest.rsplit_once('.').ok_or_else(|| format!("{key}: account.<id>.<field> expected"))?;
1857 let table = doc
1858 .get_mut("account")
1859 .and_then(Item::as_array_of_tables_mut)
1860 .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1861 .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1862 match setting.item() {
1863 Some(item) => table[field] = item,
1864 None => {
1865 table.remove(field);
1866 }
1867 }
1868 return write_document(path, &doc);
1869 }
1870 match key.split_once('.') {
1871 Some((table, field)) => {
1872 if !doc.contains_key(table) {
1873 doc[table] = toml_edit::table();
1874 }
1875 let section = doc[table].as_table_like_mut().ok_or_else(|| format!("{}: {table} is not a table", path.display()))?;
1877 match setting.item() {
1878 Some(item) => match section.get_mut(field) {
1880 Some(slot) => *slot = item,
1881 None => {
1882 section.insert(field, item);
1883 }
1884 },
1885 None => {
1886 section.remove(field);
1887 }
1888 }
1889 }
1890 None => match setting.item() {
1891 Some(item) => doc[key] = item,
1892 None => {
1893 doc.remove(key);
1894 }
1895 },
1896 }
1897 write_document(path, &doc)
1898 })
1899}
1900
1901fn set_account_item(path: &Path, id: &str, key: &str, item: Item) -> Result<(), String> {
1902 crate::filelock::with_lock(path, || {
1904 let mut doc = read_document(path)?;
1905 let table = doc
1906 .get_mut("account")
1907 .and_then(Item::as_array_of_tables_mut)
1908 .and_then(|accounts| accounts.iter_mut().find(|t| t.get("id").and_then(Item::as_str) == Some(id)))
1909 .ok_or_else(|| format!("{}: no account {id}", path.display()))?;
1910 table[key] = item;
1911 write_document(path, &doc)
1912 })
1913}
1914
1915pub fn set_table(path: &Path, table: &str, keys: &[(String, Option<Vec<String>>)], remove: &[&str]) -> Result<(), String> {
1920 crate::filelock::with_lock(path, || {
1922 let mut doc = read_document(path)?;
1923 for key in remove {
1924 match key.split_once('.') {
1925 Some((outer, field)) => {
1926 if let Some(section) = doc.get_mut(outer).and_then(Item::as_table_like_mut) {
1927 section.remove(field);
1928 }
1929 }
1930 None => {
1931 doc.remove(key);
1932 }
1933 }
1934 }
1935 if !doc.contains_key(table) {
1936 doc[table] = toml_edit::table();
1937 }
1938 let section = doc[table].as_table_like_mut().ok_or_else(|| format!("{}: {table} is not a table", path.display()))?;
1939 for (key, words) in keys {
1940 match words {
1941 Some(words) => {
1942 let item = value(words.iter().map(String::as_str).collect::<Array>());
1943 match section.get_mut(key) {
1944 Some(slot) => *slot = item,
1945 None => {
1946 section.insert(key, item);
1947 }
1948 }
1949 }
1950 None => {
1951 section.remove(key);
1952 }
1953 }
1954 }
1955 write_document(path, &doc)
1956 })
1957}
1958
1959pub fn set_filters(path: &Path, filters: &[crate::rules::Filter]) -> Result<(), String> {
1964 crate::filelock::with_lock(path, || {
1966 let mut doc = read_document(path)?;
1967 let before: Vec<(crate::rules::Filter, Table)> = doc
1971 .get("mail")
1972 .and_then(|mail| mail.get("filter"))
1973 .and_then(Item::as_array_of_tables)
1974 .map(|list| list.iter().filter_map(|table| toml::from_str::<crate::rules::Filter>(&table.to_string()).ok().map(|filter| (filter, table.clone()))).collect())
1975 .unwrap_or_default();
1976 if !doc.contains_key("mail") {
1977 let mut mail = Table::new();
1978 mail.set_implicit(true);
1979 doc["mail"] = Item::Table(mail);
1980 }
1981 let mail = doc["mail"].as_table_mut().ok_or_else(|| format!("{}: mail is not a table", path.display()))?;
1982 if filters.is_empty() {
1983 mail.remove("filter");
1984 } else {
1985 let mut tables = ArrayOfTables::new();
1986 for filter in filters {
1987 match before.iter().find(|(was, _)| filter.unknown() && was == filter) {
1988 Some((_, was)) => {
1990 let mut kept = Table::new();
1991 for (key, item) in was.iter() {
1992 kept.insert(key, item.clone());
1993 }
1994 tables.push(kept);
1995 }
1996 None => tables.push(filter_table(filter)?),
1997 }
1998 }
1999 mail.insert("filter", Item::ArrayOfTables(tables));
2000 }
2001 write_document(path, &doc)
2002 })
2003}
2004
2005fn filter_table(filter: &crate::rules::Filter) -> Result<Table, String> {
2008 let text = toml::to_string(filter).map_err(|e| e.to_string())?;
2009 let written: DocumentMut = text.parse().map_err(|e| format!("{e}"))?;
2010 let mut table = Table::new();
2011 for (key, item) in written.iter() {
2012 let item = match item {
2013 Item::ArrayOfTables(list) => {
2014 let mut array = Array::new();
2015 for element in list.iter() {
2016 let mut inline = element.clone().into_inline_table();
2017 inline.fmt();
2018 let mut element = toml_edit::Value::InlineTable(inline);
2019 element.decor_mut().set_prefix("\n ");
2020 array.push_formatted(element);
2021 }
2022 array.set_trailing_comma(true);
2023 array.set_trailing("\n");
2024 value(array)
2025 }
2026 other => other.clone(),
2027 };
2028 table.insert(key, item);
2029 }
2030 Ok(table)
2031}
2032
2033pub(crate) fn read_document(path: &Path) -> Result<DocumentMut, String> {
2034 let text = match std::fs::read_to_string(path) {
2035 Ok(text) => text,
2036 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::from("# Sioul configuration (examples/config.toml explains each setting).\n"),
2037 Err(e) => return Err(format!("{}: {e}", path.display())),
2038 };
2039 text.parse().map_err(|e| format!("{}: {e}", path.display()))
2040}
2041
2042pub(crate) fn write_document(path: &Path, doc: &DocumentMut) -> Result<(), String> {
2046 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
2047 let target = std::fs::canonicalize(path).ok().filter(|_| path.is_symlink());
2048 let path = target.as_deref().unwrap_or(path);
2049 let text = doc.to_string();
2050 if let Err(e) = toml::from_str::<Config>(&text)
2053 && std::fs::read_to_string(path).is_ok_and(|old| toml::from_str::<Config>(&old).is_ok())
2054 {
2055 return Err(format!("{}: {e}", path.display()));
2056 }
2057 if let Some(parent) = path.parent() {
2058 std::fs::create_dir_all(parent).map_err(fail)?;
2059 }
2060 let temporary = path.with_extension("toml.new");
2061 std::fs::write(&temporary, text).map_err(fail)?;
2062 std::fs::rename(&temporary, path).map_err(fail)
2063}
2064
2065pub fn sioul_authserv_id() -> String {
2069 let path = state_dir().join("authserv-id");
2070 if let Some(id) = std::fs::read_to_string(&path).ok().map(|t| t.trim().to_string()).filter(|t| t.ends_with(".invalid")) {
2071 return id;
2072 }
2073 use std::hash::{Hash, Hasher};
2074 let mut hasher = std::collections::hash_map::DefaultHasher::new();
2075 std::time::SystemTime::now().hash(&mut hasher);
2076 std::process::id().hash(&mut hasher);
2077 path.hash(&mut hasher);
2078 let id = format!("sioul-{:012x}.invalid", hasher.finish() & 0xffff_ffff_ffff);
2079 let _ = std::fs::create_dir_all(state_dir()).and_then(|()| std::fs::write(&path, &id));
2080 id
2081}
2082
2083pub fn default_path() -> PathBuf {
2085 config_dir().join("config.toml")
2086}
2087
2088pub fn config_dir() -> PathBuf {
2090 xdg("XDG_CONFIG_HOME", "~/.config").join("sioul")
2091}
2092
2093pub fn data_dir() -> PathBuf {
2095 xdg("XDG_DATA_HOME", "~/.local/share").join("sioul")
2096}
2097
2098pub fn state_dir() -> PathBuf {
2100 xdg("XDG_STATE_HOME", "~/.local/state").join("sioul")
2101}
2102
2103fn xdg(variable: &str, fallback: &str) -> PathBuf {
2107 if let Some(value) = std::env::var_os(variable).filter(|v| !v.is_empty()) {
2108 return PathBuf::from(value);
2109 }
2110 #[cfg(any(windows, target_os = "macos"))]
2111 if let Some(dirs) = directories::BaseDirs::new() {
2112 return match variable {
2113 "XDG_CONFIG_HOME" => dirs.config_dir().to_path_buf(),
2114 "XDG_CACHE_HOME" => dirs.cache_dir().to_path_buf(),
2115 "XDG_STATE_HOME" => dirs.data_local_dir().join("state"),
2116 _ => dirs.data_local_dir().to_path_buf(),
2117 };
2118 }
2119 expand_home(fallback)
2120}
2121
2122pub fn cache_dir() -> PathBuf {
2124 xdg("XDG_CACHE_HOME", "~/.cache").join("sioul")
2125}
2126
2127pub fn make_private_dirs() {
2134 #[cfg(unix)]
2135 for dir in [config_dir(), data_dir(), state_dir(), cache_dir()] {
2136 let _ = private_dir(&dir);
2137 }
2138}
2139
2140#[cfg(unix)]
2142fn private_dir(dir: &Path) -> std::io::Result<()> {
2143 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
2144 if let Some(parent) = dir.parent() {
2145 std::fs::create_dir_all(parent)?;
2146 }
2147 match std::fs::DirBuilder::new().mode(0o700).create(dir) {
2148 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2149 let metadata = std::fs::symlink_metadata(dir)?;
2150 if metadata.is_dir() && metadata.permissions().mode() & 0o077 != 0 {
2151 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
2152 }
2153 Ok(())
2154 }
2155 other => other,
2156 }
2157}
2158
2159pub fn downloads_dir() -> PathBuf {
2161 #[cfg(any(windows, target_os = "macos"))]
2162 if let Some(dir) = directories::UserDirs::new().and_then(|u| u.download_dir().map(Path::to_path_buf)) {
2163 return dir;
2164 }
2165 let dirs = expand_home("~/.config/user-dirs.dirs");
2166 let named = std::fs::read_to_string(dirs).ok().and_then(|text| {
2167 let line = text.lines().find(|l| l.trim_start().starts_with("XDG_DOWNLOAD_DIR="))?;
2168 let value = line.split_once('=')?.1.trim().trim_matches('"');
2169 Some(PathBuf::from(value.replace("$HOME", &std::env::var("HOME").unwrap_or_default())))
2170 });
2171 named.unwrap_or_else(|| expand_home("~/Downloads"))
2172}
2173
2174pub fn expand_home(path: &str) -> PathBuf {
2177 let (first, second) = if cfg!(windows) { ("USERPROFILE", "HOME") } else { ("HOME", "USERPROFILE") };
2179 let home = std::env::var_os(first).or_else(|| std::env::var_os(second)).map(PathBuf::from);
2180 match (path.strip_prefix('~'), home) {
2181 (Some(rest), Some(home)) => home.join(rest.trim_start_matches(['/', '\\'])),
2183 _ => PathBuf::from(path),
2184 }
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189 use super::*;
2190
2191 #[cfg(unix)]
2195 #[test]
2196 fn an_account_is_found_through_a_link() {
2197 let dir = std::env::temp_dir().join(format!("sioul-account-link-{}", std::process::id()));
2198 let _ = std::fs::remove_dir_all(&dir);
2199 std::fs::create_dir_all(dir.join("real/mail-a/cur")).unwrap();
2200 std::os::unix::fs::symlink(dir.join("real"), dir.join("link")).unwrap();
2201 let file = dir.join("real/mail-a/cur/1.x:2,S");
2202 std::fs::write(&file, "Subject: x\r\n\r\nx").unwrap();
2203 let config: Config = toml::from_str(&format!("[[account]]\nid = \"a\"\nkind = \"imap\"\nhost = \"imap.example.org\"\nmaildir = \"{}\"\n", dir.join("link/mail-a").display())).unwrap();
2204 let resolved = std::fs::canonicalize(&file).unwrap();
2205 assert_eq!(config.account_of(&resolved).map(|a| a.id.as_str()), Some("a"));
2206 assert_eq!(config.account_of(&dir.join("link/mail-a/cur/1.x:2,S")).map(|a| a.id.as_str()), Some("a"));
2207 assert!(config.account_of(&dir.join("elsewhere/1.x")).is_none());
2208 let _ = std::fs::remove_dir_all(&dir);
2209 }
2210
2211 #[test]
2212 fn settings_are_written_in_place() {
2213 let dir = std::env::temp_dir().join(format!("sioul-settings-{}", std::process::id()));
2214 std::fs::create_dir_all(&dir).unwrap();
2215 let path = dir.join("config.toml");
2216 std::fs::write(&path, "# Mine.\ncase_store = \"~/Notes\"\n\n[[account]]\nid = \"a\"\nkind = \"imap\"\n").unwrap();
2217 set_value(&path, "reading.spacing", &SettingValue::Float(1.6)).unwrap();
2218 set_value(&path, "some_days", &SettingValue::Ints(vec![30, 30, 30, 30, 30, 0, 0])).unwrap();
2219 set_value(&path, "account.a.shield", &SettingValue::Bool(true)).unwrap();
2220 set_value(&path, "window", &SettingValue::Windows(vec![WindowValue { day: "tuesday".into(), start: "10:00".into(), end: "17:00".into(), minutes: 0 }])).unwrap();
2221 set_value(&path, "window.admin", &SettingValue::Windows(vec![WindowValue { day: "tuesday".into(), start: "18:00".into(), end: "19:00".into(), minutes: 0 }])).unwrap();
2223 let both = Config::load(&path).unwrap();
2224 assert_eq!(both.working_hours().len(), 1);
2225 assert_eq!(both.week_hours().iter().filter(|w| w.kind() == "admin").count(), 1);
2226 set_value(&path, "window", &SettingValue::Windows(vec![WindowValue { day: "monday".into(), start: "09:00".into(), end: "17:00".into(), minutes: 0 }])).unwrap();
2227 let again = Config::load(&path).unwrap();
2228 assert_eq!(again.working_hours()[0].day, "monday");
2229 assert_eq!(again.week_hours().iter().filter(|w| w.kind() == "admin").map(|w| w.start.as_str()).collect::<Vec<_>>(), vec!["18:00"]);
2230 set_value(&path, "case_store", &SettingValue::Text("~/Notes".into())).unwrap();
2231 set_value(&path, "reading.size", &SettingValue::Int(18)).unwrap();
2232 set_value(&path, "reading.size", &SettingValue::Text(String::new())).unwrap();
2233 let config = Config::load(&path).unwrap();
2234 assert_eq!((config.reading.spacing, config.accounts[0].shield), (1.6, true));
2235 assert!(std::fs::read_to_string(&path).unwrap().contains("some_days = [30, 30, 30, 30, 30, 0, 0]"));
2236 assert_eq!((config.windows.len(), config.notes_root.as_deref(), config.reading.size), (2, Some("~/Notes"), 16));
2237 assert!(std::fs::read_to_string(&path).unwrap().starts_with("# Mine."));
2238 let empty: SettingValue = serde_json::from_str("[]").unwrap();
2240 set_value(&path, "time_off", &SettingValue::TimeOff(vec![TimeOffValue { id: String::new(), from: "2026-12-24".into(), until: "2026-12-26".into(), label: String::new() }])).unwrap();
2241 set_value(&path, "time_off", &empty).unwrap();
2242 set_value(&path, "window.admin", &empty).unwrap();
2243 let config = Config::load(&path).unwrap();
2244 assert!(config.time_off.is_empty() && config.week_hours().iter().all(|w| w.kind() != "admin"));
2245 std::fs::write(&path, "# Mine.\n# Easier to read.\nreading = { size = 18 }\n").unwrap();
2247 set_value(&path, "reading.spacing", &SettingValue::Float(1.8)).unwrap();
2248 set_value(&path, "reading.size", &SettingValue::Int(20)).unwrap();
2249 let text = std::fs::read_to_string(&path).unwrap();
2250 assert!(text.contains("# Easier to read.\nreading = {"), "{text}");
2251 assert_eq!((Config::load(&path).unwrap().reading.size, Config::load(&path).unwrap().reading.spacing), (20, 1.8));
2252 assert!(set_value(&path, "reading.size", &SettingValue::Float(1.5)).is_err());
2254 assert_eq!(Config::load(&path).unwrap().reading.size, 20);
2255 std::fs::remove_dir_all(&dir).unwrap();
2256 }
2257
2258 #[test]
2259 fn sites_keep_the_order_chosen() {
2260 let dir = std::env::temp_dir().join(format!("sioul-order-{}", std::process::id()));
2261 let path = dir.join("config.toml");
2262 std::fs::create_dir_all(&dir).unwrap();
2263 std::fs::write(&path, "# Mine.\n[[site]]\nid = \"a\"\nurl = \"https://a.example.org\"\n\n# The second.\n[[site]]\nid = \"b\"\nurl = \"https://b.example.org\"\n").unwrap();
2264 move_site(&path, "b", -1, true).unwrap();
2265 let ids = |path: &Path| Config::load(path).unwrap().sites.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
2266 assert_eq!(ids(&path), ["b", "a"]);
2267 let text = std::fs::read_to_string(&path).unwrap();
2268 assert!(text.contains("# The second.\n[[site]]\nid = \"b\""), "{text}");
2269 std::fs::write(&path, "[[account]]\nid = \"one\"\nkind = \"imap\"\n\n[[account]]\nid = \"two\"\nkind = \"imap\"\n").unwrap();
2270 move_account(&path, "two", -1).unwrap();
2271 assert_eq!(Config::load(&path).unwrap().accounts.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(), ["two", "one"]);
2272 std::fs::remove_dir_all(&dir).unwrap();
2273 }
2274
2275 #[test]
2280 fn the_sites_order_travels_as_its_own_setting() {
2281 let dir = std::env::temp_dir().join(format!("sioul-site-order-{}", std::process::id()));
2282 let path = dir.join("config.toml");
2283 std::fs::create_dir_all(&dir).unwrap();
2284 let ids = |path: &Path| Config::load(path).unwrap().sites.iter().map(|s| s.id.clone()).collect::<Vec<_>>();
2285 let three = "[[site]]\nid = \"a\"\nurl = \"https://a.example.org\"\n\n[[site]]\nid = \"b\"\nurl = \"https://b.example.org\"\n\n[[site]]\nid = \"c\"\nurl = \"https://c.example.org\"\n";
2286 std::fs::write(&path, three).unwrap();
2287 move_site(&path, "c", -1, false).unwrap();
2288 assert_eq!(ids(&path), ["a", "c", "b"]);
2289 assert!(!std::fs::read_to_string(&path).unwrap().contains("site_order"), "format 1: the list's own order travels");
2290 move_site(&path, "a", 1, true).unwrap();
2291 assert_eq!(ids(&path), ["c", "a", "b"]);
2292 assert!(std::fs::read_to_string(&path).unwrap().contains("site_order = [\"c\", \"a\", \"b\"]"));
2293 std::fs::write(&path, format!("site_order = [\"b\", \"a\"]\n{three}")).unwrap();
2295 assert_eq!(ids(&path), ["b", "a", "c"]);
2296 move_site(&path, "c", -1, true).unwrap();
2297 assert_eq!(ids(&path), ["b", "c", "a"]);
2298 std::fs::remove_dir_all(&dir).unwrap();
2299 }
2300
2301 #[cfg(unix)]
2303 #[test]
2304 fn a_linked_configuration_stays_linked() {
2305 let dir = std::env::temp_dir().join(format!("sioul-linked-{}", std::process::id()));
2306 let _ = std::fs::remove_dir_all(&dir);
2307 std::fs::create_dir_all(dir.join("dotfiles")).unwrap();
2308 let (real, link) = (dir.join("dotfiles").join("config.toml"), dir.join("config.toml"));
2309 std::fs::write(&real, "# Mine.\n").unwrap();
2310 std::os::unix::fs::symlink(&real, &link).unwrap();
2311 set_history(&link, 4).unwrap();
2312 assert!(link.is_symlink());
2313 assert!(std::fs::read_to_string(&real).unwrap().contains("history_weeks = 4"));
2314 std::fs::remove_dir_all(&dir).unwrap();
2315 }
2316
2317 #[test]
2318 fn history_goes_above_the_accounts() {
2319 let path = std::env::temp_dir().join(format!("sioul-history-{}.toml", std::process::id()));
2320 std::fs::write(&path, "# mine\nlanguage = \"fr\"\n\n[[account]]\nid = \"a\"\nkind = \"imap\"\n").unwrap();
2321 set_history(&path, 0).unwrap();
2322 let text = std::fs::read_to_string(&path).unwrap();
2323 assert!(text.find("history_weeks = 0").unwrap() < text.find("[[account]]").unwrap(), "{text}");
2324 let config = Config::load(&path).unwrap();
2325 assert_eq!((config.history_days(), config.accounts[0].history_days()), (None, None));
2326 set_history(&path, 6).unwrap();
2327 assert_eq!(Config::load(&path).unwrap().accounts[0].history_days(), Some(42));
2328 std::fs::remove_file(&path).unwrap();
2329 }
2330
2331 #[test]
2332 fn kinds_named_your_way() {
2333 let path = std::env::temp_dir().join(format!("sioul-kinds-{}.toml", std::process::id()));
2334 std::fs::write(&path, "# mine\n[tasks]\nestimate = 20\n\n[[account]]\nid = \"a\"\nkind = \"imap\"\n").unwrap();
2335 let tr = crate::i18n::Translator::new("en");
2336 let ids = |config: &Config| config.task_kinds(&tr).into_iter().map(|(id, _)| id).collect::<Vec<_>>();
2337 let config = Config::load(&path).unwrap();
2338 assert_eq!(ids(&config).len(), crate::tasks::KINDS.len(), "Sioul's kinds until you name yours");
2339 change_kind(&path, "", "Rendez-vous").unwrap();
2340 let config = Config::load(&path).unwrap();
2341 assert_eq!(ids(&config).last().map(String::as_str), Some("rendez-vous"));
2342 change_kind(&path, "call", "Phone").unwrap();
2343 change_kind(&path, "think", "").unwrap();
2344 let config = Config::load(&path).unwrap();
2345 let kinds = config.task_kinds(&tr);
2346 assert!(kinds.contains(&("call".to_string(), "Phone".to_string())));
2347 assert!(kinds.contains(&("write".to_string(), "Writing".to_string())), "the others keep Sioul's names");
2348 assert!(!kinds.iter().any(|(id, _)| id == "think"));
2349 let text = std::fs::read_to_string(&path).unwrap();
2350 assert!(text.starts_with("# mine") && text.contains("estimate = 20") && config.accounts.len() == 1, "{text}");
2351 std::fs::remove_file(&path).unwrap();
2352 }
2353
2354 #[test]
2355 fn reads_the_example() {
2356 let config: Config = toml::from_str(include_str!("../../../examples/config.toml")).unwrap();
2357 assert!(config.sites.iter().any(|s| s.id == "proton" && s.kind == AccountKind::Portal));
2358 assert_eq!((config.windows.len(), config.working_hours().len(), config.time_off.len()), (3, 3, 1));
2359 assert_eq!(config.account("personal").and_then(|a| a.area.as_deref()), Some("personal"));
2360 let gmail = config.account("gmail").unwrap();
2361 assert_eq!(gmail.port_or_default(), 993);
2362 assert!(gmail.syncs());
2363 }
2364
2365 #[test]
2366 fn an_account_signed_in_again_with_microsoft_is_written_at_once() {
2367 let dir = std::env::temp_dir().join(format!("sioul-config-microsoft-{}", std::process::id()));
2368 std::fs::create_dir_all(&dir).unwrap();
2369 let path = dir.join("config.toml");
2370 std::fs::write(&path, "# mine\n[[account]]\nid = \"old\"\nkind = \"imap\"\naddress = \"old@hotmail.com\"\nhost = \"outlook.office365.com\"\nport = 993\nusername = \"Old@Hotmail.com\"\nsmtp_host = \"smtp.example.org\"\n").unwrap();
2371 let mut account = Config::load(&path).unwrap().account("old").unwrap().clone();
2372 account.auth = Some("microsoft".into());
2373 account.username = None;
2374 account.smtp_host = Some(MICROSOFT_SMTP_PERSONAL.into());
2375 account.smtp_port = Some(587);
2376 account.smtp_security = Security::Starttls;
2377 account.microsoft_kind = Some("personal".into());
2378 set_sign_in(&path, &account).unwrap();
2379 let back = Config::load(&path).unwrap().account("old").unwrap().clone();
2380 assert_eq!((back.auth.as_deref(), back.username.as_deref(), back.smtp_host.as_deref(), back.smtp_port, back.smtp_security), (Some("microsoft"), None, Some("smtp-mail.outlook.com"), Some(587), Security::Starttls));
2381 assert_eq!(back.microsoft_kind.as_deref(), Some("personal"));
2382 assert!(back.is_microsoft() && !back.is_microsoft_work());
2383 assert!(std::fs::read_to_string(&path).unwrap().starts_with("# mine"), "the file's comments stay");
2384 std::fs::remove_dir_all(&dir).unwrap();
2385 }
2386
2387 #[test]
2391 fn a_microsoft_accounts_kind_is_its_own() {
2392 let config: Config = toml::from_str(
2393 "[[account]]\nid = \"work\"\nkind = \"imap\"\naddress = \"you@contoso.example\"\nhost = \"outlook.office365.com\"\nauth = \"microsoft\"\nmicrosoft_kind = \"work\"\nsmtp_host = \"smtp-mail.outlook.com\"\nshield = true\nshield_ai = true\n\
2394 [[account]]\nid = \"home\"\nkind = \"imap\"\naddress = \"you@outlook.com\"\nhost = \"outlook.office365.com\"\nauth = \"microsoft\"\nmicrosoft_kind = \"personal\"\nsmtp_host = \"smtp.office365.com\"\n\
2395 [[account]]\nid = \"older\"\nkind = \"imap\"\naddress = \"me@hotmail.com\"\nhost = \"outlook.office365.com\"\nauth = \"microsoft\"\nsmtp_host = \"smtp-mail.outlook.com\"\n",
2396 )
2397 .unwrap();
2398 let work = config.account("work").unwrap();
2399 assert!(work.is_microsoft_work() && !work.ai_reads(), "Outlook.com's server, by hand: still a work account");
2400 assert!(!config.account("home").unwrap().is_microsoft_work());
2401 assert!(config.account("older").unwrap().is_microsoft_work(), "no kind said: work, until signed in again");
2402 }
2403
2404 #[test]
2405 fn a_mail_account_signed_in_with_google_keeps_it() {
2406 let dir = std::env::temp_dir().join(format!("sioul-config-google-{}", std::process::id()));
2407 let path = dir.join("config.toml");
2408 std::fs::create_dir_all(&dir).unwrap();
2409 let mut account = Account::imap("gmail", "you@gmail.com", "imap.gmail.com", 993, Security::Tls, None);
2410 account.auth = Some("google".into());
2411 add_imap_account(&path, &account).unwrap();
2412 assert_eq!(Config::load(&path).unwrap().account("gmail").and_then(|a| a.auth.clone()).as_deref(), Some("google"));
2413 add_imap_account(&path, &Account::imap("other", "you@example.org", "imap.example.org", 993, Security::Tls, None)).unwrap();
2415 assert_eq!(Config::load(&path).unwrap().account("other").and_then(|a| a.auth.clone()), None);
2416 set_auth(&path, "gmail", None).unwrap();
2418 assert_eq!(Config::load(&path).unwrap().account("gmail").and_then(|a| a.auth.clone()), None);
2419 set_auth(&path, "gmail", Some("google")).unwrap();
2420 assert_eq!(Config::load(&path).unwrap().account("gmail").and_then(|a| a.auth.clone()).as_deref(), Some("google"));
2421 std::fs::remove_dir_all(&dir).unwrap();
2422 }
2423
2424 #[test]
2425 fn writes_accounts_and_keeps_comments() {
2426 let dir = std::env::temp_dir().join(format!("sioul-config-{}", std::process::id()));
2427 let path = dir.join("config.toml");
2428 std::fs::create_dir_all(&dir).unwrap();
2429 std::fs::write(&path, "# My settings.\ncase_store = \"~/Notes\"\n\n# Uncomment to choose:\n# language = \"en\"\n").unwrap();
2430 let account = Account::imap("example", "someone@example.org", "imap.example.org", 993, Security::Tls, Some("someone@example.org"));
2431 assert_eq!(account.username, None);
2432 add_imap_account(&path, &account).unwrap();
2433 add_portal(&path, "portal", "https://mail.example.net").unwrap();
2434 set_trusted_ids(&path, "example", &["mx.example.org".into()]).unwrap();
2435 set_priority(&path, "example", Priority::Below).unwrap();
2436 let text = std::fs::read_to_string(&path).unwrap();
2437 assert!(text.starts_with("# My settings.\n"));
2438 assert!(text.find("# language").unwrap() < text.find("[[account]]").unwrap(), "{text}");
2439 assert!(text.contains("[[site]]") && !text.contains("kind = \"portal\""), "{text}");
2441 let config = Config::load(&path).unwrap();
2442 assert_eq!((config.accounts.len(), config.sites.len()), (1, 1));
2443 assert_eq!(config.accounts[0].trusted_authserv_ids, vec!["mx.example.org"]);
2444 assert_eq!(config.accounts[0].priority, Priority::Below);
2445 assert_eq!(config.free_id("someone@example.org"), "example-someone");
2446 set_enabled(&path, "example", false).unwrap();
2448 let config = Config::load(&path).unwrap();
2449 assert_eq!((config.accounts.len(), config.accounts_off.len()), (0, 1));
2450 assert_eq!(config.free_id("someone@example.org"), "example-someone", "an id stays taken while off");
2451 set_enabled(&path, "example", true).unwrap();
2452 remove_account(&path, "example").unwrap();
2453 let config = Config::load(&path).unwrap();
2454 assert_eq!(config.accounts.len(), 0);
2455 assert_eq!(config.free_id("someone@example.org"), "example");
2456 std::fs::remove_dir_all(&dir).unwrap();
2457 }
2458
2459 #[test]
2460 fn sites_moved_out_of_the_accounts() {
2461 let dir = std::env::temp_dir().join(format!("sioul-sites-{}", std::process::id()));
2462 let path = dir.join("config.toml");
2463 std::fs::create_dir_all(&dir).unwrap();
2464 std::fs::write(&path, "[[account]]\nid = \"mail\"\nkind = \"imap\"\nhost = \"imap.example.org\"\n\n# My bank.\n[[account]]\nid = \"bank\"\nkind = \"portal\"\nurl = \"https://bank.example.org\"\nrealtime = true\n").unwrap();
2465 let config = Config::load(&path).unwrap();
2467 assert_eq!((config.accounts.len(), config.sites.len()), (1, 1));
2468 assert!(migrate_sites(&path).unwrap());
2469 let text = std::fs::read_to_string(&path).unwrap();
2470 assert!(text.contains("# My bank.\n[[site]]\nid = \"bank\"") && !text.contains("portal"), "{text}");
2471 assert!(!migrate_sites(&path).unwrap(), "once");
2472 set_value(&path, "site.bank.muted", &SettingValue::Bool(true)).unwrap();
2473 move_site(&path, "bank", -1, false).unwrap();
2474 let config = Config::load(&path).unwrap();
2475 assert!(config.site("bank").is_some_and(|s| s.muted && s.realtime));
2476 remove_site(&path, "bank").unwrap();
2477 assert!(Config::load(&path).unwrap().sites.is_empty());
2478 std::fs::remove_dir_all(&dir).unwrap();
2479 }
2480
2481 #[test]
2484 fn the_copy_before_the_sites_move_is_made_once() {
2485 let dir = std::env::temp_dir().join(format!("sioul-sites-start-{}", std::process::id()));
2486 let _ = std::fs::remove_dir_all(&dir);
2487 std::fs::create_dir_all(&dir).unwrap();
2488 let path = dir.join("config.toml");
2489 let copy = before_sites_path(&path);
2490 std::fs::write(&path, "[[account]]\nid = \"mail\"\nkind = \"imap\"\n").unwrap();
2492 assert!(!migrate_sites_at_start(&path).unwrap());
2493 assert!(!copy.exists());
2494 let first = "site = 1\n\n[[account]]\nid = \"bank\"\nkind = \"portal\"\nurl = \"https://bank.example.org\"\n";
2496 std::fs::write(&path, first).unwrap();
2497 assert!(migrate_sites_at_start(&path).is_err());
2498 assert_eq!(std::fs::read_to_string(©).unwrap(), first);
2499 std::fs::write(&path, format!("{first}\n[[account]]\nid = \"post\"\nkind = \"portal\"\nurl = \"https://post.example.org\"\n")).unwrap();
2501 assert!(migrate_sites_at_start(&path).is_err());
2502 assert_eq!(std::fs::read_to_string(©).unwrap(), first, "never written over");
2503 std::fs::write(&path, first.replace("site = 1\n\n", "")).unwrap();
2505 assert!(migrate_sites_at_start(&path).unwrap());
2506 assert!(!std::fs::read_to_string(&path).unwrap().contains("portal"));
2507 assert_eq!(std::fs::read_to_string(©).unwrap(), first);
2508 std::fs::remove_dir_all(&dir).unwrap();
2509 }
2510
2511 #[cfg(unix)]
2512 #[test]
2513 fn sioul_folders_are_yours_alone() {
2514 use std::os::unix::fs::PermissionsExt;
2515 let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
2516 let root = std::env::temp_dir().join(format!("sioul-private-{}", std::process::id()));
2517 let _ = std::fs::remove_dir_all(&root);
2518 let new = root.join("share").join("sioul");
2520 private_dir(&new).unwrap();
2521 assert_eq!(mode(&new), 0o700);
2522 let old = root.join("state").join("sioul");
2524 std::fs::create_dir_all(&old).unwrap();
2525 std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o755)).unwrap();
2526 std::fs::set_permissions(root.join("state"), std::fs::Permissions::from_mode(0o755)).unwrap();
2527 private_dir(&old).unwrap();
2528 assert_eq!((mode(&old), mode(&root.join("state"))), (0o700, 0o755));
2529 let elsewhere = root.join("synced");
2531 std::fs::create_dir_all(&elsewhere).unwrap();
2532 std::fs::set_permissions(&elsewhere, std::fs::Permissions::from_mode(0o755)).unwrap();
2533 let link = root.join("cache");
2534 std::fs::create_dir_all(&link).unwrap();
2535 std::os::unix::fs::symlink(&elsewhere, link.join("sioul")).unwrap();
2536 private_dir(&link.join("sioul")).unwrap();
2537 assert_eq!(mode(&elsewhere), 0o755);
2538 std::fs::remove_dir_all(&root).unwrap();
2539 }
2540
2541 #[test]
2542 fn filters_are_written_whole_a_newer_one_as_it_was() {
2543 use crate::rules::{Act, Condition, Field, Filter, Test};
2544 let path = std::env::temp_dir().join(format!("sioul-filters-{}.toml", std::process::id()));
2545 std::fs::write(
2546 &path,
2547 "# Mine\nfetch_minutes = 15\n\n[mail]\nthreads = true # by conversation\n\n\
2548 [[mail.filter]]\nname = \"Newer\"\nif = [{ field = \"frobnicate\", test = \"contains\", value = \"x\" }]\nthen = [{ do = \"read\" }]\n\n\
2549 [[mail.filter]]\nname = \"Bank\"\nif = [{ field = \"from\", test = \"contains\", value = \"@bank.example\" }]\nthen = [{ do = \"read\" }]\n",
2550 )
2551 .unwrap();
2552 let read = |path: &Path| Config::load(path).unwrap().mail.filters;
2553 let mut filters = read(&path);
2554 assert_eq!(filters.len(), 2);
2555 assert!(filters[0].unknown() && !filters[1].unknown());
2556 filters[1].actions.push(Act::Archive);
2558 filters.swap(0, 1);
2559 filters.push(Filter { name: "Lists".into(), conditions: vec![Condition::new(Field::List, Test::Exists, "")], actions: vec![Act::Read], ..Filter::default() });
2560 set_filters(&path, &filters).unwrap();
2561 let text = std::fs::read_to_string(&path).unwrap();
2562 assert!(text.contains("# Mine") && text.contains("# by conversation"), "comments kept: {text}");
2563 assert!(text.contains("frobnicate") && !text.contains("unknown"), "a newer Sioul's words kept: {text}");
2564 let again = read(&path);
2565 assert_eq!(again.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Bank", "Newer", "Lists"]);
2566 assert_eq!(again[0].actions, vec![Act::Read, Act::Archive]);
2567 let mut renamed = again.clone();
2569 renamed[1].name = "Renamed".into();
2570 set_filters(&path, &renamed).unwrap();
2571 assert!(std::fs::read_to_string(&path).unwrap().contains("field = \"unknown\""));
2572 set_filters(&path, &[]).unwrap();
2574 let text = std::fs::read_to_string(&path).unwrap();
2575 assert!(read(&path).is_empty() && !text.contains("[[mail.filter]]") && text.contains("threads = true"), "{text}");
2576 let _ = std::fs::remove_file(&path);
2577 }
2578}