Skip to main content

sioul_app/
appointments.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A task made an appointment, an appointment made a task, from the agenda
5//! (21 October 2026; `sioul_core::appointments`). The owner's appointment
6//! was a task whose time block looked like an event: which one an item is
7//! stays the person's to say, never guessed from its times.
8//!
9//! - "Make it an appointment", on a task's block: the event form opens with
10//!   the task's title, place, notes and the block's time, in the calendar
11//!   Settings ▸ Agenda names for new events, or another chosen there. Saved,
12//!   the event is written and the task taken out, with its time blocks to
13//!   come and the block it was opened from, in one step of the move journal
14//!   (`vdir::replace_items`): never both, never neither.
15//! - "Make it a task", on an event of your own: a task in the usual list, on
16//!   its day, its length its estimate; its time, when still to come and not
17//!   repeating, its block, made in the calendar for blocks. Then the event is
18//!   taken out, in the same one step. An invitation stays an event: others
19//!   expect you there.
20
21use 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
34/// Whether an event's text is the time block of the task `uid` (any task when `uid` is "").
35fn 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
39/// Every account of these files told to send them.
40fn 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
49/// The pages read again: the task views and the agenda, the tasks as they are now.
50fn 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
58/// The event form a task becomes, as JSON (`EventEdit`): at its block's time
59/// (`start`, `end`, Unix seconds, from the agenda; 0 for none), else its own;
60/// {"error"} when it is gone.
61pub(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            // What it cannot keep, said before it is saved (F5).
68            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
76/// How many open tasks wait for `uid`: they stop waiting once it is an appointment.
77fn 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
81/// "Not kept as an appointment: the date asked, its tags." (`intro` the
82/// sentence's id), or "" when everything goes along.
83fn 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
91/// What an event cannot keep as a task, in a sentence; "" when everything goes along (F5).
92pub(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
96/// The UID a calendar item's file holds.
97fn 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
101/// The other tasks that named `old` now name `new` (an appointment when
102/// `became_event`, which nothing waits for any more): their files written,
103/// returned (F5).
104fn 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
119/// A task made an appointment: the event form `edit` (JSON) written in
120/// `calendar` ("account/id"; else the agenda's usual), then the task taken out
121/// with its time blocks to come and `block`, the block it was opened from.
122/// Returns what went wrong, else "".
123pub(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
138/// The event `edit` written for `task` in `calendar`, the task and its blocks
139/// taken out (`others`: every task, to find its steps): what went, the new
140/// file, and the calendar's name.
141fn 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    // Done, it is the record of work done: it stays a task (F5).
147    if task.status == tasks::Status::Completed {
148        return Err(tr().text("task-done-stays", None));
149    }
150    // Its steps would lose the task they belong to.
151    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    // What the task was tied to, the event is (its blocks aside: they go).
166    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
180/// An event made a task (`key`, its file): in the usual list, its time its
181/// block's when still to come and not repeating, then the event taken out.
182/// Returns what went wrong, else "".
183pub(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
197/// The event `key` made a task in `list`: the event's file, what was written
198/// (the task, its block), and the list's name.
199fn 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    // A repeat a task cannot say (every other Tuesday, until a day) is not lost: a task's is FREQ alone.
215    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    // What the event was tied to, the task is (F5).
223    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    // Its time, still to come: the task's block, so that it keeps it, shown as a task.
230    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    /// Whether a file is a block of a task, or of this one.
255    #[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    /// The files of an account's calendars and lists that hold `what`.
263    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    /// The owner's appointment of 21 October 2026, a task in his list "Plan"
268    /// shown by its time block: made an appointment at the block's time, the
269    /// task and its block gone; made a task again, the event gone, the task
270    /// in the list with its length, its time its block. One file each time.
271    #[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        // A task with steps stays a task: they would lose the task they belong to.
289        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        // Done, it stays a task: the record of work done (F5).
294        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        // A task that waits for it, and links it: its links follow, its wait goes (F5).
297        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        // Back: a task in the list, an hour, its time its block's; its links with it.
313        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        // An invitation stays an event; a repeat the task cannot say is never lost.
328        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}