1use crate::backend::{QtThread, Shared, load_config, say, tell, tr};
22use crate::{pim, work};
23use jiff::civil::DateTime;
24use jiff::tz::TimeZone;
25use jiff::{Timestamp, Zoned};
26use sioul_core::agenda::{self, EventEdit};
27use sioul_core::vdir::{self, Kind};
28use sioul_core::{appointments, blocks, lines, tasks};
29use sioul_core::tasks::Task;
30use sioul_core::vdir::Collection;
31use std::path::{Path, PathBuf};
32use std::sync::Arc;
33
34fn block_of(text: &str, uid: &str) -> bool {
36 lines::unfold(text).iter().any(|l| lines::name(l) == blocks::TASK && (uid.is_empty() || lines::value(l).trim() == uid))
37}
38
39fn nudge_all(shared: &Shared, paths: &[PathBuf]) {
41 let mut told = std::collections::BTreeSet::new();
42 for account in paths.iter().filter_map(|p| pim::account_of(p)) {
43 if told.insert(account.clone()) {
44 pim::nudge(shared, &account);
45 }
46 }
47}
48
49fn shown_again(qt: &QtThread, shared: &Arc<Shared>) {
51 if let Ok(mut cache) = shared.loaded.lock() {
52 *cache = None;
53 }
54 work::show_work(qt, shared);
55 pim::show_pim(qt, shared);
56}
57
58pub(crate) fn form(shared: &Shared, uid: &str, start: i64, end: i64) -> String {
62 let zone = TimeZone::system();
63 match work::find_task(shared, uid) {
64 Ok(task) => {
65 let time = appointments::time_of(&task, (end > start && start > 0).then_some((start, end)), &zone);
66 let mut form = serde_json::to_value(appointments::event_of(&task, time, Zoned::now().date(), &zone)).unwrap_or_default();
67 let waiting = waiting_for(&work::loaded(shared).tasks, uid);
69 form["losses"] = serde_json::Value::String(lost_sentence("appointment-not-kept", &appointments::task_losses(&task), waiting));
70 form.to_string()
71 }
72 Err(error) => serde_json::json!({ "error": error }).to_string(),
73 }
74}
75
76fn waiting_for(tasks: &[Task], uid: &str) -> usize {
78 tasks.iter().filter(|t| t.uid != uid && t.status.is_open() && t.relations.iter().any(|r| r.uid == uid && r.kind != "PARENT" && r.kind != "CHILD")).count()
79}
80
81fn lost_sentence(intro: &str, lost: &[&str], waiting: usize) -> String {
84 let mut words: Vec<String> = lost.iter().map(|id| tr().text(&format!("conversion-lost-{id}"), None)).collect();
85 if waiting > 0 {
86 words.push(say("conversion-lost-waiting", &[("n", waiting.to_string())]));
87 }
88 if words.is_empty() { String::new() } else { say(intro, &[("what", words.join(", "))]) }
89}
90
91pub(crate) fn task_losses(key: &str) -> String {
93 pim::ours(key).and_then(|p| std::fs::read_to_string(p).ok()).map_or_else(String::new, |text| lost_sentence("task-not-kept", &appointments::event_losses(&text), 0))
94}
95
96fn uid_in(path: &Path) -> String {
98 std::fs::read_to_string(path).ok().and_then(|t| lines::unfold(&t).iter().find(|l| lines::name(l) == "UID").map(|l| lines::value(l).trim().to_string())).unwrap_or_default()
99}
100
101fn retie(tasks: &[Task], old: &str, new: &str, became_event: bool) -> Vec<PathBuf> {
105 let now = Zoned::now();
106 let mut written = Vec::new();
107 for task in tasks.iter().filter(|t| t.uid != old && !t.read_only) {
108 let path = PathBuf::from(&task.key);
109 let Ok(text) = std::fs::read_to_string(&path) else { continue };
110 if let Some(changed) = appointments::retarget(&text, old, new, became_event, &now)
111 && vdir::write_item(&path, &changed).is_ok()
112 {
113 written.push(path);
114 }
115 }
116 written
117}
118
119pub(crate) fn task_to_event(qt: &QtThread, shared: &Arc<Shared>, uid: &str, block: &str, edit: &str, calendar: &str) -> String {
124 let loaded = work::loaded(shared);
125 let made = work::find_task(shared, uid).and_then(|task| make_event(&task, &loaded.tasks, block, edit, calendar));
126 match made {
127 Ok((gone, made, name)) => {
128 let retied = retie(&loaded.tasks, uid, &uid_in(&made), true);
129 nudge_all(shared, &[gone, vec![made], retied].concat());
130 tell(qt, shared, say("appointment-made", &[("calendar", name)]));
131 shown_again(qt, shared);
132 String::new()
133 }
134 Err(problem) => problem,
135 }
136}
137
138fn make_event(task: &Task, others: &[Task], block: &str, edit: &str, calendar: &str) -> Result<(Vec<PathBuf>, PathBuf, String), String> {
142 let uid = task.uid.as_str();
143 if task.read_only {
144 return Err(tr().text("task-read-only", None));
145 }
146 if task.status == tasks::Status::Completed {
148 return Err(tr().text("task-done-stays", None));
149 }
150 if others.iter().any(|t| t.status.is_open() && t.parent() == Some(uid)) {
152 return Err(tr().text("appointment-has-steps", None));
153 }
154 let edit: EventEdit = serde_json::from_str(edit).map_err(|e| e.to_string())?;
155 if edit.title.trim().is_empty() {
156 return Err(tr().text("event-no-title", None));
157 }
158 let target = vdir::collections(Kind::Calendars)
159 .into_iter()
160 .find(|c| !c.read_only && c.holds("VEVENT") && format!("{}/{}", c.account, c.id) == calendar)
161 .or_else(agenda::default_calendar)
162 .ok_or_else(|| tr().text("dav-no-calendar", None))?;
163 let zone = TimeZone::system();
164 let text = agenda::new_event(&edit, &zone)?;
165 let links: Vec<String> = task.links.iter().filter(|l| !blocks::is_block_link(l)).map(tasks::link_line).collect();
167 let text = agenda::add_lines(&text, &links).unwrap_or(text);
168 let mut gone = vec![PathBuf::from(&task.key)];
169 let opened = pim::ours(block).filter(|p| std::fs::read_to_string(p).is_ok_and(|t| block_of(&t, uid)) && pim::collection_of(p).is_some_and(|c| !c.read_only));
170 for path in opened.into_iter().chain(crate::blocks::with_task(uid).into_iter().map(|(_, p)| p)) {
171 if !gone.contains(&path) {
172 gone.push(path);
173 }
174 }
175 let path = agenda::new_path(&target);
176 vdir::replace_items(&gone, &[], &path, &text)?;
177 Ok((gone, path, target.label(&load_config(), tr())))
178}
179
180pub(crate) fn event_to_task(qt: &QtThread, shared: &Arc<Shared>, key: &str) -> String {
184 let old = pim::ours(key).map(|p| uid_in(&p)).unwrap_or_default();
185 match work::target_list("").and_then(|list| make_task(key, &list)) {
186 Ok((gone, made, list)) => {
187 let retied = if old.is_empty() { Vec::new() } else { retie(&work::loaded(shared).tasks, &old, &uid_in(&made[0]), false) };
188 nudge_all(shared, &[vec![gone], made, retied].concat());
189 tell(qt, shared, say("task-made-from-event", &[("list", list)]));
190 shown_again(qt, shared);
191 String::new()
192 }
193 Err(problem) => problem,
194 }
195}
196
197fn make_task(key: &str, list: &Collection) -> Result<(PathBuf, Vec<PathBuf>, String), String> {
200 let path = pim::ours(key).ok_or_else(|| tr().text("agenda-gone", None))?;
201 if pim::collection_of(&path).is_none_or(|c| c.read_only) {
202 return Err(tr().text("agenda-read-only", None));
203 }
204 let text = std::fs::read_to_string(&path).map_err(|_| tr().text("agenda-gone", None))?;
205 if block_of(&text, "") {
206 return Err(tr().text("task-made-already", None));
207 }
208 let unfolded = lines::unfold(&text);
209 if unfolded.iter().any(|l| matches!(lines::name(l).as_str(), "ATTENDEE" | "ORGANIZER")) {
210 return Err(tr().text("task-from-invitation", None));
211 }
212 let zone = TimeZone::system();
213 let event = agenda::edit_of_text(&text, &zone).ok_or_else(|| tr().text("agenda-gone", None))?;
214 let plain = format!("FREQ={}", event.repeat.to_ascii_uppercase());
216 if unfolded.iter().filter(|l| lines::name(l) == "RRULE").any(|l| event.repeat.is_empty() || !lines::value(l).trim().eq_ignore_ascii_case(&plain))
217 || unfolded.iter().any(|l| matches!(lines::name(l).as_str(), "RDATE" | "EXDATE" | "RECURRENCE-ID"))
218 {
219 return Err(tr().text("task-from-odd-repeat", None));
220 }
221 let mut edit = appointments::task_of(&event);
222 edit.links = unfolded.iter().filter(|l| lines::name(l) == "LINK").map(|l| tasks::link_of(l)).filter(|l| !blocks::is_block_link(l)).collect();
224 let now = Zoned::now();
225 let uid = vdir::new_name();
226 let mut task_text = tasks::new_task(&edit, &uid, &zone, &now)?;
227 let task_path = list.dir.join(format!("{uid}.ics"));
228 let mut made = Vec::new();
229 let at = |text: &str| text.parse::<DateTime>().ok().and_then(|d| d.to_zoned(zone.clone()).ok()).map(|z| z.timestamp().as_second());
231 if !event.all_day
232 && event.repeat.is_empty()
233 && let (Some(start), Some(end)) = (at(&event.start), at(&event.end))
234 && end > start
235 && end > Timestamp::now().as_second()
236 && let Some(mut task) = tasks::task_of_text(&task_text, &zone)
237 {
238 task.list_id = format!("{}/{}", list.account, list.id);
239 let calendar = crate::blocks::calendar_for(&task)?;
240 let block_uid = vdir::new_name();
241 let block = blocks::new_block(&task, &block_uid, start, end, load_config().tasks.block_alarms, &now)?;
242 made.push((calendar.dir.join(format!("{block_uid}.ics")), block));
243 task_text = blocks::task_pinned(&task_text, Some(&block_uid), &now)?;
244 }
245 vdir::replace_items(std::slice::from_ref(&path), &made, &task_path, &task_text)?;
246 let written: Vec<PathBuf> = std::iter::once(task_path).chain(made.into_iter().map(|(p, _)| p)).collect();
247 Ok((path, written, list.label(&load_config(), tr())))
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
256 fn a_block_is_known_by_its_task() {
257 let block = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:b\r\nX-SIOUL-TASK:t1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
258 assert!(block_of(block, "t1") && block_of(block, "") && !block_of(block, "t2"));
259 assert!(!block_of("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:e\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", ""));
260 }
261
262 fn holding(account: &str, what: &str) -> Vec<PathBuf> {
264 vdir::collections(Kind::Calendars).into_iter().filter(|c| c.account == account).flat_map(|c| c.items()).filter(|p| std::fs::read_to_string(p).is_ok_and(|t| t.contains(what))).collect()
265 }
266
267 #[test]
272 fn a_task_becomes_an_appointment_and_back_once() {
273 let account = "kinds-stand-in";
274 let list = vdir::create(Kind::Calendars, account, "Plan", None, &["VTODO"]).unwrap();
275 vdir::prepare(Kind::Calendars, account, "personal", "Personal", None).unwrap();
276 let personal = vdir::collections(Kind::Calendars).into_iter().find(|c| c.account == account && c.id == "personal").unwrap();
277 let task_path = list.dir.join("t-kinds.ics");
278 std::fs::write(&task_path, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:t-kinds\r\nSUMMARY:Dentist\r\nLOCATION:1 Example Street\r\nDTSTART;VALUE=DATE:20991023\r\nEND:VTODO\r\nEND:VCALENDAR\r\n").unwrap();
279 let block_path = personal.dir.join("b-kinds.ics");
280 std::fs::write(&block_path, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:b-kinds\r\nDTSTART:20991023T130000Z\r\nDTEND:20991023T140000Z\r\nSUMMARY:Dentist\r\nX-SIOUL-TASK:t-kinds\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n").unwrap();
281 let zone = TimeZone::system();
282 let task = tasks::read(&task_path, &list, &zone).unwrap();
283 let (start, end) = (4_096_443_600, 4_096_447_200);
284 let edit = appointments::event_of(&task, appointments::time_of(&task, Some((start, end)), &zone), Zoned::now().date(), &zone);
285 let edit = serde_json::to_string(&edit).unwrap();
286 let calendar = format!("{account}/personal");
287
288 let step = Task { uid: "s".into(), relations: vec![tasks::Relation { kind: "PARENT".into(), uid: "t-kinds".into(), gap: 0 }], ..Task::default() };
290 assert!(make_event(&task, &[task.clone(), step], block_path.to_str().unwrap(), &edit, &calendar).is_err());
291 assert!(task_path.exists() && block_path.exists());
292
293 let done = Task { status: tasks::Status::Completed, ..task.clone() };
295 assert!(make_event(&done, std::slice::from_ref(&done), block_path.to_str().unwrap(), &edit, &calendar).is_err());
296 let waiting_path = list.dir.join("w-kinds.ics");
298 std::fs::write(&waiting_path, "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:w-kinds\r\nSUMMARY:Pay\r\nRELATED-TO;RELTYPE=DEPENDS-ON:t-kinds\r\nLINK;LINKREL=related;VALUE=URI:sioul:task/t-kinds\r\nEND:VTODO\r\nEND:VCALENDAR\r\n").unwrap();
299 let waiting = tasks::read(&waiting_path, &list, &zone).unwrap();
300 assert_eq!(waiting_for(std::slice::from_ref(&waiting), "t-kinds"), 1);
301 let (gone, made, _) = make_event(&task, std::slice::from_ref(&task), block_path.to_str().unwrap(), &edit, &calendar).unwrap();
302 assert_eq!(retie(std::slice::from_ref(&waiting), "t-kinds", &uid_in(&made), true), [waiting_path.clone()]);
303 let retied = std::fs::read_to_string(&waiting_path).unwrap();
304 assert!(!retied.contains("RELATED-TO") && retied.contains(&format!("sioul:event/{}", uid_in(&made))), "{retied}");
305 std::fs::remove_file(&waiting_path).unwrap();
306 assert_eq!(gone, [task_path.clone(), block_path.clone()]);
307 assert!(!task_path.exists() && !block_path.exists(), "the task and its block gone");
308 let event = std::fs::read_to_string(&made).unwrap();
309 assert!(event.contains("BEGIN:VEVENT") && event.contains("SUMMARY:Dentist") && event.contains("LOCATION:1 Example Street") && !event.contains("X-SIOUL-TASK"), "{event}");
310 assert_eq!(holding(account, "Dentist"), [made.clone()], "one file: the appointment");
311
312 let linked = std::fs::read_to_string(&made).unwrap().replace("END:VEVENT", "LINK;LINKREL=via;VALUE=URI:mid:letter@example.org\r\nEND:VEVENT");
314 std::fs::write(&made, linked).unwrap();
315 let (gone, written, _) = make_task(made.to_str().unwrap(), &list).unwrap();
316 assert!(std::fs::read_to_string(&written[0]).unwrap().contains("mid:letter@example.org"), "the event's links go along");
317 assert_eq!(gone, made);
318 assert!(!made.exists(), "the event gone");
319 let task_text = std::fs::read_to_string(&written[0]).unwrap();
320 assert!(task_text.contains("BEGIN:VTODO") && task_text.contains("ESTIMATED-DURATION:PT1H") && task_text.contains("LOCATION:1 Example Street") && task_text.contains("DTSTART;VALUE=DATE:20991023"), "{task_text}");
321 let block = std::fs::read_to_string(&written[1]).unwrap();
322 let uid = tasks::task_of_text(&task_text, &zone).unwrap().uid;
323 assert!(block.contains(&format!("X-SIOUL-TASK:{uid}")) && block.contains("DTSTART:20991023T130000Z") && task_text.contains("time-block"), "{block}\n{task_text}");
324 assert_eq!(holding(account, "Dentist").len(), 2, "the task and its block, nothing else");
325 assert!(make_task(written[1].to_str().unwrap(), &list).is_err(), "a block is a task already");
326
327 let invited = personal.dir.join("i-kinds.ics");
329 std::fs::write(&invited, "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:i\r\nDTSTART:20991024T090000Z\r\nDTEND:20991024T100000Z\r\nSUMMARY:Meeting\r\nORGANIZER:mailto:boss@example.org\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n").unwrap();
330 assert!(make_task(invited.to_str().unwrap(), &list).is_err() && invited.exists());
331 let odd = personal.dir.join("o-kinds.ics");
332 std::fs::write(&odd, "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:o\r\nDTSTART:20991024T090000Z\r\nDTEND:20991024T100000Z\r\nSUMMARY:Odd\r\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n").unwrap();
333 assert!(make_task(odd.to_str().unwrap(), &list).is_err() && odd.exists());
334 let _ = std::fs::remove_dir_all(Kind::Calendars.root().join(account));
335 }
336}