1use crate::backend::{QtThread, Shared, config_path, json, load_config, say, set_status, show, tell, tr};
13use crate::mail;
14use cxx_qt_lib::QString;
15use jiff::tz::TimeZone;
16use jiff::{Timestamp, Zoned};
17use sioul_core::agenda::{self, EventEdit};
18use sioul_core::config::{self, Account};
19use sioul_core::contacts::{self, ContactEdit};
20use sioul_core::vdir::{self, Collection, Kind};
21use sioul_core::{maildir, reading, view};
22use sioul_sync::dav::{self, Homes};
23use sioul_sync::{Control, SyncError, google, secret};
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use std::sync::atomic::Ordering;
27use std::time::Duration;
28
29const DAV_PAUSE: Duration = Duration::from_secs(15 * 60);
31
32#[derive(Clone)]
34pub(crate) struct PimState {
35 pub query: String,
36 pub category: String,
38 pub from: jiff::civil::Date,
39 pub days: i64,
40 pub upcoming: bool,
42}
43
44impl Default for PimState {
45 fn default() -> PimState {
46 PimState { query: String::new(), category: String::new(), from: Zoned::now().date(), days: view::AGENDA_DAYS, upcoming: true }
47 }
48}
49
50pub(crate) fn show_pim(qt: &QtThread, shared: &Arc<Shared>) {
52 let qt = qt.clone();
53 crate::backend::coalesced(shared, |s| &s.pim_job, move |shared| {
54 let generation = shared.pim_generation.fetch_add(1, Ordering::Relaxed) + 1;
55 let state = shared.pim.lock().map(|s| s.clone()).unwrap_or_default();
56 let (removed, skipped) = mail::hidden_pim(shared);
57 crate::homecard::agenda_seen(&removed, &skipped);
59 let everyone: Vec<contacts::Contact> = contacts::all().into_iter().filter(|c| !removed.contains(Path::new(&c.key))).collect();
60 let contacts = json(&view::contacts(&everyone, &state.query, &state.category, tr()));
61 let zone = TimeZone::system();
62 let from = state.from.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second());
63 let to = from + state.days * 86_400 + 3_600;
64 let now = Zoned::now().timestamp().as_second();
65 let occurrences: Vec<agenda::Occurrence> = agenda::occurrences(from, to)
66 .into_iter()
67 .filter(|o| !removed.contains(Path::new(&o.key)) && !skipped.contains(&(PathBuf::from(&o.key), o.start)))
68 .filter(|o| !state.upcoming || o.end.max(o.start) > now)
70 .collect();
71 let mut shown = view::agenda(&occurrences, state.from, state.days, tr(), &zone);
72 view::set_hours(&mut shown, &load_config().week_hours(), &zone);
74 let agenda = json(&shown);
75 let clashes = overlaps(14, &removed, &skipped);
76 let _ = qt.queue(move |mut sioul| {
77 if sioul.shared().pim_generation.load(Ordering::Relaxed) != generation || sioul.shared().pim_shown_generation.fetch_max(generation, Ordering::Relaxed) > generation {
79 return;
80 }
81 sioul.as_mut().set_contacts(QString::from(&contacts));
82 sioul.as_mut().set_agenda(QString::from(&agenda));
83 sioul.as_mut().set_overlaps(QString::from(&clashes));
84 });
85 });
86}
87
88pub(crate) fn account_of(path: &Path) -> Option<String> {
90 [Kind::Contacts, Kind::Calendars]
91 .into_iter()
92 .find_map(|kind| path.strip_prefix(kind.root()).ok().and_then(|rest| rest.components().next()).map(|c| c.as_os_str().to_string_lossy().to_string()))
93}
94
95pub(crate) fn collection_of(path: &Path) -> Option<Collection> {
97 let dir = path.parent()?;
98 [Kind::Contacts, Kind::Calendars].into_iter().flat_map(vdir::collections).find(|c| c.dir == dir)
99}
100
101pub(crate) fn ours(key: &str) -> Option<PathBuf> {
104 let path = PathBuf::from(key);
105 let climbs = path.components().any(|c| c == std::path::Component::ParentDir);
106 let inside = [Kind::Contacts, Kind::Calendars].iter().any(|k| path.starts_with(k.root()));
107 (inside && !climbs && path.is_file()).then_some(path)
108}
109
110pub(crate) fn nudge(shared: &Shared, account: &str) {
112 if let Some(control) = shared.dav_watchers.lock().ok().and_then(|w| w.get(account).cloned()) {
113 control.nudge();
114 }
115}
116
117pub(crate) fn stop_dav_watcher(shared: &Arc<Shared>, account: &str) {
119 if let Some(control) = shared.dav_watchers.lock().ok().and_then(|mut w| w.remove(account)) {
120 control.stop();
121 }
122}
123
124pub(crate) fn start_dav_watchers(qt: &QtThread, shared: &Arc<Shared>) -> usize {
126 if crate::backend::offline() {
127 return 0;
128 }
129 let accounts: Vec<Account> = load_config().accounts.into_iter().filter(Account::is_dav).collect();
130 let count = accounts.len();
131 for account in accounts {
132 start_dav_watcher(qt, shared, account);
133 }
134 count
135}
136
137pub(crate) fn start_dav_watcher(qt: &QtThread, shared: &Arc<Shared>, account: Account) {
138 let control = Arc::new(Control::default());
139 {
140 let Ok(mut watchers) = shared.dav_watchers.lock() else { return };
141 if let Some(running) = watchers.get(&account.id) {
142 running.retry();
144 return;
145 }
146 watchers.insert(account.id.clone(), Arc::clone(&control));
147 }
148 let (qt, shared) = (qt.clone(), Arc::clone(shared));
149 std::thread::spawn(move || {
150 dav::watch(&account, &control, DAV_PAUSE, |result| reported(&qt, &shared, &account, result));
152 if let Ok(mut watchers) = shared.dav_watchers.lock()
153 && watchers.get(&account.id).is_some_and(|c| Arc::ptr_eq(c, &control))
154 {
155 watchers.remove(&account.id);
156 }
157 });
158}
159
160fn reported(qt: &QtThread, shared: &Arc<Shared>, account: &Account, result: Result<dav::Report, SyncError>) {
162 let (line, problem) = match &result {
163 Ok(_) => (say("dav-synced-at", &[("time", Zoned::now().strftime("%H:%M").to_string())]), false),
164 Err(e) => (e.sentence(tr(), &account.id), true),
165 };
166 if let Ok(mut statuses) = shared.statuses.lock() {
167 statuses.insert(account.id.clone(), (line.clone(), problem));
168 }
169 crate::backend::want_password(shared, &account.id, result.as_ref().err());
170 let changed = match result {
173 Ok(report) => {
174 let changed = report.sent + report.received + report.removed > 0 || !report.conflicts.is_empty();
175 if !report.conflicts.is_empty() {
177 tell(qt, shared, tr().text("dav-conflict", None));
178 }
179 for (summary, kept) in report.answered {
181 tell(qt, shared, say("dav-answered-elsewhere", &[("summary", summary), ("answer", kept.to_ascii_lowercase())]));
182 }
183 changed
184 }
185 Err(_) => {
186 set_status(qt, line);
187 true
188 }
189 };
190 if changed {
191 show(qt, shared);
192 show_pim(qt, shared);
193 crate::work::show_work(qt, shared);
194 }
195}
196
197pub(crate) fn contact(key: &str) -> String {
199 let Some(path) = ours(key) else { return String::new() };
200 let Some(book) = collection_of(&path) else { return String::new() };
201 contacts::read(&path, &book).map(|c| json(&c)).unwrap_or_default()
202}
203
204pub(crate) fn save_contact(qt: &QtThread, shared: &Arc<Shared>, key: &str, edit: &str) -> Result<String, String> {
207 let edit: ContactEdit = serde_json::from_str(edit).map_err(|e| e.to_string())?;
208 if edit.name.trim().is_empty() && edit.emails.iter().all(|e| e.value.trim().is_empty()) {
209 return Err(tr().text("contact-no-name", None));
210 }
211 let (path, text) = match ours(key) {
212 Some(path) => {
213 let current = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
214 (path, contacts::apply(¤t, &edit))
215 }
216 None => {
217 let book = contacts::default_book().ok_or_else(|| tr().text("dav-no-book", None))?;
218 (contacts::new_path(&book), contacts::new_card(&edit))
219 }
220 };
221 vdir::write_item(&path, &text)?;
222 if let Some(account) = account_of(&path) {
223 nudge(shared, &account);
224 }
225 tell(qt, shared, tr().text("contact-saved", None));
226 show_pim(qt, shared);
227 Ok(path.display().to_string())
228}
229
230pub(crate) fn books() -> String {
232 let config = load_config();
233 let rows: Vec<serde_json::Value> = vdir::collections(Kind::Contacts).into_iter().filter(|c| !c.read_only).map(|c| serde_json::json!({ "id": format!("{}/{}", c.account, c.id), "name": c.label(&config, tr()) })).collect();
234 json(&rows)
235}
236
237pub(crate) fn move_contact(qt: &QtThread, shared: &Arc<Shared>, key: &str, book: &str, confirmed: bool) -> String {
241 let fail = |e: String| serde_json::json!({ "error": e }).to_string();
242 let Some(from) = ours(key) else { return fail(tr().text("contact-gone", None)) };
243 let Some(target) = vdir::collections(Kind::Contacts).into_iter().find(|c| !c.read_only && format!("{}/{}", c.account, c.id) == book) else { return fail(tr().text("dav-no-book", None)) };
244 if collection_of(&from).is_some_and(|c| c.read_only) {
245 return fail(tr().text("contact-read-only", None));
246 }
247 if from.parent() == Some(target.dir.as_path()) {
248 return serde_json::json!({ "key": from.display().to_string() }).to_string();
249 }
250 let Ok(text) = std::fs::read_to_string(&from) else { return fail(tr().text("contact-gone", None)) };
251 let provider = sioul_core::capabilities::provider_of(load_config().account(&target.account));
252 let losses = sioul_core::capabilities::contact_losses(&text, provider);
253 if !losses.is_empty() && !confirmed {
254 return serde_json::json!({ "losses": losses.iter().map(|l| tr().text(&format!("loss-{l}"), None)).collect::<Vec<_>>() }).to_string();
255 }
256 let to = contacts::new_path(&target);
257 if let Err(e) = vdir::write_item(&to, &text).and_then(|()| std::fs::remove_file(&from).map_err(|e| e.to_string())) {
258 return fail(e);
259 }
260 for account in [account_of(&from), Some(target.account.clone())].into_iter().flatten() {
261 nudge(shared, &account);
262 }
263 tell(qt, shared, tr().text("contact-moved", None));
264 show_pim(qt, shared);
265 serde_json::json!({ "key": to.display().to_string() }).to_string()
266}
267
268pub(crate) fn delete_contact(qt: &QtThread, shared: &Arc<Shared>, key: &str) {
270 let Some((path, account)) = ours(key).and_then(|p| Some((p.clone(), account_of(&p)?))) else { return };
271 mail::schedule_removal(qt, shared, &account, path, tr().text("undo-contact-deleted", None));
272 show_pim(qt, shared);
273}
274
275#[derive(serde::Serialize)]
276struct CalendarChoice {
277 id: String,
279 name: String,
280 color: Option<String>,
281}
282
283pub(crate) fn calendars() -> String {
285 let config = load_config();
286 let mut writable: Vec<Collection> = vdir::collections(Kind::Calendars).into_iter().filter(|c| !c.read_only).collect();
287 writable.sort_by_key(|c| c.id == sioul_core::blocks::CALENDAR);
289 let choices: Vec<CalendarChoice> = writable.into_iter().map(|c| CalendarChoice { id: format!("{}/{}", c.account, c.id), name: c.label(&config, tr()), color: c.color }).collect();
290 json(&choices)
291}
292
293#[derive(serde::Serialize)]
294struct EventView {
295 edit: EventEdit,
296 calendar: String,
297 read_only: bool,
298}
299
300pub(crate) fn overlaps(days: i64, removed: &std::collections::BTreeSet<PathBuf>, skipped: &std::collections::BTreeSet<(PathBuf, i64)>) -> String {
304 let now = Zoned::now();
305 let zone = now.time_zone().clone();
306 let midnight = now.date().to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second());
307 let aside = sioul_core::overlaps::SetAside::load(&sioul_core::overlaps::SetAside::default_path());
308 let hm = |at: i64| Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default();
309 let day = |at: i64| Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone()).date()).ok();
310 let one = |e: &agenda::Occurrence| serde_json::json!({ "key": e.key, "title": e.summary, "from": hm(e.start), "to": hm(e.end) });
311 let events: Vec<agenda::Occurrence> = agenda::occurrences(midnight, midnight + days.max(1) * 86_400)
312 .into_iter()
313 .filter(|o| !removed.contains(Path::new(&o.key)) && !skipped.contains(&(PathBuf::from(&o.key), o.start)))
314 .collect();
315 let rows: Vec<serde_json::Value> = sioul_core::overlaps::overlaps(&events)
316 .iter()
317 .filter(|o| !aside.keys.contains(&o.key) && o.first.end.max(o.second.end) > now.timestamp().as_second())
319 .map(|o| serde_json::json!({ "key": o.key, "day": day(o.second.start).map(|d| d.to_string()).unwrap_or_default(), "today": day(o.second.start) == Some(now.date()), "first": one(&o.first), "second": one(&o.second) }))
320 .collect();
321 json(&rows)
322}
323
324pub(crate) fn set_overlap_aside(key: &str) -> String {
326 let path = sioul_core::overlaps::SetAside::default_path();
327 let mut aside = sioul_core::overlaps::SetAside::load(&path);
328 aside.keys.insert(key.to_string());
329 aside.save(&path).err().unwrap_or_default()
330}
331
332pub(crate) fn event(key: &str) -> String {
334 let Some(path) = ours(key) else { return String::new() };
335 let Some(calendar) = collection_of(&path) else { return String::new() };
336 agenda::edit_of(&path)
337 .map(|edit| json(&EventView { edit, calendar: format!("{}/{}", calendar.account, calendar.id), read_only: calendar.read_only }))
338 .unwrap_or_default()
339}
340
341pub(crate) fn save_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, edit: &str, calendar: &str) -> Result<(), String> {
343 let links: Vec<String> = serde_json::from_str::<serde_json::Value>(edit)
345 .ok()
346 .and_then(|v| v.get("links").and_then(|l| l.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()))
347 .unwrap_or_default();
348 let edit: EventEdit = serde_json::from_str(edit).map_err(|e| e.to_string())?;
349 if edit.title.trim().is_empty() {
350 return Err(tr().text("event-no-title", None));
351 }
352 let zone = TimeZone::system();
353 let mut renamed: Option<String> = None;
355 let (path, text) = match ours(key) {
356 Some(path) => {
357 let current = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
358 let task = sioul_core::lines::unfold(¤t).iter().find(|l| sioul_core::lines::name(l) == sioul_core::blocks::TASK).map(|l| sioul_core::lines::value(l).trim().to_string());
359 if let Some(task) = task.filter(|t| !t.is_empty())
360 && agenda::edit_of_text(¤t, &zone).is_some_and(|before| before.title.trim() != edit.title.trim())
361 {
362 renamed = Some(task);
363 }
364 (path, agenda::apply(¤t, &edit, &zone)?)
365 }
366 None => {
367 let target = vdir::collections(Kind::Calendars)
368 .into_iter()
369 .find(|c| !c.read_only && format!("{}/{}", c.account, c.id) == calendar)
370 .or_else(agenda::default_calendar)
371 .ok_or_else(|| tr().text("dav-no-calendar", None))?;
372 let text = agenda::new_event(&edit, &zone)?;
373 let lines: Vec<String> = links
374 .iter()
375 .map(|uri| {
376 let rel = if uri.starts_with("mid:") { "via" } else { "related" };
377 sioul_core::tasks::link_line(&sioul_core::tasks::Link { uri: uri.clone(), label: String::new(), rel: rel.into() })
378 })
379 .collect();
380 (agenda::new_path(&target), agenda::add_lines(&text, &lines).unwrap_or(text))
381 }
382 };
383 vdir::write_item(&path, &text)?;
384 if let Some(account) = account_of(&path) {
385 nudge(shared, &account);
386 }
387 if let Some(task) = renamed {
388 let _ = crate::work::retitle(qt, shared, &task, edit.title.trim());
389 }
390 tell(qt, shared, tr().text("event-saved", None));
391 show_pim(qt, shared);
392 crate::work::show_work(qt, shared);
393 Ok(())
394}
395
396pub(crate) fn move_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, start: i64, new_start: i64, new_end: i64, only_this: bool) -> String {
402 let Some(path) = ours(key) else { return tr().text("agenda-gone", None) };
403 if collection_of(&path).is_none_or(|c| c.read_only) {
404 return tr().text("agenda-read-only", None);
405 }
406 let Ok(before) = std::fs::read_to_string(&path) else { return tr().text("agenda-gone", None) };
407 let zone = TimeZone::system();
408 let text = match agenda::moved(&before, start, new_start, new_end, only_this, &zone) {
409 Ok(text) => text,
410 Err(agenda::MoveProblem::Gone) => return tr().text("agenda-gone", None),
411 Err(agenda::MoveProblem::SetDays) => return tr().text("agenda-move-set-days", None),
412 Err(agenda::MoveProblem::Unreadable(e)) => return e,
413 };
414 if text == before {
415 return String::new();
416 }
417 if let Err(e) = vdir::write_item(&path, &text) {
418 return e;
419 }
420 let account = account_of(&path);
421 if let Some(account) = &account {
422 nudge(shared, account);
423 }
424 let at = |seconds: i64| Timestamp::from_second(seconds).map(|t| t.to_zoned(zone.clone())).ok();
426 let time = match (at(new_start), at(new_end)) {
427 (Some(from), Some(to)) => format!("{}, {}–{}", tr().day(from.date()), from.strftime("%H:%M"), to.strftime("%H:%M")),
428 _ => String::new(),
429 };
430 let title = agenda::edit_of_text(&text, &zone).map(|e| e.title).filter(|t| !t.trim().is_empty()).unwrap_or_else(|| tr().text("agenda-untitled", None));
431 let line = say("drag-done", &[("what", title), ("time", time)]);
432 mail::offer_back(qt, shared, line, move |qt, shared| {
433 if std::fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) {
435 return tr().text("undo-too-late", None);
436 }
437 if let Err(e) = vdir::write_item(&path, &before) {
438 return e;
439 }
440 if let Some(account) = &account {
441 nudge(shared, account);
442 }
443 show_pim(qt, shared);
444 crate::work::show_work(qt, shared);
445 String::new()
446 });
447 show_pim(qt, shared);
448 crate::work::show_work(qt, shared);
449 String::new()
450}
451
452pub(crate) fn delete_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, start: i64, only_this: bool) {
454 let Some((path, account)) = ours(key).and_then(|p| Some((p.clone(), account_of(&p)?))) else { return };
455 if only_this {
456 mail::schedule_skip(qt, shared, &account, path, start, tr().text("undo-occurrence-skipped", None));
457 } else {
458 mail::schedule_removal(qt, shared, &account, path, tr().text("undo-event-deleted", None));
459 }
460 show_pim(qt, shared);
461}
462
463pub(crate) fn completions(typed: &str) -> String {
465 let current = typed.rsplit([',', ';']).next().unwrap_or(typed);
467 json(&contacts::completions(&contacts::all(), current, 6))
468}
469
470pub(crate) fn contact_for(address: &str) -> String {
472 contacts::by_address(&contacts::all(), address).map(|c| c.key.clone()).unwrap_or_default()
473}
474
475pub(crate) fn add_sender(qt: &QtThread, shared: &Arc<Shared>, name: &str, address: &str) -> Result<String, String> {
477 let edit = ContactEdit {
478 name: if name.trim().is_empty() { address.to_string() } else { name.trim().to_string() },
479 emails: vec![contacts::Labeled { label: String::new(), value: address.trim().to_string() }],
480 ..ContactEdit::default()
481 };
482 save_contact(qt, shared, "", &serde_json::to_string(&serde_json::json!({ "name": edit.name, "emails": edit.emails })).unwrap_or_default())
483}
484
485#[derive(serde::Serialize)]
486struct InvitationView {
487 method: String,
489 summary: String,
490 when: String,
492 location: String,
493 organizer: String,
494 known: bool,
496}
497
498pub(crate) fn invitation(key: &str) -> String {
500 let Some(path) = maildir::locate(Path::new(key)) else { return String::new() };
501 let Some(text) = reading::calendar(&path) else { return String::new() };
502 let zone = TimeZone::system();
503 let Some(asked) = agenda::invitation(&text, &zone) else { return String::new() };
504 let at = |t: i64| Timestamp::from_second(t).map(|t| t.to_zoned(zone.clone())).ok();
505 let when = match (at(asked.start), at(asked.end)) {
506 (Some(start), _) if asked.all_day => tr().day(start.date()),
507 (Some(start), Some(end)) => format!("{}, {} – {}", tr().day(start.date()), start.strftime("%H:%M"), end.strftime("%H:%M")),
508 _ => String::new(),
509 };
510 json(&InvitationView { method: asked.method, summary: asked.summary, when, location: asked.location, organizer: asked.organizer, known: find_uid(&asked.uid).is_some() })
511}
512
513fn find_uid(uid: &str) -> Option<PathBuf> {
515 if uid.is_empty() {
516 return None;
517 }
518 vdir::collections(Kind::Calendars).into_iter().flat_map(|c| c.items()).find(|path| {
519 std::fs::read_to_string(path).ok().is_some_and(|text| sioul_core::lines::unfold(&text).iter().any(|l| sioul_core::lines::name(l) == "UID" && sioul_core::lines::value(l).trim() == uid))
520 })
521}
522
523pub(crate) fn answer(qt: &QtThread, shared: &Arc<Shared>, key: &str, answer: &str) -> Result<(), String> {
528 let path = maildir::locate(Path::new(key)).ok_or_else(|| tr().text("mail-message-gone", None))?;
529 let text = reading::calendar(&path).ok_or_else(|| tr().text("agenda-gone", None))?;
530 let zone = TimeZone::system();
531 let asked = agenda::invitation(&text, &zone).ok_or_else(|| tr().text("agenda-gone", None))?;
532 let known = find_uid(&asked.uid);
533 match answer {
534 "remove" => {
535 if let Some(file) = known {
536 let account = account_of(&file).unwrap_or_default();
537 mail::schedule_removal(qt, shared, &account, file, tr().text("undo-event-deleted", None));
538 }
539 return Ok(());
540 }
541 "accepted" | "tentative" | "add" => {
542 let calendar = agenda::default_calendar().ok_or_else(|| tr().text("dav-no-calendar", None))?;
543 let target = known.clone().unwrap_or_else(|| agenda::new_path(&calendar));
544 let me = load_config().account_of(&path).and_then(|a| a.address.clone()).unwrap_or_default();
548 let kept = if answer == "add" { agenda::stored(&text) } else { agenda::stored_answered(&text, &me, &answer.to_ascii_uppercase()) };
549 vdir::write_item(&target, &kept)?;
550 if let Some(account) = account_of(&target) {
551 nudge(shared, &account);
552 }
553 }
554 _ => {}
555 }
556 if answer != "add" {
557 send_answer(&path, &text, &asked, answer)?;
558 }
559 tell(qt, shared, tr().text(&format!("invitation-{answer}"), None));
560 show_pim(qt, shared);
561 crate::work::show_work(qt, shared);
563 Ok(())
564}
565
566fn send_answer(message: &Path, text: &str, asked: &agenda::Invitation, answer: &str) -> Result<(), String> {
568 let config = load_config();
569 let account = config.account_of(message).cloned().ok_or_else(|| tr().text("mail-no-account", None))?;
570 let me = account.address.clone().unwrap_or_default();
571 let partstat = answer.to_ascii_uppercase();
572 let ics = agenda::reply(text, &me, &partstat).ok_or_else(|| tr().text("agenda-gone", None))?;
573 let name = account.name.clone().unwrap_or_default();
574 let sentence = say(&format!("invitation-sentence-{answer}"), &[("name", if name.is_empty() { me.clone() } else { name.clone() }), ("summary", asked.summary.clone())]);
575 let subject = say(&format!("invitation-subject-{answer}"), &[("summary", asked.summary.clone())]);
576 let outgoing = sioul_core::compose::invitation_answer((&name, &me), &asked.organizer_address, &subject, &sentence, &ics, Zoned::now().timestamp().as_second())?;
577 let password = secret::password(&account).map_err(|e| e.sentence(tr(), &account.id))?;
578 let account = with_smtp(&account)?;
579 match sioul_sync::send::send(&account, &password, &outgoing) {
580 Ok(()) | Err(SyncError::NotFiled(_)) => Ok(()),
581 Err(e) => Err(e.sentence(tr(), &account.id)),
582 }
583}
584
585pub(crate) fn with_smtp(account: &Account) -> Result<Account, String> {
587 if account.smtp_host.is_some() {
588 return Ok(account.clone());
589 }
590 let smtp = sioul_sync::discover_smtp(account.address.as_deref().unwrap_or(""), account.host.as_deref()).map_err(|e| e.sentence(tr(), &account.id))?;
591 config::set_smtp(&config_path(), &account.id, &smtp.host, smtp.port, smtp.security)?;
592 let mut account = account.clone();
593 account.smtp_host = Some(smtp.host);
594 account.smtp_port = Some(smtp.port);
595 account.smtp_security = smtp.security;
596 Ok(account)
597}
598
599pub(crate) fn add_google_account(qt: &QtThread, shared: &Arc<Shared>, address: String, client_id: String, client_secret: String, again: Option<String>) {
603 let (qt, shared) = (qt.clone(), Arc::clone(shared));
604 shared.google_stop.store(false, Ordering::Relaxed);
605 std::thread::spawn(move || {
606 let config = load_config();
607 let address = address.trim().to_ascii_lowercase();
608 let kept = again.as_ref().and_then(|_| google::grant(&address));
610 let (client_id, client_secret) = match (&kept, google::built_in()) {
612 (Some(grant), _) if client_id.trim().is_empty() => (grant.client_id.clone(), grant.client_secret.clone()),
613 (_, Some((id, secret))) if client_id.trim().is_empty() => (id.to_string(), secret.to_string()),
614 _ => (client_id, client_secret),
615 };
616 let id = again.clone().unwrap_or_else(|| {
617 let base = config::slug("google");
618 std::iter::once(base.clone()).chain((2..).map(|n| format!("{base}-{n}"))).find(|id| config.every_account().all(|a| &a.id != id)).unwrap_or(base)
620 });
621 let outcome = google::begin(&client_id, &address)
622 .and_then(|sign_in| {
623 let url = sign_in.url.clone();
624 let _ = qt.queue(move |mut sioul| sioul.as_mut().open_url(QString::from(&url)));
625 google::finish(sign_in, &client_id, &client_secret, &address, &shared.google_stop)
626 })
627 .and_then(|_| dav::test_google(&address))
628 .map_err(|e| google_sentence(&e, &id))
629 .and_then(|homes: Homes| {
630 if again.is_none() {
631 let mut account = Account::dav(&id, &address, "apidata.googleusercontent.com", None, Some(&address));
632 account.auth = Some("google".into());
633 config::add_dav_account(&config_path(), &account)?;
634 }
635 homes.save(&id).map_err(|e| e.sentence(tr(), &id))?;
636 load_config().account(&id).cloned().ok_or_else(|| say("account-unknown", &[("id", id.clone())]))
637 });
638 let added = outcome.as_ref().ok().map(|a| say("dav-added", &[("id", a.id.clone())]));
639 if let Ok(account) = outcome.as_ref() {
640 stop_dav_watcher(&shared, &account.id);
641 start_dav_watcher(&qt, &shared, account.clone());
642 }
643 let error = outcome.err();
644 let _ = qt.queue(move |mut sioul| {
645 sioul.as_mut().set_form_busy(false);
646 match (added, error) {
647 (Some(line), _) => {
648 sioul.as_mut().set_status(QString::from(&line));
649 sioul.as_mut().account_added();
650 }
651 (None, Some(e)) => sioul.as_mut().set_form_error(QString::from(&e)),
652 (None, None) => {}
653 }
654 });
655 show(&qt, &shared);
656 });
657}
658
659pub(crate) fn google_sentence(error: &SyncError, id: &str) -> String {
661 match error {
662 SyncError::Message(m) if m.starts_with("google-") => {
663 let (key, other) = m.split_once(':').unwrap_or((m.as_str(), ""));
664 say(key, &[("address", other.to_string())])
665 }
666 e => e.sentence(tr(), id),
667 }
668}
669
670pub(crate) fn add_account(qt: &QtThread, shared: &Arc<Shared>, address: String, url: String, login: String, password: String) {
673 let (qt, shared) = (qt.clone(), Arc::clone(shared));
674 std::thread::spawn(move || {
675 let config = load_config();
676 let address = address.trim().to_ascii_lowercase();
677 let login = if login.trim().is_empty() { address.clone() } else { login.trim().to_string() };
678 let mail_host = config.accounts.iter().find(|a| a.address.as_deref() == Some(address.as_str())).and_then(|a| a.host.clone());
679 let given = Some(url.trim()).filter(|u| !u.is_empty());
680 let label = address.rsplit('@').next().unwrap_or("dav").split('.').next().unwrap_or("dav");
681 let base = config::slug(&format!("{label}-agenda"));
682 let id = std::iter::once(base.clone()).chain((2..).map(|n| format!("{base}-{n}"))).find(|id| config.every_account().all(|a| &a.id != id)).unwrap_or(base);
683 let outcome = dav::test(&address, &login, &password, given, mail_host.as_deref()).map_err(|e| e.sentence(tr(), &id)).and_then(|homes: Homes| {
684 let found = homes.calendars.clone().or(homes.contacts.clone()).unwrap_or_default();
685 let host = found.split_once("://").map_or(found.as_str(), |(_, r)| r).split(['/', ':']).next().unwrap_or("").to_string();
686 let account = Account::dav(&id, &address, &host, Some(&found), Some(&login));
687 secret::save(&account, &password).map_err(|e| e.sentence(tr(), &id))?;
688 config::add_dav_account(&config_path(), &account)?;
689 homes.save(&id).map_err(|e| e.sentence(tr(), &id))?;
690 Ok(account)
691 });
692 let added = outcome.as_ref().ok().map(|a| say("dav-added", &[("id", a.id.clone())]));
693 if let Ok(account) = outcome.as_ref() {
694 start_dav_watcher(&qt, &shared, account.clone());
695 }
696 let error = outcome.err();
697 let _ = qt.queue(move |mut sioul| {
698 sioul.as_mut().set_form_busy(false);
699 match (added, error) {
700 (Some(line), _) => {
701 sioul.as_mut().set_status(QString::from(&line));
702 sioul.as_mut().account_added();
703 }
704 (None, Some(e)) => sioul.as_mut().set_form_error(QString::from(&e)),
705 (None, None) => {}
706 }
707 });
708 show(&qt, &shared);
709 });
710}