1mod accounts;
44mod dav;
45mod mail;
46mod mcp;
47mod pgp;
48mod remind;
49#[cfg(not(target_os = "android"))]
51mod spam;
52mod tasks;
53
54use clap::{Parser, Subcommand};
55use jiff::{Timestamp, Zoned, tz::TimeZone};
56use sioul_core::budget::{self, Ledger};
57use sioul_core::projects::ProjectStore;
58use sioul_core::config::{self, Config, Source};
59use sioul_core::i18n::{self, Translator};
60use sioul_core::porch::{self, Context, KnownSenders, Lane, SenderList, Triaged};
61use sioul_core::state::{MoneyState, PorchState};
62use sioul_core::{maildir, trust, view, window};
63use std::path::{Path, PathBuf};
64use std::process::ExitCode;
65
66#[derive(Parser)]
67#[command(name = "sioul", version = sioul_core::build::DESCRIBED, about = "Calm admin: the Porch, projects and admin windows.")]
69struct Cli {
70 #[arg(long, global = true)]
72 config: Option<PathBuf>,
73 #[arg(long, global = true)]
75 language: Option<String>,
76 #[command(subcommand)]
77 command: Command,
78}
79
80#[derive(Subcommand)]
81enum Command {
82 Porch {
84 #[arg(long)]
86 maildir: Vec<PathBuf>,
87 #[arg(long)]
89 open: bool,
90 #[arg(long)]
92 all: bool,
93 },
94 Done,
96 Card { file: PathBuf },
98 #[command(alias = "cases")]
100 Projects {
101 #[arg(long)]
103 store: Option<PathBuf>,
104 },
105 Window,
107 Budgets {
109 #[arg(long)]
111 file: Option<PathBuf>,
112 #[arg(long)]
114 maildir: Vec<PathBuf>,
115 },
116 #[command(subcommand)]
118 Account(accounts::AccountCommand),
119 #[command(subcommand)]
121 Mail(mail::MailCommand),
122 #[command(subcommand)]
124 Pgp(pgp::PgpCommand),
125 #[command(subcommand)]
127 Dav(dav::DavCommand),
128 Contacts { query: Option<String> },
130 Contact {
132 #[arg(long)]
133 name: String,
134 #[arg(long)]
135 email: Vec<String>,
136 #[arg(long)]
137 phone: Vec<String>,
138 },
139 Agenda {
141 #[arg(long, default_value_t = 15, value_parser = clap::value_parser!(i64).range(1..=366))]
142 days: i64,
143 },
144 Event {
146 #[arg(long)]
147 title: String,
148 #[arg(long)]
149 start: String,
150 #[arg(long)]
151 end: Option<String>,
152 #[arg(long)]
153 all_day: bool,
154 #[arg(long)]
155 location: Option<String>,
156 #[arg(long)]
158 repeat: Option<String>,
159 },
160 Block { entry: Option<String> },
163 Unblock { entry: String },
165 Verify {
168 #[arg(long)]
170 again: bool,
171 },
172 #[command(subcommand)]
174 Tasks(tasks::TasksCommand),
175 #[command(subcommand)]
177 Focus(tasks::FocusCommand),
178 Links { uri: String },
180 Notes {
182 query: Vec<String>,
183 #[arg(long)]
184 path: Option<String>,
185 },
186 Sync {
188 #[arg(long)]
189 account: Option<String>,
190 },
191 Watch,
193 Remind {
195 #[arg(long)]
198 watch: bool,
199 },
200 Shield {
203 #[arg(long)]
205 account: Option<String>,
206 #[arg(long)]
208 read: bool,
209 },
210 Mcp,
213 #[cfg(not(target_os = "android"))]
216 #[command(subcommand)]
217 Spam(spam::SpamCommand),
218}
219
220pub(crate) struct Session {
222 pub config: Config,
223 pub config_path: PathBuf,
224 pub tr: Translator,
225}
226
227impl Session {
228 pub fn say(&self, id: &str, pairs: &[(&str, String)]) -> String {
230 let mut args = i18n::args();
231 for (key, value) in pairs {
232 match value.parse::<i32>() {
235 Ok(n) if n.to_string() == *value => args.set(key.to_string(), n),
236 _ => args.set(key.to_string(), value.clone()),
237 }
238 }
239 self.tr.text(id, Some(&args))
240 }
241}
242
243pub(crate) fn one_line(text: &str) -> String {
249 let mut out = String::with_capacity(text.len());
250 for c in text.chars() {
251 match c {
252 '\t' | '\n' | '\r' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' => {
253 if !out.ends_with(' ') {
254 out.push(' ');
255 }
256 }
257 c if hidden(c) => {}
258 c => out.push(c),
259 }
260 }
261 out.trim().to_string()
262}
263
264pub(crate) fn plain_lines(text: &str) -> String {
268 text.replace("\r\n", "\n")
269 .chars()
270 .map(|c| if matches!(c, '\r' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}') { '\n' } else { c })
271 .filter(|&c| c == '\n' || c == '\t' || !hidden(c))
272 .collect()
273}
274
275fn hidden(c: char) -> bool {
278 c.is_control() || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
279}
280
281fn session_language() -> String {
285 let said = ["LC_ALL", "LC_MESSAGES", "LANG"].iter().any(|v| std::env::var(v).is_ok_and(|l| !l.trim().is_empty()));
286 if said {
287 return i18n::system_language();
288 }
289 system_locale().unwrap_or_else(i18n::system_language)
290}
291
292#[cfg(windows)]
294fn system_locale() -> Option<String> {
295 #[link(name = "kernel32")]
296 unsafe extern "system" {
297 fn GetUserDefaultLocaleName(name: *mut u16, size: i32) -> i32;
298 }
299 let mut name = [0u16; 85];
301 let written = unsafe { GetUserDefaultLocaleName(name.as_mut_ptr(), name.len() as i32) };
303 let length = usize::try_from(written).ok().filter(|n| *n > 1 && *n <= name.len())?;
304 Some(String::from_utf16_lossy(&name[..length - 1]))
305}
306
307#[cfg(target_os = "macos")]
309fn system_locale() -> Option<String> {
310 let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().ok()?;
312 String::from_utf8_lossy(&out.stdout).split('"').nth(1).map(str::to_string).filter(|l| !l.is_empty())
313}
314
315#[cfg(not(any(windows, target_os = "macos")))]
317fn system_locale() -> Option<String> {
318 None
319}
320
321fn main() -> ExitCode {
322 sioul_sync::securitykey::cap_logging();
324 let cli = Cli::parse();
325 config::make_private_dirs();
327 let config_path = cli.config.clone().unwrap_or_else(config::default_path);
328 let config = Config::load(&config_path).unwrap_or_else(|e| {
330 if config_path.exists() {
331 eprintln!("{e}");
332 }
333 Config::default()
334 });
335 let language = cli.language.clone().or_else(|| config.language.clone()).unwrap_or_else(session_language);
336 sioul_core::words::set_current(sioul_core::words::Words::of(&config));
338 let session = Session { tr: Translator::new(&language), config, config_path };
339 let result = match cli.command {
340 Command::Porch { maildir, open, all } => porch_command(&session, &maildir, open, all),
341 Command::Done => done_command(&session),
342 Command::Card { file } => card_command(&session, &file),
343 Command::Projects { store } => projects_command(&session, store),
344 Command::Window => window_command(&session),
345 Command::Budgets { file, maildir } => budgets_command(&session, file, &maildir),
346 Command::Account(command) => accounts::run(&session, command),
347 Command::Mail(command) => mail::run(&session, command),
348 Command::Dav(command) => dav::run(&session, command),
349 Command::Pgp(command) => pgp::run(&session, command),
350 Command::Contacts { query } => dav::list_contacts(&session, query.as_deref().unwrap_or("")),
351 Command::Contact { name, email, phone } => dav::new_contact(&session, &name, &email, &phone),
352 Command::Agenda { days } => dav::list_agenda(&session, days),
353 Command::Event { title, start, end, all_day, location, repeat } => {
354 let edit = sioul_core::agenda::EventEdit {
355 end: end.unwrap_or_else(|| start.clone()),
356 title,
357 start,
358 all_day,
359 location: location.unwrap_or_default(),
360 repeat: repeat.unwrap_or_default(),
361 notes: String::new(),
362 ..sioul_core::agenda::EventEdit::default()
363 };
364 dav::new_event(&session, &edit)
365 }
366 Command::Block { entry } => block_command(&session, entry.as_deref()),
367 Command::Unblock { entry } => unblock_command(&session, &entry),
368 Command::Verify { again } => verify_command(&session, again),
369 Command::Tasks(command) => tasks::run(&session, command),
370 Command::Focus(command) => tasks::focus(&session, command),
371 Command::Links { uri } => tasks::links_command(&session, &uri),
372 Command::Notes { query, path } => tasks::notes_command(&session, &query.join(" "), path.as_deref()),
373 Command::Sync { account } => accounts::sync_command(&session, account.as_deref()),
374 Command::Watch => accounts::watch_command(&session),
375 Command::Remind { watch: false } => remind::list(&session),
376 Command::Remind { watch: true } => remind::watch(&session),
377 Command::Shield { account, read } => shield_command(&session, account.as_deref(), read),
378 Command::Mcp => mcp::run(&session),
379 #[cfg(not(target_os = "android"))]
380 Command::Spam(command) => spam::run(&session, command),
381 };
382 result.map_or_else(
383 |message| {
384 eprintln!("{}", plain_lines(&message));
386 ExitCode::FAILURE
387 },
388 |()| ExitCode::SUCCESS,
389 )
390}
391
392fn sources(config: &Config, maildirs: &[PathBuf]) -> Vec<Source> {
394 if maildirs.is_empty() {
395 return config.mail_sources();
396 }
397 let ids = config.all_trusted_ids();
398 maildirs.iter().map(|m| Source { account: None, address: None, folder: m.clone(), trusted_ids: ids.clone(), priority: Default::default(), shielded: false, words: sioul_core::words::Words::of(config), spam: Some(sioul_core::spam::Filter::of(config)) }).collect()
399}
400
401pub(crate) fn load_store(config: &Config) -> Option<ProjectStore> {
402 let root = config.notes_root_path()?;
403 let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
405 ProjectStore::load(&root).map_err(|e| eprintln!("{}", plain_lines(&e))).ok().map(|s| s.with_ties(&ties))
406}
407
408fn porch_command(s: &Session, maildirs: &[PathBuf], open: bool, all: bool) -> Result<(), String> {
409 let sources = sources(&s.config, maildirs);
410 if sources.is_empty() {
411 return Err(s.tr.text("error-no-mail", None));
412 }
413 let store = load_store(&s.config);
414 let known = KnownSenders::load(&s.config.known_senders_path());
415 let senders = porch::Senders::load(&s.config);
416 let state = if all { PorchState::default() } else { PorchState::load(&PorchState::default_path()) };
417 let now = Zoned::now();
418 let triaged = porch::gather(&sources, store.as_ref(), &known, &senders, &state, now.timestamp().as_second());
419 print_right_now(s, &triaged);
420 let is_open = open || s.config.windows.is_empty() || window::current(&s.config.windows, &now).is_some();
421 if is_open {
422 print_open_porch(s, &triaged, store.as_ref());
423 } else {
424 print_closed_porch(s, &now);
425 }
426 Ok(())
427}
428
429fn shield_command(s: &Session, account: Option<&str>, read: bool) -> Result<(), String> {
430 use sioul_core::shield::{self, AiCache, Tone};
431 let accounts: Vec<_> = s.config.accounts.iter().filter(|a| a.shield && account.is_none_or(|id| a.id == id)).collect();
432 if accounts.is_empty() {
433 return Err(s.tr.text("shield-none", None));
434 }
435 if read {
436 let done = sioul_sync::shield_ai::read_new(&s.config).map_err(|e| e.sentence(&s.tr, "Anthropic"))?;
437 println!("{}", s.say("shield-read", &[("n", done.read.to_string()), ("left", done.left.to_string())]));
438 }
439 for account in accounts {
440 println!("{}", account.address.as_deref().unwrap_or(&account.id));
441 let cache = AiCache::load(&account.id);
442 for card in maildir::read_messages(&account.maildir_path()).iter().rev() {
444 let assessment = shield::reading(card, Some(&cache.messages));
445 let tone = s.tr.text(&format!("tone-{}", match assessment.tone { Tone::Calm => "calm", Tone::Rude => "rude", Tone::Hostile => "hostile" }), None);
446 let (sender, subject) = if assessment.tone == Tone::Hostile {
447 let domain = card.from_address.as_deref().and_then(|a| a.rsplit_once('@')).map(|(_, d)| d.to_string()).unwrap_or_default();
448 (s.say("hostile-someone-at", &[("domain", domain)]), s.tr.text("hostile-subject", None))
449 } else {
450 (card.sender().to_string(), card.subject.clone())
451 };
452 println!(" {tone:<8} {:<16} {} · {}", s.tr.topic(assessment.topic), one_line(&sender), one_line(&subject));
453 if !assessment.summary.is_empty() {
454 println!(" {}", one_line(&assessment.summary));
455 } else if !assessment.words.is_empty() && assessment.tone != Tone::Hostile {
456 println!(" {}", one_line(&s.say("shield-words", &[("words", assessment.words.join(", "))])));
457 }
458 }
459 }
460 Ok(())
461}
462
463fn done_command(s: &Session) -> Result<(), String> {
464 let path = PorchState::default_path();
465 let mut state = PorchState::load(&path);
466 let senders = porch::Senders::load(&s.config);
467 let shown = porch::gather(&s.config.mail_sources(), None, &KnownSenders::default(), &senders, &state, Timestamp::now().as_second());
468 let newest = PorchState::newest_shown(&shown);
469 if newest.is_empty() {
470 println!("{}", s.tr.text("done-nothing", None));
471 return Ok(());
472 }
473 state.close(&newest);
474 state.save(&path)?;
475 println!("{}", s.tr.text("done-closed", None));
476 Ok(())
477}
478
479pub(crate) fn print_right_now(s: &Session, triaged: &[Triaged]) {
481 let codes: Vec<&Triaged> = triaged.iter().filter(|t| t.lane == Lane::RightNow).collect();
482 if codes.is_empty() {
483 return;
484 }
485 println!("{}", s.tr.text("right-now-title", None));
486 for t in codes {
487 let Some(code) = &t.code else { continue };
488 println!(" {}", one_line(&s.tr.right_now(code.kind, t.card.sender(), t.trust, code.code.as_deref(), code.expires_minutes)));
489 }
490 println!();
491}
492
493fn print_closed_porch(s: &Session, now: &Zoned) {
494 let next = window::next_opening(&s.config.windows, now);
495 let mut args = sioul_core::i18n::args();
496 args.set("when", next.map_or_else(String::new, |z| s.tr.when(&z)));
497 println!("{}", s.tr.text("porch-closed", Some(&args)));
498 println!("{}", s.tr.text("porch-open-hint", None));
499}
500
501fn print_open_porch(s: &Session, triaged: &[Triaged], store: Option<&ProjectStore>) {
502 println!("{}\n", plain_lines(&s.tr.summary(&porch::summarise(triaged), store)));
503 for project in store.map_or(&[][..], |st| &st.projects[..]) {
504 print_lane(s, &project.title, triaged, &Lane::Project(project.id.clone()), false, store);
505 }
506 print_lane(s, &s.tr.text("lane-people", None), triaged, &Lane::People, false, store);
507 print_lane(s, &s.tr.text("lane-screener", None), triaged, &Lane::Screener, true, store);
508 print_lane(s, &s.tr.text("lane-filed", None), triaged, &Lane::Filed, false, store);
509 print_lane(s, &s.tr.text("lane-low", None), triaged, &Lane::Low, false, store);
510 print_lane(s, &s.tr.text("lane-review", None), triaged, &Lane::Review, true, store);
511 print_lane(s, &s.tr.text("lane-set-aside", None), triaged, &Lane::SetAside, true, store);
512}
513
514fn print_lane(s: &Session, title: &str, triaged: &[Triaged], lane: &Lane, with_reason: bool, store: Option<&ProjectStore>) {
515 let items: Vec<&Triaged> = triaged.iter().filter(|t| &t.lane == lane).collect();
516 if items.is_empty() {
517 return;
518 }
519 println!("{}", one_line(title));
520 for t in items {
521 let reason = match (with_reason, t.reasons.last()) {
523 (true, Some(r)) => format!(" ({})", one_line(&s.tr.reason(r, store))),
524 _ => String::new(),
525 };
526 println!(" · {} ({}) · {} · {}{reason}", one_line(t.card.sender()), s.tr.trust(t.trust), one_line(&t.card.subject), date(s, t.card.date));
527 }
528 println!();
529}
530
531fn card_command(s: &Session, file: &Path) -> Result<(), String> {
532 let card = maildir::read_one(file).ok_or_else(|| format!("{}: ?", file.display()))?;
533 let store = load_store(&s.config);
534 let known = KnownSenders::load(&s.config.known_senders_path());
535 let senders = porch::Senders::load(&s.config);
536 let ids = s.config.all_trusted_ids();
537 let own = sioul_core::lookalike::own_domains(&sioul_core::words::Words::of(&s.config).brands.shared, s.config.accounts.iter().filter_map(|a| a.address.as_deref()));
538 let auth = trust::read_auth_results(&card.headers, &ids);
539 let spam = trust::read_spam_verdict(&card.headers);
540 let route: Vec<String> = trust::route_ips(&card.headers).iter().map(ToString::to_string).collect();
541 let own_addresses = porch::own_addresses(&s.config.mail_sources());
542 let filter = sioul_core::spam::Filter::of(&s.config);
544 let words = sioul_core::words::Words::of(&s.config);
545 let ctx = Context { projects: store.as_ref(), known: &known, senders: &senders, trusted_ids: &ids, now: Some(Timestamp::now().as_second()), priority: Default::default(), own_domains: &own, shielded: false, assessments: None, words: Some(&words), own_addresses: &own_addresses, spam: Some(&filter) };
546 let t = porch::triage(card, &ctx);
547 println!("From {} <{}>", one_line(t.card.sender()), one_line(t.card.from_address.as_deref().unwrap_or("?")));
548 println!("Subject {}", one_line(&t.card.subject));
549 println!("Date {}", date(s, t.card.date));
550 println!("Trust {}", s.tr.trust(t.trust));
551 println!("Lane {:?}", t.lane);
552 for reason in &t.reasons {
553 println!(" · {}", one_line(&s.tr.reason(reason, store.as_ref())));
554 }
555 println!("Provider {auth:?}");
556 println!("Spam {spam:?}");
557 println!("Route {}", route.join(" ← "));
558 if let Some(code) = &t.code {
559 println!("Code {code:?}");
560 }
561 if let Some(reading) = sioul_core::reading::read(file) {
562 for a in &reading.attachments {
563 println!("Attached {} ({}, {} bytes)", one_line(&a.name), one_line(&a.mime), a.size);
564 }
565 for part in &reading.parts {
566 let line = format!("{part:?}").replace('\n', " ⏎ ");
567 println!("Part {}", line.chars().take(110).collect::<String>());
568 }
569 }
570 Ok(())
571}
572
573fn projects_command(s: &Session, store: Option<PathBuf>) -> Result<(), String> {
574 let root = store.or_else(|| s.config.notes_root_path()).ok_or_else(|| s.tr.text("error-no-store", None))?;
575 let store = ProjectStore::load(&root)?;
576 let missing = store.missing_files();
577 for project in &store.projects {
578 let status = project.status.as_deref().map_or(String::new(), |st| format!(" [{}]", one_line(st)));
579 println!("{} · {}{status}", one_line(&project.id), one_line(&project.title));
580 for file in &project.files {
581 let gone = missing.iter().any(|(id, f)| id == &project.id && f == file);
582 println!(" {}{}", one_line(file), if gone { " (?)" } else { "" });
583 }
584 }
585 Ok(())
586}
587
588fn window_command(s: &Session) -> Result<(), String> {
589 if s.config.windows.is_empty() {
590 return Err(s.tr.text("error-no-windows", None));
591 }
592 let now = Zoned::now();
593 let mut args = sioul_core::i18n::args();
594 let id = match window::current(&s.config.windows, &now) {
595 Some((_, closing)) => {
596 args.set("time", closing.strftime("%H:%M").to_string());
597 "window-open-until"
598 }
599 None => {
600 let next = window::next_opening(&s.config.windows, &now).ok_or_else(|| s.tr.text("window-none", None))?;
601 args.set("when", s.tr.when(&next));
602 "window-next"
603 }
604 };
605 println!("{}", s.tr.text(id, Some(&args)));
606 Ok(())
607}
608
609fn budgets_command(s: &Session, file: Option<PathBuf>, maildirs: &[PathBuf]) -> Result<(), String> {
610 let path = file
611 .or_else(|| s.config.notes_root_path().map(|root| root.join(budget::LEDGER)))
612 .ok_or_else(|| s.tr.text("error-no-ledger", None))?;
613 let ledger = Ledger::load_file(&path)?;
614 let ledger = match path.parent().map(sioul_core::bank::Bank::load) {
616 Some(Ok(bank)) => ledger.with_bank(&bank, &sioul_core::words::Words::of(&s.config)),
617 _ => ledger,
618 };
619 let today = Zoned::now().date();
620 let mut args = sioul_core::i18n::args();
621 args.set("month", s.tr.month_year(today));
622 println!("{}", s.tr.text("budgets-title", Some(&args)));
623 for b in &ledger.budgets {
624 let status = ledger.status(b, today);
625 let reserve = status.reserve_transfer.as_ref().map(|(id, _)| ledger.reserve_title(id));
626 print_indented(&s.tr.budget_lines(&b.title, b.period, &status, reserve));
627 }
628 if !ledger.reserves.is_empty() {
629 println!("\n{}", s.tr.text("reserves-title", None));
630 }
631 for r in &ledger.reserves {
632 print_indented(&s.tr.reserve_lines(&r.title, &ledger.reserve_status(r, today), today));
633 }
634 print_mail_lines(s, &ledger, maildirs);
635 Ok(())
636}
637
638fn print_indented(lines: &[String]) {
640 for (i, line) in lines.iter().enumerate() {
641 println!("{}{}", if i == 0 { " " } else { " " }, one_line(line));
642 }
643}
644
645fn print_mail_lines(s: &Session, ledger: &Ledger, maildirs: &[PathBuf]) {
647 let store = load_store(&s.config);
648 let known = KnownSenders::load(&s.config.known_senders_path());
649 let senders = porch::Senders::load(&s.config);
650 let now = Zoned::now();
651 let items = porch::gather(&sources(&s.config, maildirs), store.as_ref(), &known, &senders, &PorchState::default(), now.timestamp().as_second());
653 let ignored = MoneyState::load(&MoneyState::default_path()).ignored;
654 let lines = budget::mail_lines(ledger, &items, &ignored, &sioul_core::words::Words::of(&s.config));
655 let shown = view::budgets(ledger, &lines, &s.tr, now.date());
656 if shown.mail.is_empty() {
657 return;
658 }
659 println!("\n{}", shown.mail_title);
660 for row in &shown.mail {
661 let budget = row.budget.as_deref().map_or("?", |b| ledger.budget_title(b));
662 let amount = if row.amount.is_empty() { "—" } else { row.amount.as_str() };
663 println!(" {} · {} · {} · {amount} · {}", row.date, row.kind_label, one_line(&row.party), one_line(budget));
664 let doubtful = row.doubtful && row.state == "proposed";
665 for note in [row.note.clone(), if doubtful { s.tr.text("note-doubtful", None) } else { String::new() }] {
666 if !note.is_empty() {
667 println!(" {}", one_line(¬e));
668 }
669 }
670 }
671}
672
673fn block_command(s: &Session, entry: Option<&str>) -> Result<(), String> {
674 let path = s.config.blocked_senders_path();
675 let Some(entry) = entry else {
676 let list = SenderList::load(&path);
678 let entries: Vec<String> = list.entries().into_iter().chain(list.numbers().iter().map(|n| format!("{}{n}", porch::TEL))).chain(list.cards().iter().map(|c| format!("{}{c}", porch::CONTACT))).collect();
679 if entries.is_empty() {
680 println!("{}", s.tr.text("blocked-none", None));
681 }
682 for e in entries {
683 println!("{}", one_line(&e));
684 }
685 return Ok(());
686 };
687 porch::set_standing(&s.config, entry, porch::Standing::Blocked)?;
688 println!("{}", s.say("ui-blocked", &[("entry", entry.trim().to_ascii_lowercase())]));
689 Ok(())
690}
691
692fn verify_command(s: &Session, again: bool) -> Result<(), String> {
693 for source in s.config.mail_sources() {
694 let checked = sioul_sync::verify::verify_folder(&source.folder, again);
695 let mut args = s.tr.counted(checked);
696 args.set("account", source.account.unwrap_or_default());
697 println!("{}", s.tr.text("verify-done", Some(&args)));
698 }
699 Ok(())
700}
701
702fn unblock_command(s: &Session, entry: &str) -> Result<(), String> {
703 porch::unblock(&s.config, entry)?;
705 println!("{}", s.say("ui-unblocked", &[("entry", entry.trim().to_ascii_lowercase())]));
706 Ok(())
707}
708
709pub(crate) fn date(s: &Session, seconds: Option<i64>) -> String {
710 seconds
711 .and_then(|sec| Timestamp::from_second(sec).ok())
712 .map_or_else(|| s.tr.text("no-date", None), |t| s.tr.date(&t.to_zoned(TimeZone::system()), true))
713}
714
715#[cfg(test)]
716mod terminal_tests {
717 use super::*;
718
719 #[test]
720 fn what_is_printed_cannot_drive_the_terminal() {
721 let subject = "Invoice\u{1b}]0;pwned\u{7}\u{1b}]52;c;Y2F0\u{7} \u{202e}fdp.exe\r\nBcc: x";
723 let line = one_line(subject);
724 assert!(!line.contains(['\u{1b}', '\u{7}', '\u{202e}', '\r', '\n']), "{line:?}");
725 let lines = plain_lines("First line\r\nsecond\u{1b}[2J\rthird\ttab");
726 assert_eq!(lines, "First line\nsecond[2J\nthird\ttab");
727 }
728
729 #[test]
730 fn numbers_choose_the_plural() {
731 let s = Session { config: Config::default(), config_path: PathBuf::new(), tr: Translator::new("en") };
732 let one = s.say("shield-read", &[("n", "1".to_string()), ("left", "0".to_string())]);
733 assert!(one.contains("1 message;"), "{one}");
734 let many = s.say("shield-read", &[("n", "2".to_string()), ("left", "0".to_string())]);
735 assert!(many.contains("2 messages;"), "{many}");
736 }
737}