Skip to main content

sioul/
dav.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Contacts and calendars from the terminal: `sioul dav add|sync`,
5//! `sioul contacts`, `sioul contact`, `sioul agenda`, `sioul event`.
6
7use crate::Session;
8use crate::accounts::prompt_password;
9use clap::Subcommand;
10use jiff::Zoned;
11use jiff::tz::TimeZone;
12use sioul_core::agenda::{self, EventEdit};
13use sioul_core::config::{self, Account};
14use sioul_core::contacts::{self, ContactEdit, Labeled};
15use sioul_core::{vdir, view};
16use sioul_sync::dav::{self, Homes};
17use sioul_sync::secret;
18
19#[derive(Subcommand)]
20pub(crate) enum DavCommand {
21    /// Finds where your contacts and calendars are, asks for the password, tests it and adds the account.
22    Add {
23        address: String,
24        /// The server's address, when it cannot be found from yours.
25        #[arg(long)]
26        url: Option<String>,
27        /// The login, when it is not the address.
28        #[arg(long)]
29        username: Option<String>,
30        #[arg(long)]
31        id: Option<String>,
32    },
33    /// Syncs contacts and calendars both ways: every account, or one.
34    Sync { account: Option<String> },
35    /// Reads every contact and event kept: what does not parse, what a change
36    /// made here would rewrite, what each collection holds. Names files, never contents.
37    Check,
38    /// Makes an address book; the next sync creates it on the account's server.
39    NewBook { account: String, name: String },
40}
41
42pub(crate) fn run(s: &Session, command: DavCommand) -> Result<(), String> {
43    match command {
44        DavCommand::Add { address, url, username, id } => add(s, &address, url.as_deref(), username.as_deref(), id),
45        DavCommand::Sync { account } => sync(s, account.as_deref()),
46        DavCommand::Check => check(),
47        DavCommand::NewBook { account, name } => {
48            if s.config.account(&account).is_none_or(|a| !a.is_dav()) {
49                return Err(s.say("account-unknown", &[("id", account)]));
50            }
51            let book = vdir::create(vdir::Kind::Contacts, &account, &name, None, &[])?;
52            println!("{}/{}", book.account, book.id);
53            Ok(())
54        }
55    }
56}
57
58fn add(s: &Session, address: &str, url: Option<&str>, username: Option<&str>, id: Option<String>) -> Result<(), String> {
59    let address = address.trim().to_ascii_lowercase();
60    let id = id.unwrap_or_else(|| dav_id(&s.config, &address));
61    if s.config.every_account().any(|a| a.id == id) {
62        return Err(s.say("account-exists", &[("id", id)]));
63    }
64    let login = username.unwrap_or(&address).to_string();
65    // A mail account at the same domain tells where a cPanel host serves them.
66    let mail_host = s.config.accounts.iter().find(|a| a.address.as_deref() == Some(address.as_str())).and_then(|a| a.host.clone());
67    let password = prompt_password(s, &address)?;
68    let homes = dav::test(&address, &login, &password, url, mail_host.as_deref()).map_err(|e| e.sentence(&s.tr, &id))?;
69    say_homes(s, &homes);
70    let found = homes.calendars.clone().or(homes.contacts.clone()).unwrap_or_default();
71    let host = host_of(&found);
72    let account = Account::dav(&id, &address, &host, Some(&found), Some(&login));
73    secret::save(&account, &password).map_err(|e| e.sentence(&s.tr, &id))?;
74    config::add_dav_account(&s.config_path, &account)?;
75    homes.save(&id).map_err(|e| e.sentence(&s.tr, &id))?;
76    println!("{}", s.say("dav-added", &[("id", id.clone())]));
77    sync_one(s, &account)
78}
79
80/// Reads everything kept, as Sioul would, and says where it would go wrong.
81fn check() -> Result<(), String> {
82    use sioul_core::lines;
83    for collection in vdir::collections(vdir::Kind::Contacts) {
84        let (mut failed, mut changed, mut grouped, mut versions) = (Vec::new(), Vec::new(), 0, std::collections::BTreeMap::<String, usize>::new());
85        let items = collection.items();
86        for path in &items {
87            let text = std::fs::read_to_string(path).unwrap_or_default();
88            let Some(contact) = contacts::read(path, &collection) else {
89                failed.push(path.file_name().unwrap_or_default().to_string_lossy().to_string());
90                continue;
91            };
92            let version = lines::unfold(&text).iter().find(|l| lines::name(l) == "VERSION").map(|l| lines::value(l).trim().to_string()).unwrap_or_default();
93            *versions.entry(version).or_default() += 1;
94            grouped += usize::from(lines::unfold(&text).iter().any(|l| l.split([';', ':']).next().is_some_and(|h| h.contains('.'))));
95            // A change that changes nothing: what would differ besides REV.
96            let edit = ContactEdit {
97                name: contact.name.clone(),
98                emails: contact.emails.clone(),
99                phones: contact.phones.clone(),
100                org: contact.org.clone(),
101                title: contact.title.clone(),
102                addresses: contact.addresses.clone(),
103                birthday: contact.birthday.clone(),
104                notes: contact.notes.clone(),
105                urls: contact.urls.clone(),
106                categories: Some(contact.categories.clone()),
107            };
108            let before: std::collections::BTreeSet<String> = lines::unfold(&text).into_iter().filter(|l| lines::name(l) != "REV").collect();
109            let after: std::collections::BTreeSet<String> = lines::unfold(&contacts::apply(&text, &edit)).into_iter().filter(|l| lines::name(l) != "REV").collect();
110            let lost: Vec<String> = before.difference(&after).map(|l| lines::name(l)).collect();
111            if !lost.is_empty() {
112                // How lines differ, values hidden: the head (name and parameters), and whether the value reads the same.
113                if std::env::var_os("SIOUL_CHECK_DETAILS").is_some() {
114                    for old in before.difference(&after) {
115                        let head = |l: &str| l[..l.len() - lines::value(l).len()].to_string();
116                        let partner = after.iter().find(|n| lines::name(n) == lines::name(old) && lines::unescape(lines::value(n)) == lines::unescape(lines::value(old)));
117                        match partner {
118                            Some(new) => println!("    {} → {} (same value)", crate::one_line(&head(old)), crate::one_line(&head(new))),
119                            None => println!("    {} → value differs (len {} vs {:?})", crate::one_line(&head(old)), lines::value(old).len(), after.iter().filter(|n| lines::name(n) == lines::name(old)).map(|n| format!("{}{}", head(n), lines::value(n).len())).collect::<Vec<_>>()),
120                        }
121                    }
122                }
123                changed.push((path.file_name().unwrap_or_default().to_string_lossy().to_string(), lost));
124            }
125        }
126        println!("contacts/{}/{}: {} cards, versions {:?}, {} with grouped lines (item1.EMAIL…)", crate::one_line(&collection.account), crate::one_line(&collection.id), items.len(), versions, grouped);
127        for name in &failed {
128            println!("  does not parse: {}", crate::one_line(name));
129        }
130        let mut by_property: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
131        for (_, lost) in &changed {
132            for property in lost {
133                *by_property.entry(property.clone()).or_default() += 1;
134            }
135        }
136        println!("  an unchanged edit would rewrite {} cards; lines rewritten by property: {:?}", changed.len(), by_property);
137    }
138    let zone = TimeZone::system();
139    for collection in vdir::collections(vdir::Kind::Calendars) {
140        let (mut failed, mut components, mut recurring, mut rewritten) = (Vec::new(), std::collections::BTreeMap::<String, usize>::new(), 0, Vec::new());
141        let items = collection.items();
142        for path in &items {
143            let text = std::fs::read_to_string(path).unwrap_or_default();
144            let Some(ical) = agenda::parse(&text) else {
145                failed.push(path.file_name().unwrap_or_default().to_string_lossy().to_string());
146                continue;
147            };
148            for component in &ical.components {
149                *components.entry(format!("{:?}", component.component_type)).or_default() += 1;
150            }
151            // A rule of an event or a task, not a time zone's change of hour.
152            let mut inside: Vec<String> = Vec::new();
153            let repeats = lines::unfold(&text).iter().any(|l| match lines::name(l).as_str() {
154                "BEGIN" => {
155                    inside.push(lines::value(l).trim().to_ascii_uppercase());
156                    false
157                }
158                "END" => {
159                    inside.pop();
160                    false
161                }
162                "RRULE" => inside.last().is_some_and(|c| c == "VEVENT" || c == "VTODO"),
163                _ => false,
164            });
165            recurring += usize::from(repeats);
166            if let Some(edit) = agenda::edit_of_text(&text, &zone)
167                && let Ok(again) = agenda::apply(&text, &edit, &zone)
168            {
169                let skip = ["DTSTAMP", "LAST-MODIFIED", "SEQUENCE"];
170                let before: std::collections::BTreeSet<String> = lines::unfold(&text).into_iter().filter(|l| !skip.contains(&lines::name(l).as_str())).collect();
171                let after: std::collections::BTreeSet<String> = lines::unfold(&again).into_iter().filter(|l| !skip.contains(&lines::name(l).as_str())).collect();
172                let lost: Vec<String> = before.difference(&after).map(|l| lines::name(l)).collect();
173                if !lost.is_empty() {
174                    rewritten.push((path.file_name().unwrap_or_default().to_string_lossy().to_string(), lost));
175                }
176            }
177        }
178        println!(
179            "calendars/{}/{}: {} items, holds {:?}, read-only {}, components {:?}, {} repeating",
180            crate::one_line(&collection.account),
181            crate::one_line(&collection.id),
182            items.len(),
183            collection.components,
184            collection.read_only,
185            components,
186            recurring
187        );
188        for name in &failed {
189            println!("  does not parse: {}", crate::one_line(name));
190        }
191        for (name, lost) in &rewritten {
192            println!("  an unchanged edit would rewrite {}: {lost:?}", crate::one_line(name));
193        }
194    }
195    Ok(())
196}
197
198/// "example-agenda" for jane@example.org, "example-agenda-2" when taken.
199pub(crate) fn dav_id(config: &sioul_core::config::Config, address: &str) -> String {
200    let label = address.rsplit('@').next().unwrap_or(address).split('.').next().unwrap_or("dav");
201    let base = config::slug(&format!("{label}-agenda"));
202    // Those switched off keep their id too.
203    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)
204}
205
206/// `https://dav.example.org/remote.php/dav/` → `dav.example.org`; the port is kept apart.
207fn host_of(url: &str) -> String {
208    let rest = url.split_once("://").map_or(url, |(_, r)| r);
209    rest.split(['/', ':']).next().unwrap_or(rest).to_string()
210}
211
212fn say_homes(s: &Session, homes: &Homes) {
213    let none = s.tr.text("dav-none", None);
214    println!(
215        "{}",
216        crate::one_line(&s.say("dav-found", &[("contacts", homes.contacts.clone().unwrap_or_else(|| none.clone())), ("calendars", homes.calendars.clone().unwrap_or(none))]))
217    );
218}
219
220fn sync(s: &Session, only: Option<&str>) -> Result<(), String> {
221    let accounts: Vec<&Account> = s.config.accounts.iter().filter(|a| a.is_dav() && only.is_none_or(|id| a.id == id)).collect();
222    if accounts.is_empty() {
223        return Err(s.tr.text("dav-nothing", None));
224    }
225    for account in accounts {
226        if let Err(e) = sync_one(s, account) {
227            eprintln!("{}", crate::plain_lines(&e));
228        }
229    }
230    Ok(())
231}
232
233pub(crate) fn sync_one(s: &Session, account: &Account) -> Result<(), String> {
234    let report = dav::sync(account).map_err(|e| e.sentence(&s.tr, &account.id))?;
235    println!(
236        "{}",
237        s.say(
238            "dav-report",
239            &[("account", account.id.clone()), ("sent", report.sent.to_string()), ("received", report.received.to_string()), ("removed", report.removed.to_string())]
240        )
241    );
242    // Some collections failed, the others synced (a calendar shared with you refusing your login…).
243    for problem in &report.problems {
244        eprintln!("{}", crate::plain_lines(&problem.sentence(&s.tr, &account.id)));
245    }
246    for conflict in &report.conflicts {
247        println!("{}", s.say("dav-conflict", &[("path", crate::one_line(&conflict.display().to_string()))]));
248    }
249    for (summary, kept) in &report.answered {
250        println!("{}", s.say("dav-answered-elsewhere", &[("summary", crate::one_line(summary)), ("answer", kept.to_ascii_lowercase())]));
251    }
252    Ok(())
253}
254
255/// The contacts matching `query`, one per line: name, then address or number.
256pub(crate) fn list_contacts(s: &Session, query: &str) -> Result<(), String> {
257    let all = contacts::all();
258    let shown = view::contacts(&all, query, "", &s.tr);
259    if !shown.sentence.is_empty() {
260        println!("{}", crate::one_line(&shown.sentence));
261    }
262    // Names and details as the cards wrote them, never a terminal's escape sequences.
263    for row in shown.contacts {
264        println!("{} · {} · {}", crate::one_line(&row.name), crate::one_line(&row.detail), crate::one_line(&row.key));
265    }
266    Ok(())
267}
268
269/// A new contact in the first address book that takes it, then sent.
270pub(crate) fn new_contact(s: &Session, name: &str, emails: &[String], phones: &[String]) -> Result<(), String> {
271    let book = contacts::default_book().ok_or_else(|| s.tr.text("dav-no-book", None))?;
272    let edit = ContactEdit {
273        name: name.to_string(),
274        emails: emails.iter().map(|e| Labeled { label: String::new(), value: e.clone() }).collect(),
275        phones: phones.iter().map(|p| Labeled { label: String::new(), value: p.clone() }).collect(),
276        ..ContactEdit::default()
277    };
278    let path = contacts::new_path(&book);
279    vdir::write_item(&path, &contacts::new_card(&edit))?;
280    println!("{}", path.display());
281    let account = s.config.account(&book.account).cloned().ok_or_else(|| s.say("account-unknown", &[("id", book.account.clone())]))?;
282    sync_one(s, &account)
283}
284
285/// What comes, day by day.
286pub(crate) fn list_agenda(s: &Session, days: i64) -> Result<(), String> {
287    let zone = TimeZone::system();
288    let today = Zoned::now().date();
289    let from = today.to_zoned(zone.clone()).map_err(|e| e.to_string())?.timestamp().as_second();
290    let to = from + days * 86_400 + 3_600;
291    let shown = view::agenda(&agenda::occurrences(from, to), today, days, &s.tr, &zone);
292    if !shown.sentence.is_empty() {
293        println!("{}", crate::one_line(&shown.sentence));
294        return Ok(());
295    }
296    for day in shown.days.iter().filter(|d| !d.events.is_empty()) {
297        println!("{}", day.title);
298        for event in &day.events {
299            // Titles and places as invitations and shared calendars wrote them, on one line.
300            let place = if event.location.is_empty() { String::new() } else { format!(" · {}", crate::one_line(&event.location)) };
301            println!("  {}  {}{place}", crate::one_line(&event.when), crate::one_line(&event.summary));
302        }
303    }
304    Ok(())
305}
306
307/// A new event in the first calendar that takes it, then sent.
308pub(crate) fn new_event(s: &Session, edit: &EventEdit) -> Result<(), String> {
309    let calendar = agenda::default_calendar().ok_or_else(|| s.tr.text("dav-no-calendar", None))?;
310    let text = agenda::new_event(edit, &TimeZone::system())?;
311    let path = agenda::new_path(&calendar);
312    vdir::write_item(&path, &text)?;
313    println!("{}", path.display());
314    let account = s.config.account(&calendar.account).cloned().ok_or_else(|| s.say("account-unknown", &[("id", calendar.account.clone())]))?;
315    sync_one(s, &account)
316}