Skip to main content

sioul_app/
pim.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Contacts and calendars behind the window.
5//!
6//! What you change is written to the folders on disk at once, then the
7//! account's sync sends it, right away when the server answers, later when it
8//! does not: nothing waits on the network. Each contacts-and-calendars account
9//! syncs in the background every fifteen minutes, and at once after a change
10//! here. Deleting waits ten seconds with "Undo", as in the mail.
11
12use 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
29/// Between two syncs of an account's contacts and calendars, unless something changed here.
30const DAV_PAUSE: Duration = Duration::from_secs(15 * 60);
31
32/// What the contacts and agenda pages show: their search and their stretch of days.
33#[derive(Clone)]
34pub(crate) struct PimState {
35    pub query: String,
36    /// The contacts of one category only; empty: all of them.
37    pub category: String,
38    pub from: jiff::civil::Date,
39    pub days: i64,
40    /// What comes only: events over already are left out (the list opened on today).
41    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
50/// Computes both pages on a thread and shows them.
51pub(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        // The phone's home-screen card reads its month of events again (homecard.rs).
58        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            // What comes: an event over is the past, shown only when you go back to it.
69            .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        // The planning shows the hours given to something, not the night.
73        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            // Only the newest (see `backend::show`).
78            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
88/// The account a contact or event file belongs to, from its folder.
89pub(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
95/// The collection a file is in.
96pub(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
101/// A file inside Sioul's address books and calendars, never elsewhere: no
102/// "..", which `starts_with` would let through ("contacts/../../.bashrc").
103pub(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
110/// The account's background sync runs now.
111pub(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
117/// Stops an account's background sync: it was removed.
118pub(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
124/// Starts a background sync for each contacts-and-calendars account that has none.
125pub(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            // Asked again ("Sync now", an account changed): a refused password is tried again too.
143            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        // After a lasting error (a refused password) it waits for a nudge, never ends.
151        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
160/// What a sync said: the account's status, conflicts in the status line, the pages again.
161fn 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    // The pages again only when something came, went or clashed: a sync that
171    // changed nothing every quarter of an hour costs nothing (a phone's battery).
172    let changed = match result {
173        Ok(report) => {
174            let changed = report.sent + report.received + report.removed > 0 || !report.conflicts.is_empty();
175            // Yours kept among the earlier versions, which the window lists and puts back, on a phone too.
176            if !report.conflicts.is_empty() {
177                tell(qt, shared, tr().text("dav-conflict", None));
178            }
179            // An invitation you had answered otherwise on another device: its answer kept, said once.
180            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
197/// A contact, every field, for its card and its form.
198pub(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
204/// Saves the contact form: into its file, or a new one in the first address book; then sent.
205/// Returns the contact's file.
206pub(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(&current, &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
230/// The address books a contact can go into: [{id, name}].
231pub(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
237/// A contact moved to another address book: written there, then taken out
238/// here. When the book would not keep something the card holds, nothing moves
239/// until `confirmed`: returns `{"losses": [words]}`, else `{"key"}` or `{"error"}`.
240pub(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
268/// Deletes a contact, after ten seconds to undo.
269pub(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    /// "account/id", to say where a new event goes.
278    id: String,
279    name: String,
280    color: Option<String>,
281}
282
283/// The calendars an event can go into.
284pub(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    // The one made for time blocks last: a new event goes elsewhere first (docs/tasks.md, "Pinned to a time").
288    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
300/// Two events at once in the next `days` days, their margins counted, those
301/// set aside left out, as JSON: [{key, day, today, first, second}], each
302/// event {key, title, from, to} ("09:30"), for the Porch and the agenda.
303pub(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        // Over already: nothing to do about it.
318        .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
324/// An overlap set aside for good; returns what went wrong, else "".
325pub(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
332/// An event as its form shows it.
333pub(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
341/// Saves the event form: into its file, or a new one in `calendar` ("account/id"); then sent.
342pub(crate) fn save_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, edit: &str, calendar: &str) -> Result<(), String> {
343    // What a new event is made from: a message, a task (`links`).
344    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    // A task's time block renamed here: its task takes the title too, as both are kept in step (docs/tasks.md, "Pinned to a time").
354    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(&current).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(&current, &zone).is_some_and(|before| before.title.trim() != edit.title.trim())
361            {
362                renamed = Some(task);
363            }
364            (path, agenda::apply(&current, &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
396/// An event moved by a drag in the agenda: its occurrence starting at `start`
397/// now from `new_start` to `new_end` (Unix seconds), that time alone or every
398/// time (`agenda::moved`); then sent, the agenda and the plan made again, and
399/// "Undo" offered for ten seconds, which writes back what the file held, unless
400/// it changed since (a sync, another device). Returns what went wrong, else "".
401pub(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    // Said as it is now: its title, its day, its times.
425    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        // Only what this drag wrote is put back: a change since stays.
434        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
452/// Deletes an event, or only this occurrence of a repeating one, after ten seconds to undo.
453pub(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
463/// Addresses to complete what is typed in an address field, as JSON.
464pub(crate) fn completions(typed: &str) -> String {
465    // Only the part after the last comma is being typed.
466    let current = typed.rsplit([',', ';']).next().unwrap_or(typed);
467    json(&contacts::completions(&contacts::all(), current, 6))
468}
469
470/// The contact with this address, by file; empty when there is none.
471pub(crate) fn contact_for(address: &str) -> String {
472    contacts::by_address(&contacts::all(), address).map(|c| c.key.clone()).unwrap_or_default()
473}
474
475/// A sender becomes a contact, in the first address book.
476pub(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    /// "REQUEST": answer; "CANCEL": take it out; "PUBLISH": add.
488    method: String,
489    summary: String,
490    /// "Thursday 8 October, 10:00 – 11:00".
491    when: String,
492    location: String,
493    organizer: String,
494    /// Already in one of your calendars.
495    known: bool,
496}
497
498/// The invitation a message carries, as the reader shows it; empty when it carries none.
499pub(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
513/// The event file with this UID, in any calendar.
514fn 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
523/// Answers an invitation: "accepted", "tentative" or "declined". Accepted or
524/// maybe, the event goes into your first calendar; then your answer goes to
525/// the organizer, from the account the invitation came to. "add" only adds it
526/// (an event published, not asked); "remove" takes a cancelled one out.
527pub(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            // Answered, your calendar keeps your answer on your own line: a device
545            // answering it too can tell whether it said the same (`dav`, an item
546            // the server holds already under its UID).
547            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    // Accepted or added, it takes its time in the plan at once, not after the sync.
562    crate::work::show_work(qt, shared);
563    Ok(())
564}
565
566/// The REPLY to the organizer, through the account the invitation came to.
567fn 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
585/// The account with its sending server, found and kept when it had none.
586pub(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
599/// Adds a Google account, or signs one in again (`again`: its id): Google's
600/// page opens in your browser, Sioul waits for its answer here (Cancel stops
601/// the wait), finds the calendars and contacts, and syncs them. On a thread.
602pub(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        // Signed in again: the client kept with its grant.
609        let kept = again.as_ref().and_then(|_| google::grant(&address));
610        // The key kept (signing in again), else the one given, else Sioul's own when the build has one.
611        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            // Those switched off keep their id too.
619            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
659/// What went wrong while signing in with Google, in your language (its mail's sign-in too, gmail.rs).
660pub(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
670/// Adds a contacts-and-calendars account: finds where they are, tests the
671/// password, keeps it in the keyring, and starts syncing. On a thread.
672pub(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}