1use crate::backend::{QtThread, Shared, config_path, json, load_config, say, set_status, show, tell, tr};
13use crate::mail;
14use cxx_qt_lib::QString;
15use jiff::tz::TimeZone;
16use jiff::{Timestamp, Zoned};
17use sioul_core::agenda::{self, EventEdit};
18use sioul_core::config::{self, Account};
19use sioul_core::contacts::{self, ContactEdit};
20use sioul_core::vdir::{self, Collection, Kind};
21use sioul_core::{maildir, reading, view};
22use sioul_sync::dav::{self, Homes};
23use sioul_sync::{Control, SyncError, google, secret};
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use std::sync::atomic::Ordering;
27use std::time::Duration;
28
29const DAV_PAUSE: Duration = Duration::from_secs(15 * 60);
31
32#[derive(Clone)]
34pub(crate) struct PimState {
35 pub query: String,
36 pub category: String,
38 pub from: jiff::civil::Date,
39 pub days: i64,
40 pub upcoming: bool,
42}
43
44impl Default for PimState {
45 fn default() -> PimState {
46 PimState { query: String::new(), category: String::new(), from: Zoned::now().date(), days: view::AGENDA_DAYS, upcoming: true }
47 }
48}
49
50pub(crate) fn show_pim(qt: &QtThread, shared: &Arc<Shared>) {
52 let qt = qt.clone();
53 crate::backend::coalesced(shared, |s| &s.pim_job, move |shared| {
54 let generation = shared.pim_generation.fetch_add(1, Ordering::Relaxed) + 1;
55 let state = shared.pim.lock().map(|s| s.clone()).unwrap_or_default();
56 let (removed, skipped) = mail::hidden_pim(shared);
57 crate::homecard::agenda_seen(&removed, &skipped);
59 let everyone: Vec<contacts::Contact> = contacts::all().into_iter().filter(|c| !removed.contains(Path::new(&c.key))).collect();
60 let contacts = json(&view::contacts(&everyone, &state.query, &state.category, tr()));
61 let zone = TimeZone::system();
62 let from = state.from.to_zoned(zone.clone()).map_or(0, |z| z.timestamp().as_second());
63 let to = from + state.days * 86_400 + 3_600;
64 let now = Zoned::now().timestamp().as_second();
65 let occurrences: Vec<agenda::Occurrence> = agenda_items(from, to, &removed, &skipped, &crate::work::loaded(shared).tasks)
66 .into_iter()
67 .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 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 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
87pub(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
97pub(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
104pub(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
111pub(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
117pub(crate) fn here_only(calendar: &Collection) -> bool {
122 calendar.account == vdir::LOCAL && !(crate::share::configured() && crate::share::shares("lists"))
123}
124
125pub(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
134pub(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
141pub(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
148pub(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 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 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 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 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 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
199fn 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
216fn 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
235fn 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
247fn 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 let changed = match result {
261 Ok(report) => {
262 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 let changed = report.sent + report.received + report.removed > 0 || !report.conflicts.is_empty() || report.registered > 0;
268 if !report.conflicts.is_empty() {
270 tell(qt, shared, tr().text("dav-conflict", None));
271 }
272 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
290pub(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
297pub(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(¤t, &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
323pub(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
330pub(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
361pub(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 id: String,
372 name: String,
373 color: Option<String>,
374}
375
376pub(crate) fn calendars() -> String {
378 let config = load_config();
379 let mut writable: Vec<Collection> = vdir::collections(Kind::Calendars).into_iter().filter(|c| !c.read_only && c.holds("VEVENT")).collect();
381 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 calendar_name: String,
395 here_only: String,
397 read_only: bool,
398 stays: String,
400}
401
402pub(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 .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
429pub(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
437pub(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
450pub(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 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
474pub(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
480pub(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
495struct SavedEvent {
498 #[cfg_attr(not(test), allow(dead_code))]
500 path: PathBuf,
501 accounts: Vec<String>,
502 renamed: Option<String>,
503 title: String,
504}
505
506fn write_event(key: &str, edit: &str, calendar: &str) -> Result<SavedEvent, String> {
510 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 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 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(¤t) {
528 return Err(tr().text("event-move-with-others", None));
529 }
530 let task = sioul_core::lines::unfold(¤t).iter().find(|l| sioul_core::lines::name(l) == sioul_core::blocks::TASK).map(|l| sioul_core::lines::value(l).trim().to_string());
531 if let Some(task) = task.filter(|t| !t.is_empty())
532 && agenda::edit_of_text(¤t, &zone).is_some_and(|before| before.title.trim() != edit.title.trim())
533 {
534 renamed = Some(task);
535 }
536 (path, agenda::apply(¤t, &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 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
570pub(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 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 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
626pub(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
637pub(crate) fn completions(typed: &str) -> String {
639 let current = typed.rsplit([',', ';']).next().unwrap_or(typed);
641 json(&contacts::completions(&contacts::all(), current, 6))
642}
643
644pub(crate) fn contact_for(address: &str) -> String {
646 contacts::by_address(&contacts::all(), address).map(|c| c.key.clone()).unwrap_or_default()
647}
648
649pub(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 method: String,
663 summary: String,
664 when: String,
666 location: String,
667 organizer: String,
668 known: bool,
670}
671
672pub(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
687fn 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
697pub(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 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 crate::work::show_work(qt, shared);
737 Ok(())
738}
739
740fn 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
759pub(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
773pub(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 let kept = again.as_ref().and_then(|_| google::grant(&address));
784 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 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
833pub(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
844pub(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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}