Skip to main content

sioul_core/
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 (21 October 2026).
5//! An item's kind is the person's to say, never guessed from its times: a
6//! task may have a time and a length and still be a task. What one says the
7//! other keeps: its title, place, notes, repeat, margins and costs; a time
8//! becomes the event's, or the task's time block (`blocks`). The files are
9//! swapped by `vdir::replace_items`: never both, never neither.
10
11use crate::agenda::EventEdit;
12use crate::tasks::{Status, Task, TaskEdit};
13use jiff::civil::{Date, DateTime};
14use jiff::tz::TimeZone;
15use jiff::{Timestamp, Zoned};
16
17/// An appointment made from a task without a length lasts an hour.
18pub const HOUR_MINUTES: i64 = 60;
19
20/// The longest estimate a task takes (`TaskEdit::estimate`, as the form and the MCP cap it).
21const LONGEST: i64 = 6000;
22
23fn local(seconds: i64, zone: &TimeZone) -> Option<Zoned> {
24    Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()))
25}
26
27fn minute_text(z: &Zoned) -> String {
28    z.strftime("%Y-%m-%dT%H:%M").to_string()
29}
30
31/// When the appointment a task becomes takes place, Unix seconds: its time
32/// block's time (`block`), else the time it was given by a drag before blocks,
33/// else its start when that has a time, for its estimate or an hour. None: it
34/// has no time, the form asks one.
35pub fn time_of(task: &Task, block: Option<(i64, i64)>, zone: &TimeZone) -> Option<(i64, i64)> {
36    if let Some((start, end)) = block.filter(|(s, e)| e > s) {
37        return Some((start, end));
38    }
39    let length = if task.estimate > 0 { i64::from(task.estimate) } else { HOUR_MINUTES };
40    [task.at.as_str(), task.start.as_str()].into_iter().filter(|t| t.contains('T')).find_map(|text| {
41        let start = text.parse::<DateTime>().ok()?.to_zoned(zone.clone()).ok()?.timestamp().as_second();
42        Some((start, start + length * 60))
43    })
44}
45
46/// The event form of a task made an appointment: at `time` (`time_of`);
47/// without one, on its day to start, else its date asked, else `today`, at
48/// 9:00 for an hour, for the person to say.
49pub fn event_of(task: &Task, time: Option<(i64, i64)>, today: Date, zone: &TimeZone) -> EventEdit {
50    let (start, end) = match time.and_then(|(s, e)| Some((local(s, zone)?, local(e, zone)?))) {
51        Some((start, end)) => (minute_text(&start), minute_text(&end)),
52        None => {
53            let day = task.start_date().or(task.due_date()).unwrap_or(today);
54            (format!("{day}T09:00"), format!("{day}T10:00"))
55        }
56    };
57    EventEdit {
58        title: task.title.clone(),
59        location: task.location.clone(),
60        notes: task.notes.clone(),
61        start,
62        end,
63        all_day: false,
64        repeat: task.repeat.clone(),
65        margins: task.margins,
66        demands: task.demands,
67        remind: String::new(),
68    }
69}
70
71/// Minutes between two form times ("2026-10-05T09:00"); None for days or nonsense.
72fn minutes_between(start: &str, end: &str) -> Option<i64> {
73    let (start, end) = (start.parse::<DateTime>().ok()?, end.parse::<DateTime>().ok()?);
74    let span = end.since(start).ok()?;
75    Some(span.total(jiff::Unit::Minute).ok()? as i64)
76}
77
78/// The task an appointment becomes: its title, place and notes; its day as
79/// the day it can start; its length as its estimate (none for a whole day);
80/// its repeat, margins and costs. Its time is its block's, made beside it.
81pub fn task_of(event: &EventEdit) -> TaskEdit {
82    let estimate = if event.all_day { 0 } else { minutes_between(&event.start, &event.end).unwrap_or(0).clamp(0, LONGEST) };
83    TaskEdit {
84        title: event.title.clone(),
85        notes: event.notes.clone(),
86        location: event.location.clone(),
87        status: Status::NeedsAction,
88        start: event.start.get(..10).unwrap_or_default().to_string(),
89        estimate: u32::try_from(estimate).unwrap_or(0),
90        repeat: event.repeat.clone(),
91        margins: event.margins,
92        demands: event.demands,
93        ..TaskEdit::default()
94    }
95}
96
97/// What a task holds that an appointment cannot, by word ids
98/// (`appointment-lost-*`): said before it becomes one (the review of 22
99/// October 2026, F5). Its title, place, notes, repeat, margins, costs and
100/// links go along.
101pub fn task_losses(task: &Task) -> Vec<&'static str> {
102    let mut lost = Vec::new();
103    for (has, word) in [
104        (!task.due.is_empty(), "due"),
105        (task.priority > 0, "priority"),
106        (!task.categories.is_empty(), "tags"),
107        (!task.contacts.is_empty(), "contacts"),
108        (!task.kind.is_empty(), "kind"),
109        (!task.projects.is_empty(), "projects"),
110    ] {
111        if has {
112            lost.push(word);
113        }
114    }
115    lost
116}
117
118/// What an event holds that a task cannot, by word ids: its own reminder,
119/// its alarms. Its title, place, notes, repeat, margins, costs, length and
120/// links go along.
121pub fn event_losses(text: &str) -> Vec<&'static str> {
122    let source = crate::lines::unfold(text);
123    let mut lost = Vec::new();
124    if source.iter().any(|l| crate::lines::name(l) == crate::reminders::REMIND) {
125        lost.push("remind");
126    }
127    if source.iter().any(|l| crate::lines::name(l) == "BEGIN" && crate::lines::value(l).trim().eq_ignore_ascii_case("VALARM")) {
128        lost.push("alarms");
129    }
130    lost
131}
132
133/// Another task's text once the thing it names by `old` became `new`, an
134/// appointment (`became_event`) or a task: its links to it named again; its
135/// waits and ties of steps on it dropped when it became an appointment,
136/// which nothing waits for. None when it names nothing of it.
137pub fn retarget(text: &str, old: &str, new: &str, became_event: bool, now: &Zoned) -> Option<String> {
138    use crate::tasks::{link_line, link_of};
139    let olds = [format!("uid:{old}"), crate::links::task_uri(old), crate::links::event_uri(old)];
140    let names_it = |l: &str| match crate::lines::name(l).as_str() {
141        "LINK" => olds.contains(&link_of(l).uri),
142        "RELATED-TO" => crate::lines::value(l).trim() == old,
143        _ => false,
144    };
145    let source = crate::lines::unfold(text);
146    let hits: Vec<&String> = source.iter().filter(|l| names_it(l)).collect();
147    if hits.is_empty() {
148        return None;
149    }
150    let added: Vec<String> = hits
151        .iter()
152        .filter_map(|l| {
153            if crate::lines::name(l) == "LINK" {
154                let link = link_of(l);
155                let uri = if link.uri.starts_with("uid:") { format!("uid:{new}") } else if became_event { crate::links::event_uri(new) } else { crate::links::task_uri(new) };
156                Some(link_line(&crate::tasks::Link { uri, ..link }))
157            } else if became_event {
158                None
159            } else {
160                let value = crate::lines::value(l);
161                Some(format!("{}{new}", &l[..l.len() - value.len()]))
162            }
163        })
164        .collect();
165    crate::tasks::replace_lines(text, |l| names_it(l), added, now).ok()
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn zone() -> TimeZone {
173        TimeZone::get("Europe/Paris").unwrap()
174    }
175
176    fn at(text: &str) -> i64 {
177        text.parse::<DateTime>().unwrap().to_zoned(zone()).unwrap().timestamp().as_second()
178    }
179
180    /// Its block's time first; a time of its own, for its estimate or an
181    /// hour; none: the form asks, on its day at 9:00.
182    #[test]
183    fn a_task_made_an_appointment_takes_its_time() {
184        let today: Date = "2026-10-21".parse().unwrap();
185        let task = Task { title: "Dentist".into(), location: "12 rue de l'Exemple".into(), notes: "Bring the card".into(), start: "2026-10-23T14:00".into(), ..Task::default() };
186        let block = (at("2026-10-23T15:00"), at("2026-10-23T15:45"));
187        assert_eq!(time_of(&task, Some(block), &zone()), Some(block));
188        assert_eq!(time_of(&task, None, &zone()), Some((at("2026-10-23T14:00"), at("2026-10-23T15:00"))), "an hour without a length");
189        let estimated = Task { estimate: 30, ..task.clone() };
190        assert_eq!(time_of(&estimated, None, &zone()), Some((at("2026-10-23T14:00"), at("2026-10-23T14:30"))));
191        let dated = Task { start: "2026-10-24".into(), ..task.clone() };
192        assert_eq!(time_of(&dated, None, &zone()), None, "a day is no time: never guessed");
193        let event = event_of(&task, Some(block), today, &zone());
194        assert_eq!((event.title.as_str(), event.start.as_str(), event.end.as_str(), event.location.as_str(), event.notes.as_str()), ("Dentist", "2026-10-23T15:00", "2026-10-23T15:45", "12 rue de l'Exemple", "Bring the card"));
195        let asked = event_of(&dated, None, today, &zone());
196        assert_eq!((asked.start.as_str(), asked.end.as_str()), ("2026-10-24T09:00", "2026-10-24T10:00"));
197        assert_eq!(event_of(&Task::default(), None, today, &zone()).start, "2026-10-21T09:00");
198    }
199
200    /// What one kind holds that the other cannot is said; another task's ties
201    /// follow the new UID, its waits dropped when it became an appointment (F5).
202    #[test]
203    fn what_cannot_go_along_is_said_and_ties_follow() {
204        let task = Task { due: "2026-10-30".into(), priority: 2, categories: vec!["admin".into()], ..Task::default() };
205        assert_eq!(task_losses(&task), ["due", "priority", "tags"]);
206        assert!(task_losses(&Task::default()).is_empty());
207        let event = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:e\r\nX-SIOUL-REMIND:30\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
208        assert_eq!(event_losses(event), ["remind", "alarms"]);
209        let now: Zoned = "2026-10-22T12:00[Europe/Paris]".parse().unwrap();
210        let other = "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:other\r\nRELATED-TO;RELTYPE=DEPENDS-ON:old\r\nLINK;LINKREL=related;VALUE=URI:sioul:task/old\r\nLINK;LINKREL=related;VALUE=UID:old\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
211        let as_event = retarget(other, "old", "new", true, &now).unwrap();
212        assert!(!as_event.contains("RELATED-TO") && as_event.contains("sioul:event/new") && as_event.contains("VALUE=UID:new") && !as_event.contains("old\r\n"), "{as_event}");
213        let as_task = retarget(other, "old", "new", false, &now).unwrap();
214        assert!(as_task.contains("RELATED-TO;RELTYPE=DEPENDS-ON:new") && as_task.contains("sioul:task/new"), "{as_task}");
215        assert!(retarget(other, "nobody", "new", true, &now).is_none());
216    }
217
218    /// An appointment made a task: its day to start, its length as its estimate.
219    #[test]
220    fn an_appointment_made_a_task_keeps_its_length() {
221        let event = EventEdit { title: "Bank".into(), location: "Example Street".into(), start: "2026-10-23T10:00".into(), end: "2026-10-23T11:30".into(), repeat: "weekly".into(), ..EventEdit::default() };
222        let task = task_of(&event);
223        assert_eq!((task.title.as_str(), task.location.as_str(), task.start.as_str(), task.estimate, task.repeat.as_str()), ("Bank", "Example Street", "2026-10-23", 90, "weekly"));
224        let whole = EventEdit { all_day: true, start: "2026-10-23".into(), end: "2026-10-23".into(), ..event };
225        assert_eq!((task_of(&whole).estimate, task_of(&whole).start.as_str()), (0, "2026-10-23"));
226    }
227}