1use 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 Add {
23 address: String,
24 #[arg(long)]
26 url: Option<String>,
27 #[arg(long)]
29 username: Option<String>,
30 #[arg(long)]
31 id: Option<String>,
32 },
33 Sync { account: Option<String> },
35 Check,
38 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 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
80fn 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 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 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 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
198pub(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 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
206fn 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 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
255pub(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 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
269pub(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
285pub(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 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
307pub(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}