1use crate::agenda::EventEdit;
12use crate::tasks::{Status, Task, TaskEdit};
13use jiff::civil::{Date, DateTime};
14use jiff::tz::TimeZone;
15use jiff::{Timestamp, Zoned};
16
17pub const HOUR_MINUTES: i64 = 60;
19
20const 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
31pub 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
46pub 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
71fn 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
78pub 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
97pub 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
118pub 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
133pub 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 #[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 #[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 #[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}