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_items(from, to, &removed, &skipped, &crate::work::loaded(shared).tasks)
66            .into_iter()
67            // What comes: an event over is the past, shown only when you go back to it.
68            .filter(|o| !state.upcoming || o.end.max(o.start) > now)
69            .collect();
70        let mut shown = view::agenda(&occurrences, state.from, state.days, tr(), &zone);
71        // The planning shows the hours given to something, not the night.
72        view::set_hours(&mut shown, &load_config().week_hours(), &zone);
73        let agenda = json(&shown);
74        let clashes = overlaps(14, &removed, &skipped);
75        let _ = qt.queue(move |mut sioul| {
76            // Only the newest (see `backend::show`).
77            if sioul.shared().pim_generation.load(Ordering::Relaxed) != generation || sioul.shared().pim_shown_generation.fetch_max(generation, Ordering::Relaxed) > generation {
78                return;
79            }
80            sioul.as_mut().set_contacts(QString::from(&contacts));
81            sioul.as_mut().set_agenda(QString::from(&agenda));
82            sioul.as_mut().set_overlaps(QString::from(&clashes));
83        });
84    });
85}
86
87/// What every agenda shows from `from` to `to` (Unix seconds), the
88/// window's, the phone's and the home card alike (21 October 2026): the
89/// calendars' events, those whose deletion waits ("Undo") aside, a task's
90/// time block marked as the task it is, with its list (`agenda::mark_tasks`).
91pub(crate) fn agenda_items(from: i64, to: i64, removed: &std::collections::BTreeSet<PathBuf>, skipped: &std::collections::BTreeSet<(PathBuf, i64)>, tasks: &[sioul_core::tasks::Task]) -> Vec<agenda::Occurrence> {
92    let mut items = agenda_read(from, to, removed, skipped);
93    agenda::mark_tasks(&mut items, tasks);
94    items
95}
96
97/// The calendars' events from `from` to `to`, those whose deletion waits
98/// aside, before their tasks are known (`agenda_items`; the home card marks
99/// them with the tasks of its plan).
100pub(crate) fn agenda_read(from: i64, to: i64, removed: &std::collections::BTreeSet<PathBuf>, skipped: &std::collections::BTreeSet<(PathBuf, i64)>) -> Vec<agenda::Occurrence> {
101    agenda::occurrences(from, to).into_iter().filter(|o| !removed.contains(Path::new(&o.key)) && !skipped.contains(&(PathBuf::from(&o.key), o.start))).collect()
102}
103
104/// The account a contact or event file belongs to, from its folder.
105pub(crate) fn account_of(path: &Path) -> Option<String> {
106    [Kind::Contacts, Kind::Calendars]
107        .into_iter()
108        .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()))
109}
110
111/// The collection a file is in.
112pub(crate) fn collection_of(path: &Path) -> Option<Collection> {
113    let dir = path.parent()?;
114    [Kind::Contacts, Kind::Calendars].into_iter().flat_map(vdir::collections).find(|c| c.dir == dir)
115}
116
117/// Whether a calendar stays on this device alone (21 October 2026): this
118/// device's own (`vdir::LOCAL`) while the sharing does not carry them (no
119/// sharing set up, or its part "lists" off here). A server's calendar is on
120/// every device that has its account.
121pub(crate) fn here_only(calendar: &Collection) -> bool {
122    calendar.account == vdir::LOCAL && !(crate::share::configured() && crate::share::shares("lists"))
123}
124
125/// A file inside Sioul's address books and calendars, never elsewhere: no
126/// "..", which `starts_with` would let through ("contacts/../../.bashrc").
127pub(crate) fn ours(key: &str) -> Option<PathBuf> {
128    let path = PathBuf::from(key);
129    let climbs = path.components().any(|c| c == std::path::Component::ParentDir);
130    let inside = [Kind::Contacts, Kind::Calendars].iter().any(|k| path.starts_with(k.root()));
131    (inside && !climbs && path.is_file()).then_some(path)
132}
133
134/// The account's background sync runs now.
135pub(crate) fn nudge(shared: &Shared, account: &str) {
136    if let Some(control) = shared.dav_watchers.lock().ok().and_then(|w| w.get(account).cloned()) {
137        control.nudge();
138    }
139}
140
141/// Stops an account's background sync: it was removed.
142pub(crate) fn stop_dav_watcher(shared: &Arc<Shared>, account: &str) {
143    if let Some(control) = shared.dav_watchers.lock().ok().and_then(|mut w| w.remove(account)) {
144        control.stop();
145    }
146}
147
148/// Starts a background sync for each contacts-and-calendars account that has none.
149pub(crate) fn start_dav_watchers(qt: &QtThread, shared: &Arc<Shared>) -> usize {
150    if crate::backend::offline() {
151        return 0;
152    }
153    let config = load_config();
154    let accounts: Vec<Account> = config.accounts.iter().filter(|a| a.is_dav()).cloned().collect();
155    let count = accounts.len();
156    // The switched-off marks, this device's own, made to follow the configuration, which travels.
157    let ids = |list: &[Account]| list.iter().filter(|a| a.kind == sioul_core::config::AccountKind::Dav).map(|a| a.id.clone()).collect::<Vec<_>>();
158    if vdir::follow_switches(&ids(&config.accounts), &ids(&config.accounts_off)) {
159        eprintln!("calendars: an account switched on or off on another device: its calendars follow here");
160        show_pim(qt, shared);
161    }
162    // The phone's log: whether this device has any to sync, never which (21 October 2026).
163    let off = config.accounts_off.iter().filter(|a| a.is_dav()).count();
164    let unread = config.every_account().filter(|a| a.kind == sioul_core::config::AccountKind::Dav && a.host.is_none()).count();
165    eprintln!("{}", watchers_line(count, off, unread));
166    for account in accounts {
167        start_dav_watcher(qt, shared, account);
168    }
169    count
170}
171
172pub(crate) fn start_dav_watcher(qt: &QtThread, shared: &Arc<Shared>, account: Account) {
173    let control = Arc::new(Control::default());
174    {
175        let Ok(mut watchers) = shared.dav_watchers.lock() else { return };
176        if let Some(running) = watchers.get(&account.id) {
177            // Asked again ("Sync now", an account changed): a refused password is tried again too.
178            running.retry();
179            return;
180        }
181        watchers.insert(account.id.clone(), Arc::clone(&control));
182    }
183    let (qt, shared) = (qt.clone(), Arc::clone(shared));
184    std::thread::spawn(move || {
185        // What this device has for it, in yes or no: its homes found here, its password kept here.
186        let homes = !dav::Homes::load(&account.id).is_empty();
187        let password = account.auth.as_deref() == Some("google") || sioul_sync::secret::password(&account).is_ok();
188        eprintln!("calendars: an account's sync starts (its homes {} here, its password {} here)", if homes { "known" } else { "not known yet" }, if password { "kept" } else { "not kept" });
189        // After a lasting error (a refused password) it waits for a nudge, never ends.
190        dav::watch(&account, &control, DAV_PAUSE, |result| reported(&qt, &shared, &account, result));
191        if let Ok(mut watchers) = shared.dav_watchers.lock()
192            && watchers.get(&account.id).is_some_and(|c| Arc::ptr_eq(c, &control))
193        {
194            watchers.remove(&account.id);
195        }
196    });
197}
198
199/// The phone's log at the start of the syncs: how many contacts-and-calendars
200/// accounts this device has, switched on and off, and those it cannot sync
201/// (no server named); never which.
202fn watchers_line(on: usize, off: usize, unread: usize) -> String {
203    let mut line = format!("calendars: {on} contacts-and-calendars account(s) here, synced while the window is open");
204    if off > 0 {
205        line.push_str(&format!("; {off} switched off"));
206    }
207    if unread > 0 {
208        line.push_str(&format!("; {unread} without a server, not synced"));
209    }
210    if on == 0 {
211        line.push_str("; the calendars shown are this device's own and those your other devices share (none of a server's)");
212    }
213    line
214}
215
216/// The phone's log after each sync of an account (21 October 2026): counts
217/// only, and what went wrong as a code; never a name, an address or a calendar's.
218fn sync_line(result: &Result<dav::Report, SyncError>) -> String {
219    match result {
220        Ok(r) => format!(
221            "calendars: {} found ({} read only, {} for tasks only), {} synced, {} new or renamed; address books: {} found, {} synced; first problem: {}",
222            r.calendars_listed,
223            r.calendars_read_only,
224            r.calendars_without_events,
225            r.calendars_synced,
226            r.registered,
227            r.listed - r.calendars_listed,
228            r.collections.saturating_sub(r.calendars_synced),
229            r.problems.first().map_or("none", SyncError::code)
230        ),
231        Err(e) => format!("calendars: the account's sync stopped: {}{}", e.code(), if e.is_lasting() { "; it waits for a change (a password given, Sync now) before trying again" } else { "; tried again at the next sync" }),
232    }
233}
234
235/// An account's status after a sync that listed its collections: how many
236/// were found and how many synced whole, at what time; with what went wrong
237/// with some of them, the first (the phone's calendars of 21 October 2026:
238/// a partial state is never silent).
239fn status_of(report: &dav::Report, account: &str, time: &str) -> (String, bool) {
240    let (found, synced) = (report.listed.to_string(), report.collections.to_string());
241    match report.problems.first() {
242        None => (say("dav-synced-counts", &[("time", time.to_string()), ("found", found), ("synced", synced)]), false),
243        Some(e) => (say("dav-synced-partial", &[("found", found), ("synced", synced), ("problem", e.sentence(tr(), account))]), true),
244    }
245}
246
247/// What a sync said: the account's status, conflicts in the status line, the pages again.
248fn reported(qt: &QtThread, shared: &Arc<Shared>, account: &Account, result: Result<dav::Report, SyncError>) {
249    eprintln!("{}", sync_line(&result));
250    let (line, problem) = match &result {
251        Ok(report) => status_of(report, &account.id, &Zoned::now().strftime("%H:%M").to_string()),
252        Err(e) => (e.sentence(tr(), &account.id), true),
253    };
254    if let Ok(mut statuses) = shared.statuses.lock() {
255        statuses.insert(account.id.clone(), (line.clone(), problem));
256    }
257    crate::backend::want_password(shared, &account.id, result.as_ref().err());
258    // The pages again only when something came, went or clashed: a sync that
259    // changed nothing every quarter of an hour costs nothing (a phone's battery).
260    let changed = match result {
261        Ok(report) => {
262            // A sync of an account switched on: its calendars show here, whatever an old mark says.
263            if vdir::follow_switches(std::slice::from_ref(&account.id), &[]) {
264                eprintln!("calendars: an account's calendars were marked off here, though it is on: shown again");
265            }
266            // Collections new here, renamed or recoloured count too: the lists are read again.
267            let changed = report.sent + report.received + report.removed > 0 || !report.conflicts.is_empty() || report.registered > 0;
268            // Yours kept among the earlier versions, which the window lists and puts back, on a phone too.
269            if !report.conflicts.is_empty() {
270                tell(qt, shared, tr().text("dav-conflict", None));
271            }
272            // An invitation you had answered otherwise on another device: its answer kept, said once.
273            for (summary, kept) in report.answered {
274                tell(qt, shared, say("dav-answered-elsewhere", &[("summary", summary), ("answer", kept.to_ascii_lowercase())]));
275            }
276            changed
277        }
278        Err(_) => {
279            set_status(qt, line);
280            true
281        }
282    };
283    if changed {
284        show(qt, shared);
285        show_pim(qt, shared);
286        crate::work::show_work(qt, shared);
287    }
288}
289
290/// A contact, every field, for its card and its form.
291pub(crate) fn contact(key: &str) -> String {
292    let Some(path) = ours(key) else { return String::new() };
293    let Some(book) = collection_of(&path) else { return String::new() };
294    contacts::read(&path, &book).map(|c| json(&c)).unwrap_or_default()
295}
296
297/// Saves the contact form: into its file, or a new one in the first address book; then sent.
298/// Returns the contact's file.
299pub(crate) fn save_contact(qt: &QtThread, shared: &Arc<Shared>, key: &str, edit: &str) -> Result<String, String> {
300    let edit: ContactEdit = serde_json::from_str(edit).map_err(|e| e.to_string())?;
301    if edit.name.trim().is_empty() && edit.emails.iter().all(|e| e.value.trim().is_empty()) {
302        return Err(tr().text("contact-no-name", None));
303    }
304    let (path, text) = match ours(key) {
305        Some(path) => {
306            let current = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
307            (path, contacts::apply(&current, &edit))
308        }
309        None => {
310            let book = contacts::default_book().ok_or_else(|| tr().text("dav-no-book", None))?;
311            (contacts::new_path(&book), contacts::new_card(&edit))
312        }
313    };
314    vdir::write_item(&path, &text)?;
315    if let Some(account) = account_of(&path) {
316        nudge(shared, &account);
317    }
318    tell(qt, shared, tr().text("contact-saved", None));
319    show_pim(qt, shared);
320    Ok(path.display().to_string())
321}
322
323/// The address books a contact can go into: [{id, name}].
324pub(crate) fn books() -> String {
325    let config = load_config();
326    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();
327    json(&rows)
328}
329
330/// A contact moved to another address book: written there, then taken out
331/// here. When the book would not keep something the card holds, nothing moves
332/// until `confirmed`: returns `{"losses": [words]}`, else `{"key"}` or `{"error"}`.
333pub(crate) fn move_contact(qt: &QtThread, shared: &Arc<Shared>, key: &str, book: &str, confirmed: bool) -> String {
334    let fail = |e: String| serde_json::json!({ "error": e }).to_string();
335    let Some(from) = ours(key) else { return fail(tr().text("contact-gone", None)) };
336    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)) };
337    if collection_of(&from).is_some_and(|c| c.read_only) {
338        return fail(tr().text("contact-read-only", None));
339    }
340    if from.parent() == Some(target.dir.as_path()) {
341        return serde_json::json!({ "key": from.display().to_string() }).to_string();
342    }
343    let Ok(text) = std::fs::read_to_string(&from) else { return fail(tr().text("contact-gone", None)) };
344    let provider = sioul_core::capabilities::provider_of(load_config().account(&target.account));
345    let losses = sioul_core::capabilities::contact_losses(&text, provider);
346    if !losses.is_empty() && !confirmed {
347        return serde_json::json!({ "losses": losses.iter().map(|l| tr().text(&format!("loss-{l}"), None)).collect::<Vec<_>>() }).to_string();
348    }
349    let to = contacts::new_path(&target);
350    if let Err(e) = vdir::write_item(&to, &text).and_then(|()| std::fs::remove_file(&from).map_err(|e| e.to_string())) {
351        return fail(e);
352    }
353    for account in [account_of(&from), Some(target.account.clone())].into_iter().flatten() {
354        nudge(shared, &account);
355    }
356    tell(qt, shared, tr().text("contact-moved", None));
357    show_pim(qt, shared);
358    serde_json::json!({ "key": to.display().to_string() }).to_string()
359}
360
361/// Deletes a contact, after ten seconds to undo.
362pub(crate) fn delete_contact(qt: &QtThread, shared: &Arc<Shared>, key: &str) {
363    let Some((path, account)) = ours(key).and_then(|p| Some((p.clone(), account_of(&p)?))) else { return };
364    mail::schedule_removal(qt, shared, &account, path, tr().text("undo-contact-deleted", None));
365    show_pim(qt, shared);
366}
367
368#[derive(serde::Serialize)]
369struct CalendarChoice {
370    /// "account/id", to say where a new event goes.
371    id: String,
372    name: String,
373    color: Option<String>,
374}
375
376/// The calendars an event can go into.
377pub(crate) fn calendars() -> String {
378    let config = load_config();
379    // Those that take events: a task list is the task views' (21 October 2026).
380    let mut writable: Vec<Collection> = vdir::collections(Kind::Calendars).into_iter().filter(|c| !c.read_only && c.holds("VEVENT")).collect();
381    // The one made for time blocks last: a new event goes elsewhere first (docs/tasks.md, "Pinned to a time");
382    // the one chosen in Settings ▸ Agenda first.
383    let default = agenda::default_calendar().map(|c| format!("{}/{}", c.account, c.id));
384    writable.sort_by_key(|c| (default.as_deref() != Some(format!("{}/{}", c.account, c.id).as_str()), c.id == sioul_core::blocks::CALENDAR));
385    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();
386    json(&choices)
387}
388
389#[derive(serde::Serialize)]
390struct EventView {
391    edit: EventEdit,
392    calendar: String,
393    /// Its calendar's name and place, for the editor to say it.
394    calendar_name: String,
395    /// "Only on this computer", when its calendar stays here alone; else "".
396    here_only: String,
397    read_only: bool,
398    /// Why it stays in its calendar, said in the editor: a meeting with others (`with_others`); else "".
399    stays: String,
400}
401
402/// Two events at once in the next `days` days, or closer than a time you set
403/// between them (`overlaps::overlaps`), those set aside left out, as JSON:
404/// [{key, day, today, first, second, gap, needed, at_once, source}], each
405/// event {key, title, from, to} ("09:30"), `gap` and `needed` in minutes,
406/// `source` where that time is set, in words, for the Porch and the agenda.
407pub(crate) fn overlaps(days: i64, removed: &std::collections::BTreeSet<PathBuf>, skipped: &std::collections::BTreeSet<(PathBuf, i64)>) -> String {
408    let now = Zoned::now();
409    let zone = now.time_zone().clone();
410    let midnight = now.date().to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second());
411    let aside = sioul_core::overlaps::SetAside::load(&sioul_core::overlaps::SetAside::default_path());
412    let hm = |at: i64| Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone()).strftime("%H:%M").to_string()).unwrap_or_default();
413    let day = |at: i64| Timestamp::from_second(at).map(|t| t.to_zoned(zone.clone()).date()).ok();
414    let one = |e: &agenda::Occurrence| serde_json::json!({ "key": e.key, "title": e.summary, "from": hm(e.start), "to": hm(e.end) });
415    let events: Vec<agenda::Occurrence> = agenda::occurrences(midnight, midnight + days.max(1) * 86_400)
416        .into_iter()
417        .filter(|o| !removed.contains(Path::new(&o.key)) && !skipped.contains(&(PathBuf::from(&o.key), o.start)))
418        .collect();
419    let between = load_config().agenda.between();
420    let rows: Vec<serde_json::Value> = sioul_core::overlaps::overlaps(&events, between)
421        .iter()
422        // Over already: nothing to do about it.
423        .filter(|o| !aside.keys.contains(&o.key) && o.first.end.max(o.second.end) > now.timestamp().as_second())
424        .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), "gap": o.gap, "needed": o.needed, "at_once": o.at_once(), "source": if o.source.is_empty() { String::new() } else { tr().text(&format!("overlap-source-{}", o.source), None) } }))
425        .collect();
426    json(&rows)
427}
428
429/// An overlap set aside for good; returns what went wrong, else "".
430pub(crate) fn set_overlap_aside(key: &str) -> String {
431    let path = sioul_core::overlaps::SetAside::default_path();
432    let mut aside = sioul_core::overlaps::SetAside::load(&path);
433    aside.keys.insert(key.to_string());
434    aside.save(&path).err().unwrap_or_default()
435}
436
437/// An event as its form shows it: the calendar it belongs to, by name too,
438/// and whether that calendar stays on this device alone.
439pub(crate) fn event(key: &str) -> String {
440    let Some(path) = ours(key) else { return String::new() };
441    let Some(calendar) = collection_of(&path) else { return String::new() };
442    let config = load_config();
443    let place = if here_only(&calendar) { tr().text(if cfg!(target_os = "android") { "event-calendar-here-phone" } else { "event-calendar-here-computer" }, None) } else { String::new() };
444    let stays = if std::fs::read_to_string(&path).is_ok_and(|t| with_others(&t)) { tr().text("event-move-with-others", None) } else { String::new() };
445    agenda::edit_of(&path)
446        .map(|edit| json(&EventView { edit, calendar: format!("{}/{}", calendar.account, calendar.id), calendar_name: calendar.label(&config, tr()), here_only: place, read_only: calendar.read_only, stays }))
447        .unwrap_or_default()
448}
449
450/// An event's file moved to another calendar (its editor, 21 October 2026),
451/// of any account, this device's own to a server's above all, so that an
452/// event filed there by mistake reaches your other devices: noted, written
453/// there, taken out here (`vdir::move_item`, a step cut short finished at the
454/// next look), never twice, never lost. The sync sends both: the new item
455/// there, the deletion here. Its new path.
456pub(crate) fn move_event_file(path: &Path, to: &Collection) -> Result<PathBuf, String> {
457    if path.parent() == Some(to.dir.as_path()) {
458        return Ok(path.to_path_buf());
459    }
460    // A meeting with others is never moved: its server would cancel it (or
461    // decline it) for them from the old calendar, then invite them again
462    // (the review of 22 October 2026, F2).
463    if std::fs::read_to_string(path).is_ok_and(|t| with_others(&t)) {
464        return Err(tr().text("event-move-with-others", None));
465    }
466    if to.read_only || !to.holds("VEVENT") {
467        return Err(tr().text("dav-no-calendar", None));
468    }
469    let moved = agenda::new_path(to);
470    vdir::move_item(path, &moved)?;
471    Ok(moved)
472}
473
474/// Whether an event's text names an organizer or guests: a meeting with
475/// others, which a server's scheduling answers for (RFC 6638), mail to them included.
476pub(crate) fn with_others(text: &str) -> bool {
477    sioul_core::lines::unfold(text).iter().any(|l| matches!(sioul_core::lines::name(l).as_str(), "ORGANIZER" | "ATTENDEE"))
478}
479
480/// Saves the event form: into its file, or a new one in `calendar` ("account/id"); then sent.
481pub(crate) fn save_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, edit: &str, calendar: &str) -> Result<(), String> {
482    let saved = write_event(key, edit, calendar)?;
483    for account in &saved.accounts {
484        nudge(shared, account);
485    }
486    if let Some(task) = saved.renamed {
487        let _ = crate::work::retitle(qt, shared, &task, &saved.title);
488    }
489    tell(qt, shared, tr().text("event-saved", None));
490    show_pim(qt, shared);
491    crate::work::show_work(qt, shared);
492    Ok(())
493}
494
495/// What saving the event form wrote: its file at last, the accounts to tell,
496/// the task of a time block renamed, and its title.
497struct SavedEvent {
498    // Where it is at last: what the tests look at.
499    #[cfg_attr(not(test), allow(dead_code))]
500    path: PathBuf,
501    accounts: Vec<String>,
502    renamed: Option<String>,
503    title: String,
504}
505
506/// The event form written (`save_event`, off the window): into its file, or
507/// a new one in `calendar`; an event already there moved to `calendar` when
508/// another is chosen (`move_event_file`).
509fn write_event(key: &str, edit: &str, calendar: &str) -> Result<SavedEvent, String> {
510    // What a new event is made from: a message, a task (`links`).
511    let links: Vec<String> = serde_json::from_str::<serde_json::Value>(edit)
512        .ok()
513        .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()))
514        .unwrap_or_default();
515    let edit: EventEdit = serde_json::from_str(edit).map_err(|e| e.to_string())?;
516    if edit.title.trim().is_empty() {
517        return Err(tr().text("event-no-title", None));
518    }
519    let zone = TimeZone::system();
520    // 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").
521    let mut renamed: Option<String> = None;
522    let (path, text) = match ours(key) {
523        Some(path) => {
524            let current = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
525            // Another calendar chosen for a meeting with others: nothing written (F2).
526            let elsewhere = vdir::collections(Kind::Calendars).into_iter().find(|c| format!("{}/{}", c.account, c.id) == calendar).is_some_and(|c| path.parent() != Some(c.dir.as_path()));
527            if elsewhere && with_others(&current) {
528                return Err(tr().text("event-move-with-others", None));
529            }
530            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());
531            if let Some(task) = task.filter(|t| !t.is_empty())
532                && agenda::edit_of_text(&current, &zone).is_some_and(|before| before.title.trim() != edit.title.trim())
533            {
534                renamed = Some(task);
535            }
536            (path, agenda::apply(&current, &edit, &zone)?)
537        }
538        None => {
539            let target = vdir::collections(Kind::Calendars)
540                .into_iter()
541                .find(|c| !c.read_only && c.holds("VEVENT") && format!("{}/{}", c.account, c.id) == calendar)
542                .or_else(agenda::default_calendar)
543                .ok_or_else(|| tr().text("dav-no-calendar", None))?;
544            let text = agenda::new_event(&edit, &zone)?;
545            let lines: Vec<String> = links
546                .iter()
547                .map(|uri| {
548                    let rel = if uri.starts_with("mid:") { "via" } else { "related" };
549                    sioul_core::tasks::link_line(&sioul_core::tasks::Link { uri: uri.clone(), label: String::new(), rel: rel.into() })
550                })
551                .collect();
552            (agenda::new_path(&target), agenda::add_lines(&text, &lines).unwrap_or(text))
553        }
554    };
555    vdir::write_item(&path, &text)?;
556    let mut accounts: Vec<String> = account_of(&path).into_iter().collect();
557    let mut at = path.clone();
558    // Another calendar chosen for an event already there: moved, its account told too.
559    if ours(key).is_some()
560        && let Some(target) = vdir::collections(Kind::Calendars).into_iter().find(|c| format!("{}/{}", c.account, c.id) == calendar)
561        && path.parent() != Some(target.dir.as_path())
562        && collection_of(&path).is_some_and(|from| !from.read_only)
563    {
564        at = move_event_file(&path, &target)?;
565        accounts.push(target.account.clone());
566    }
567    Ok(SavedEvent { path: at, accounts, renamed, title: edit.title.trim().to_string() })
568}
569
570/// An event moved by a drag in the agenda: its occurrence starting at `start`
571/// now from `new_start` to `new_end` (Unix seconds), that time alone or every
572/// time (`agenda::moved`); then sent, the agenda and the plan made again, and
573/// "Undo" offered for ten seconds, which writes back what the file held, unless
574/// it changed since (a sync, another device). Returns what went wrong, else "".
575pub(crate) fn move_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, start: i64, new_start: i64, new_end: i64, only_this: bool) -> String {
576    let Some(path) = ours(key) else { return tr().text("agenda-gone", None) };
577    if collection_of(&path).is_none_or(|c| c.read_only) {
578        return tr().text("agenda-read-only", None);
579    }
580    let Ok(before) = std::fs::read_to_string(&path) else { return tr().text("agenda-gone", None) };
581    let zone = TimeZone::system();
582    let text = match agenda::moved(&before, start, new_start, new_end, only_this, &zone) {
583        Ok(text) => text,
584        Err(agenda::MoveProblem::Gone) => return tr().text("agenda-gone", None),
585        Err(agenda::MoveProblem::SetDays) => return tr().text("agenda-move-set-days", None),
586        Err(agenda::MoveProblem::Unreadable(e)) => return e,
587    };
588    if text == before {
589        return String::new();
590    }
591    if let Err(e) = vdir::write_item(&path, &text) {
592        return e;
593    }
594    let account = account_of(&path);
595    if let Some(account) = &account {
596        nudge(shared, account);
597    }
598    // Said as it is now: its title, its day, its times.
599    let at = |seconds: i64| Timestamp::from_second(seconds).map(|t| t.to_zoned(zone.clone())).ok();
600    let time = match (at(new_start), at(new_end)) {
601        (Some(from), Some(to)) => format!("{}, {}–{}", tr().day(from.date()), from.strftime("%H:%M"), to.strftime("%H:%M")),
602        _ => String::new(),
603    };
604    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));
605    let line = say("drag-done", &[("what", title), ("time", time)]);
606    mail::offer_back(qt, shared, line, move |qt, shared| {
607        // Only what this drag wrote is put back: a change since stays.
608        if std::fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) {
609            return tr().text("undo-too-late", None);
610        }
611        if let Err(e) = vdir::write_item(&path, &before) {
612            return e;
613        }
614        if let Some(account) = &account {
615            nudge(shared, account);
616        }
617        show_pim(qt, shared);
618        crate::work::show_work(qt, shared);
619        String::new()
620    });
621    show_pim(qt, shared);
622    crate::work::show_work(qt, shared);
623    String::new()
624}
625
626/// Deletes an event, or only this occurrence of a repeating one, after ten seconds to undo.
627pub(crate) fn delete_event(qt: &QtThread, shared: &Arc<Shared>, key: &str, start: i64, only_this: bool) {
628    let Some((path, account)) = ours(key).and_then(|p| Some((p.clone(), account_of(&p)?))) else { return };
629    if only_this {
630        mail::schedule_skip(qt, shared, &account, path, start, tr().text("undo-occurrence-skipped", None));
631    } else {
632        mail::schedule_removal(qt, shared, &account, path, tr().text("undo-event-deleted", None));
633    }
634    show_pim(qt, shared);
635}
636
637/// Addresses to complete what is typed in an address field, as JSON.
638pub(crate) fn completions(typed: &str) -> String {
639    // Only the part after the last comma is being typed.
640    let current = typed.rsplit([',', ';']).next().unwrap_or(typed);
641    json(&contacts::completions(&contacts::all(), current, 6))
642}
643
644/// The contact with this address, by file; empty when there is none.
645pub(crate) fn contact_for(address: &str) -> String {
646    contacts::by_address(&contacts::all(), address).map(|c| c.key.clone()).unwrap_or_default()
647}
648
649/// A sender becomes a contact, in the first address book.
650pub(crate) fn add_sender(qt: &QtThread, shared: &Arc<Shared>, name: &str, address: &str) -> Result<String, String> {
651    let edit = ContactEdit {
652        name: if name.trim().is_empty() { address.to_string() } else { name.trim().to_string() },
653        emails: vec![contacts::Labeled { label: String::new(), value: address.trim().to_string() }],
654        ..ContactEdit::default()
655    };
656    save_contact(qt, shared, "", &serde_json::to_string(&serde_json::json!({ "name": edit.name, "emails": edit.emails })).unwrap_or_default())
657}
658
659#[derive(serde::Serialize)]
660struct InvitationView {
661    /// "REQUEST": answer; "CANCEL": take it out; "PUBLISH": add.
662    method: String,
663    summary: String,
664    /// "Thursday 8 October, 10:00 – 11:00".
665    when: String,
666    location: String,
667    organizer: String,
668    /// Already in one of your calendars.
669    known: bool,
670}
671
672/// The invitation a message carries, as the reader shows it; empty when it carries none.
673pub(crate) fn invitation(key: &str) -> String {
674    let Some(path) = maildir::locate(Path::new(key)) else { return String::new() };
675    let Some(text) = reading::calendar(&path) else { return String::new() };
676    let zone = TimeZone::system();
677    let Some(asked) = agenda::invitation(&text, &zone) else { return String::new() };
678    let at = |t: i64| Timestamp::from_second(t).map(|t| t.to_zoned(zone.clone())).ok();
679    let when = match (at(asked.start), at(asked.end)) {
680        (Some(start), _) if asked.all_day => tr().day(start.date()),
681        (Some(start), Some(end)) => format!("{}, {} – {}", tr().day(start.date()), start.strftime("%H:%M"), end.strftime("%H:%M")),
682        _ => String::new(),
683    };
684    json(&InvitationView { method: asked.method, summary: asked.summary, when, location: asked.location, organizer: asked.organizer, known: find_uid(&asked.uid).is_some() })
685}
686
687/// The event file with this UID, in any calendar.
688fn find_uid(uid: &str) -> Option<PathBuf> {
689    if uid.is_empty() {
690        return None;
691    }
692    vdir::collections(Kind::Calendars).into_iter().flat_map(|c| c.items()).find(|path| {
693        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))
694    })
695}
696
697/// Answers an invitation: "accepted", "tentative" or "declined". Accepted or
698/// maybe, the event goes into your first calendar; then your answer goes to
699/// the organizer, from the account the invitation came to. "add" only adds it
700/// (an event published, not asked); "remove" takes a cancelled one out.
701pub(crate) fn answer(qt: &QtThread, shared: &Arc<Shared>, key: &str, answer: &str) -> Result<(), String> {
702    let path = maildir::locate(Path::new(key)).ok_or_else(|| tr().text("mail-message-gone", None))?;
703    let text = reading::calendar(&path).ok_or_else(|| tr().text("agenda-gone", None))?;
704    let zone = TimeZone::system();
705    let asked = agenda::invitation(&text, &zone).ok_or_else(|| tr().text("agenda-gone", None))?;
706    let known = find_uid(&asked.uid);
707    match answer {
708        "remove" => {
709            if let Some(file) = known {
710                let account = account_of(&file).unwrap_or_default();
711                mail::schedule_removal(qt, shared, &account, file, tr().text("undo-event-deleted", None));
712            }
713            return Ok(());
714        }
715        "accepted" | "tentative" | "add" => {
716            let calendar = agenda::default_calendar().ok_or_else(|| tr().text("dav-no-calendar", None))?;
717            let target = known.clone().unwrap_or_else(|| agenda::new_path(&calendar));
718            // Answered, your calendar keeps your answer on your own line: a device
719            // answering it too can tell whether it said the same (`dav`, an item
720            // the server holds already under its UID).
721            let me = load_config().account_of(&path).and_then(|a| a.address.clone()).unwrap_or_default();
722            let kept = if answer == "add" { agenda::stored(&text) } else { agenda::stored_answered(&text, &me, &answer.to_ascii_uppercase()) };
723            vdir::write_item(&target, &kept)?;
724            if let Some(account) = account_of(&target) {
725                nudge(shared, &account);
726            }
727        }
728        _ => {}
729    }
730    if answer != "add" {
731        send_answer(&path, &text, &asked, answer)?;
732    }
733    tell(qt, shared, tr().text(&format!("invitation-{answer}"), None));
734    show_pim(qt, shared);
735    // Accepted or added, it takes its time in the plan at once, not after the sync.
736    crate::work::show_work(qt, shared);
737    Ok(())
738}
739
740/// The REPLY to the organizer, through the account the invitation came to.
741fn send_answer(message: &Path, text: &str, asked: &agenda::Invitation, answer: &str) -> Result<(), String> {
742    let config = load_config();
743    let account = config.account_of(message).cloned().ok_or_else(|| tr().text("mail-no-account", None))?;
744    let me = account.address.clone().unwrap_or_default();
745    let partstat = answer.to_ascii_uppercase();
746    let ics = agenda::reply(text, &me, &partstat).ok_or_else(|| tr().text("agenda-gone", None))?;
747    let name = account.name.clone().unwrap_or_default();
748    let sentence = say(&format!("invitation-sentence-{answer}"), &[("name", if name.is_empty() { me.clone() } else { name.clone() }), ("summary", asked.summary.clone())]);
749    let subject = say(&format!("invitation-subject-{answer}"), &[("summary", asked.summary.clone())]);
750    let outgoing = sioul_core::compose::invitation_answer((&name, &me), &asked.organizer_address, &subject, &sentence, &ics, Zoned::now().timestamp().as_second())?;
751    let password = secret::password(&account).map_err(|e| e.sentence(tr(), &account.id))?;
752    let account = with_smtp(&account)?;
753    match sioul_sync::send::send(&account, &password, &outgoing) {
754        Ok(()) | Err(SyncError::NotFiled(_)) => Ok(()),
755        Err(e) => Err(e.sentence(tr(), &account.id)),
756    }
757}
758
759/// The account with its sending server, found and kept when it had none.
760pub(crate) fn with_smtp(account: &Account) -> Result<Account, String> {
761    if account.smtp_host.is_some() {
762        return Ok(account.clone());
763    }
764    let smtp = sioul_sync::discover_smtp(account.address.as_deref().unwrap_or(""), account.host.as_deref()).map_err(|e| e.sentence(tr(), &account.id))?;
765    config::set_smtp(&config_path(), &account.id, &smtp.host, smtp.port, smtp.security)?;
766    let mut account = account.clone();
767    account.smtp_host = Some(smtp.host);
768    account.smtp_port = Some(smtp.port);
769    account.smtp_security = smtp.security;
770    Ok(account)
771}
772
773/// Adds a Google account, or signs one in again (`again`: its id): Google's
774/// page opens in your browser, Sioul waits for its answer here (Cancel stops
775/// the wait), finds the calendars and contacts, and syncs them. On a thread.
776pub(crate) fn add_google_account(qt: &QtThread, shared: &Arc<Shared>, address: String, client_id: String, client_secret: String, again: Option<String>) {
777    let (qt, shared) = (qt.clone(), Arc::clone(shared));
778    shared.oauth_stop.store(false, Ordering::Relaxed);
779    std::thread::spawn(move || {
780        let config = load_config();
781        let address = address.trim().to_ascii_lowercase();
782        // Signed in again: the client kept with its grant.
783        let kept = again.as_ref().and_then(|_| google::grant(&address));
784        // The key kept (signing in again), else the one given, else Sioul's own when the build has one.
785        let (client_id, client_secret) = match (&kept, google::built_in()) {
786            (Some(grant), _) if client_id.trim().is_empty() => (grant.client_id.clone(), grant.client_secret.clone()),
787            (_, Some((id, secret))) if client_id.trim().is_empty() => (id.to_string(), secret.to_string()),
788            _ => (client_id, client_secret),
789        };
790        let id = again.clone().unwrap_or_else(|| {
791            let base = config::slug("google");
792            // Those switched off keep their id too.
793            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)
794        });
795        let outcome = google::begin(&client_id, &address)
796            .and_then(|sign_in| {
797                let url = sign_in.url.clone();
798                let _ = qt.queue(move |mut sioul| sioul.as_mut().open_url(QString::from(&url)));
799                google::finish(sign_in, &client_id, &client_secret, &address, &shared.oauth_stop)
800            })
801            .and_then(|_| dav::test_google(&address))
802            .map_err(|e| google_sentence(&e, &id))
803            .and_then(|homes: Homes| {
804                if again.is_none() {
805                    let mut account = Account::dav(&id, &address, "apidata.googleusercontent.com", None, Some(&address));
806                    account.auth = Some("google".into());
807                    config::add_dav_account(&config_path(), &account)?;
808                }
809                homes.save(&id).map_err(|e| e.sentence(tr(), &id))?;
810                load_config().account(&id).cloned().ok_or_else(|| say("account-unknown", &[("id", id.clone())]))
811            });
812        let added = outcome.as_ref().ok().map(|a| say("dav-added", &[("id", a.id.clone())]));
813        if let Ok(account) = outcome.as_ref() {
814            stop_dav_watcher(&shared, &account.id);
815            start_dav_watcher(&qt, &shared, account.clone());
816        }
817        let error = outcome.err();
818        let _ = qt.queue(move |mut sioul| {
819            sioul.as_mut().set_form_busy(false);
820            match (added, error) {
821                (Some(line), _) => {
822                    sioul.as_mut().set_status(QString::from(&line));
823                    sioul.as_mut().account_added();
824                }
825                (None, Some(e)) => sioul.as_mut().set_form_error(QString::from(&e)),
826                (None, None) => {}
827            }
828        });
829        show(&qt, &shared);
830    });
831}
832
833/// What went wrong while signing in with Google, in your language (its mail's sign-in too, gmail.rs).
834pub(crate) fn google_sentence(error: &SyncError, id: &str) -> String {
835    match error {
836        SyncError::Message(m) if m.starts_with("google-") => {
837            let (key, other) = m.split_once(':').unwrap_or((m.as_str(), ""));
838            say(key, &[("address", other.to_string())])
839        }
840        e => e.sentence(tr(), id),
841    }
842}
843
844/// Adds a contacts-and-calendars account: finds where they are, tests the
845/// password, keeps it in the keyring, and starts syncing. On a thread.
846pub(crate) fn add_account(qt: &QtThread, shared: &Arc<Shared>, address: String, url: String, login: String, password: String) {
847    let (qt, shared) = (qt.clone(), Arc::clone(shared));
848    std::thread::spawn(move || {
849        let config = load_config();
850        let address = address.trim().to_ascii_lowercase();
851        let login = if login.trim().is_empty() { address.clone() } else { login.trim().to_string() };
852        let mail_host = config.accounts.iter().find(|a| a.address.as_deref() == Some(address.as_str())).and_then(|a| a.host.clone());
853        let given = Some(url.trim()).filter(|u| !u.is_empty());
854        let label = address.rsplit('@').next().unwrap_or("dav").split('.').next().unwrap_or("dav");
855        let base = config::slug(&format!("{label}-agenda"));
856        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);
857        let outcome = dav::test(&address, &login, &password, given, mail_host.as_deref()).map_err(|e| e.sentence(tr(), &id)).and_then(|homes: Homes| {
858            let found = homes.calendars.clone().or(homes.contacts.clone()).unwrap_or_default();
859            let host = found.split_once("://").map_or(found.as_str(), |(_, r)| r).split(['/', ':']).next().unwrap_or("").to_string();
860            let account = Account::dav(&id, &address, &host, Some(&found), Some(&login));
861            secret::save(&account, &password).map_err(|e| e.sentence(tr(), &id))?;
862            config::add_dav_account(&config_path(), &account)?;
863            homes.save(&id).map_err(|e| e.sentence(tr(), &id))?;
864            Ok(account)
865        });
866        let added = outcome.as_ref().ok().map(|a| say("dav-added", &[("id", a.id.clone())]));
867        if let Ok(account) = outcome.as_ref() {
868            start_dav_watcher(&qt, &shared, account.clone());
869        }
870        let error = outcome.err();
871        let _ = qt.queue(move |mut sioul| {
872            sioul.as_mut().set_form_busy(false);
873            match (added, error) {
874                (Some(line), _) => {
875                    sioul.as_mut().set_status(QString::from(&line));
876                    sioul.as_mut().account_added();
877                }
878                (None, Some(e)) => sioul.as_mut().set_form_error(QString::from(&e)),
879                (None, None) => {}
880            }
881        });
882        show(&qt, &shared);
883    });
884}
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    /// An event moved to another calendar (21 October 2026): written there,
891    /// taken out here, never twice, never lost; not into a read-only one. A
892    /// calendar of this device's own, the sharing not carrying it, is said
893    /// to stay here.
894    #[test]
895    fn an_event_moves_to_another_calendar_once() {
896        let account = "moves-here";
897        for (id, name) in [("from", "From"), ("to", "To"), ("shared", "Shared")] {
898            vdir::prepare(Kind::Calendars, account, id, name, None).unwrap();
899        }
900        let path = vdir::state_path(account, Kind::Calendars, "shared");
901        let mut state = vdir::State::load(&path);
902        state.read_only = true;
903        state.save(&path).unwrap();
904        let find = |id: &str| vdir::collections(Kind::Calendars).into_iter().find(|c| c.account == account && c.id == id).unwrap();
905        let text = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:move-1\r\nDTSTART:20261022T090000Z\r\nSUMMARY:Moved\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
906        let event = find("from").dir.join("e.ics");
907        std::fs::write(&event, text).unwrap();
908        assert!(move_event_file(&event, &find("shared")).is_err(), "never into a read-only calendar");
909        assert!(event.exists());
910        let moved = move_event_file(&event, &find("to")).unwrap();
911        assert!(!event.exists() && std::fs::read_to_string(&moved).unwrap() == text, "there, the same, and no longer here");
912        assert_eq!(find("to").items().len() + find("from").items().len(), 1, "never twice");
913        assert_eq!(move_event_file(&moved, &find("to")).unwrap(), moved, "already there: nothing done");
914        vdir::prepare(Kind::Calendars, vdir::LOCAL, "moves-mine", "Mine", None).unwrap();
915        let mine = vdir::collections(Kind::Calendars).into_iter().find(|c| c.id == "moves-mine").unwrap();
916        assert!(here_only(&mine), "this device's own, no sharing set up in a test: here only");
917        assert!(!here_only(&find("to")), "a server's: on every device with its account");
918        let _ = std::fs::remove_dir_all(Kind::Calendars.root().join(account));
919        let _ = std::fs::remove_dir_all(&mine.dir);
920    }
921
922    /// A meeting with others is never moved to another calendar: its server
923    /// would cancel it for them, then invite them again (the review of 22
924    /// October 2026, F2). Nothing is written; the editor says why.
925    #[test]
926    fn a_meeting_with_others_stays_in_its_calendar() {
927        let account = "with-others";
928        let from = vdir::create(Kind::Calendars, account, "Personnel", None, &["VEVENT"]).unwrap();
929        let to = vdir::create(Kind::Calendars, vdir::LOCAL, "With others mine", None, &["VEVENT"]).unwrap();
930        let path = from.dir.join("meeting.ics");
931        let text = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:meeting-1\r\nDTSTART:20991023T120000Z\r\nDTEND:20991023T130000Z\r\nSUMMARY:Meeting\r\nORGANIZER:mailto:boss@example.org\r\nATTENDEE;PARTSTAT=ACCEPTED:mailto:me@example.org\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
932        std::fs::write(&path, text).unwrap();
933        assert!(move_event_file(&path, &to).is_err());
934        let form = serde_json::to_string(&agenda::edit_of(&path).unwrap()).unwrap();
935        assert!(write_event(path.to_str().unwrap(), &form, &format!("{}/{}", to.account, to.id)).is_err());
936        assert_eq!(std::fs::read_to_string(&path).unwrap(), text, "nothing written, nothing moved");
937        assert!(to.items().is_empty());
938        let shown: serde_json::Value = serde_json::from_str(&event(path.to_str().unwrap())).unwrap();
939        assert!(!shown["stays"].as_str().unwrap_or_default().is_empty(), "the editor says why");
940        let _ = std::fs::remove_dir_all(Kind::Calendars.root().join(account));
941        let _ = std::fs::remove_dir_all(&to.dir);
942    }
943
944    /// The choosers by what a collection takes (21 October 2026): a new
945    /// event's calendars take events, a task's lists take tasks (a calendar
946    /// of events alone greyed, never offered); one taking both is in both and
947    /// says so.
948    #[test]
949    fn each_chooser_offers_what_takes_its_kind() {
950        let events = vdir::create(Kind::Calendars, vdir::LOCAL, "Kinds chooser events", None, &["VEVENT"]).unwrap();
951        let both = vdir::create(Kind::Calendars, vdir::LOCAL, "Kinds chooser both", None, &["VEVENT", "VTODO"]).unwrap();
952        let tasks = vdir::create(Kind::Calendars, vdir::LOCAL, "Kinds chooser tasks", None, &["VTODO"]).unwrap();
953        let id = |c: &Collection| format!("{}/{}", c.account, c.id);
954        let offered: Vec<serde_json::Value> = serde_json::from_str(&calendars()).unwrap();
955        let offered_id = |c: &Collection| offered.iter().find(|o| o["id"] == id(c)).map(|o| o["name"].as_str().unwrap_or_default().to_string());
956        assert!(offered_id(&events).is_some() && offered_id(&tasks).is_none());
957        assert!(offered_id(&both).is_some_and(|name| name.contains("events and tasks") || name.contains("événements et tâches")), "{offered:?}");
958        let lists = crate::work::writable_lists();
959        let takes = |c: &Collection| lists.iter().find(|l| l.id == id(c)).map(|l| l.tasks);
960        assert_eq!((takes(&tasks), takes(&both), takes(&events)), (Some(true), Some(true), Some(false)));
961        for c in [events, both, tasks] {
962            let _ = std::fs::remove_dir_all(&c.dir);
963        }
964    }
965
966    /// The owner's event of 22 October 2026, in his server's "Personnel":
967    /// its form saved with a calendar of this device's own moves it there,
968    /// and back, one file each time; saved with its own calendar, it stays.
969    #[test]
970    fn a_saved_event_goes_to_the_calendar_chosen() {
971        let account = "saved-moves";
972        let personnel = vdir::create(Kind::Calendars, account, "Personnel", None, &["VEVENT", "VTODO"]).unwrap();
973        let mine = vdir::create(Kind::Calendars, vdir::LOCAL, "Saved moves mine", None, &["VEVENT"]).unwrap();
974        let id = |c: &Collection| format!("{}/{}", c.account, c.id);
975        let event = personnel.dir.join("dentist.ics");
976        std::fs::write(&event, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:saved-1\r\nDTSTART:20991023T120000Z\r\nDTEND:20991023T124000Z\r\nSUMMARY:Dentist\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n").unwrap();
977        let form = serde_json::to_string(&agenda::edit_of(&event).unwrap()).unwrap();
978        let stays = write_event(event.to_str().unwrap(), &form, &id(&personnel)).unwrap();
979        assert_eq!(stays.path, event, "its own calendar: it stays");
980        let moved = write_event(event.to_str().unwrap(), &form, &id(&mine)).unwrap();
981        assert!(moved.path.starts_with(&mine.dir) && !event.exists(), "moved there, gone from here");
982        assert_eq!(moved.accounts, [account.to_string(), vdir::LOCAL.to_string()], "both accounts told");
983        assert_eq!(personnel.items().len() + mine.items().len(), 1, "never twice");
984        let back = write_event(moved.path.to_str().unwrap(), &form, &id(&personnel)).unwrap();
985        assert!(back.path.starts_with(&personnel.dir) && !moved.path.exists(), "and back");
986        assert!(std::fs::read_to_string(&back.path).unwrap().contains("UID:saved-1"));
987        let _ = std::fs::remove_dir_all(Kind::Calendars.root().join(account));
988        let _ = std::fs::remove_dir_all(&mine.dir);
989    }
990
991    /// The phone's calendars of 21 October 2026: an account's status says how
992    /// many collections were found and how many synced, and what went wrong
993    /// with some, never a partial state in silence.
994    #[test]
995    fn the_status_says_found_and_synced() {
996        let whole = dav::Report { listed: 3, collections: 3, ..dav::Report::default() };
997        let (line, problem) = status_of(&whole, "Murena", "09:30");
998        assert!(!problem && line.contains("09:30") && line.contains('3'), "{line}");
999        let partial = dav::Report { listed: 3, collections: 1, problems: vec![SyncError::Login("401".into())], ..dav::Report::default() };
1000        let (line, problem) = status_of(&partial, "Murena", "09:30");
1001        assert!(problem && line.contains('1') && line.contains('3') && line.len() > 30, "{line}");
1002        assert!(!line.contains("dav-") && !line.contains('{'), "{line}");
1003    }
1004
1005    /// The phone's log: one line per sync, counts and a code, never a name
1006    /// nor an address; and one at the start, saying whether there is any
1007    /// account to sync at all.
1008    #[test]
1009    fn the_log_says_counts_and_codes_only() {
1010        let report = dav::Report { listed: 5, collections: 3, calendars_listed: 3, calendars_synced: 2, problems: vec![SyncError::Server("PROPFIND https://dav.example.org/cal/secret/: 500".into())], ..dav::Report::default() };
1011        let line = sync_line(&Ok(report));
1012        assert_eq!(line, "calendars: 3 found (0 read only, 0 for tasks only), 2 synced, 0 new or renamed; address books: 2 found, 1 synced; first problem: sync-error-server");
1013    }
1014
1015    /// 21 October 2026: a calendar that holds no event yet is offered for a
1016    /// new event like the others, and listed in Settings; only a read-only
1017    /// one stays out of the chooser, listed there fixed, its row saying why.
1018    #[test]
1019    fn an_empty_calendar_is_offered_and_a_read_only_one_says_why() {
1020        let account = "offered-here";
1021        for (id, name) in [("full", "Full"), ("empty", "Empty"), ("shared", "Shared")] {
1022            vdir::prepare(Kind::Calendars, account, id, name, None).unwrap();
1023        }
1024        std::fs::write(Kind::Calendars.root().join(account).join("full").join("x.ics"), "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n").unwrap();
1025        let path = vdir::state_path(account, Kind::Calendars, "shared");
1026        let mut state = vdir::State::load(&path);
1027        state.read_only = true;
1028        state.save(&path).unwrap();
1029        let offered: Vec<String> = serde_json::from_str::<Vec<serde_json::Value>>(&calendars()).unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).filter(|id| id.starts_with(account)).collect();
1030        assert!(offered.contains(&format!("{account}/empty")) && offered.contains(&format!("{account}/full")), "{offered:?}");
1031        assert!(!offered.contains(&format!("{account}/shared")), "read only: not offered");
1032        vdir::create(Kind::Calendars, account, "Errands", None, &["VTODO"]).unwrap();
1033        let offered: Vec<String> = serde_json::from_str::<Vec<serde_json::Value>>(&calendars()).unwrap().iter().map(|c| c["name"].as_str().unwrap().to_string()).collect();
1034        assert!(!offered.iter().any(|n| n.starts_with("Errands")), "a task list is never offered for an event: {offered:?}");
1035        let rows = sioul_core::settings::for_view("agenda", &load_config(), tr(), &[], None);
1036        let named = rows.iter().find(|s| s.key == "collections" && matches!(&s.value, sioul_core::config::SettingValue::Named(n) if n.iter().any(|v| v.id.contains(account)))).map(|s| match &s.value {
1037            sioul_core::config::SettingValue::Named(n) => n.clone(),
1038            _ => Vec::new(),
1039        });
1040        let named = named.expect("the calendars' setting");
1041        let shared = named.iter().find(|v| v.id.ends_with(&format!("{account}/shared"))).expect("the read-only one listed");
1042        assert_eq!(shared.locked, tr().text("collection-read-only", None));
1043        assert!(named.iter().any(|v| v.id.ends_with(&format!("{account}/empty")) && v.locked.is_empty()));
1044        let _ = std::fs::remove_dir_all(Kind::Calendars.root().join(account));
1045        let stopped = sync_line(&Err(SyncError::NoPassword));
1046        assert!(stopped.starts_with("calendars: the account's sync stopped: ") && stopped.contains("waits"), "{stopped}");
1047        assert!(!stopped.contains("example"));
1048        assert!(watchers_line(0, 0, 0).contains("0 contacts-and-calendars account(s) here") && watchers_line(0, 0, 0).contains("none of a server's"));
1049        assert!(watchers_line(1, 1, 0).contains("1 switched off"));
1050    }
1051}