1use crate::lines;
18use crate::vdir::{self, Collection, Kind};
19use calcard::icalendar::dates::TimeOrDelta;
20use calcard::icalendar::{ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarParameterName, ICalendarParameterValue, ICalendarParticipationStatus, ICalendarProperty, ICalendarValue};
21use calcard::{Entry, Parser};
22use jiff::civil::{Date, DateTime};
23use jiff::tz::TimeZone;
24use jiff::{Timestamp, ToSpan, Zoned};
25use serde::{Deserialize, Serialize};
26use std::collections::BTreeMap;
27use std::path::{Path, PathBuf};
28
29const EXPANSION_LIMIT: usize = 20_000;
33
34const FIRST_LOOKED_AT: usize = 100;
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
41pub struct Occurrence {
42 pub key: String,
44 pub uid: String,
45 pub summary: String,
46 pub location: String,
47 pub notes: String,
48 pub start: i64,
50 pub end: i64,
51 pub all_day: bool,
52 pub recurring: bool,
53 pub calendar: String,
54 pub color: Option<String>,
55 pub cancelled: bool,
56 pub tentative: bool,
57 pub transparent: bool,
60 pub declined: bool,
64 pub organizer: String,
65 pub attendees: Vec<Attendee>,
66 pub read_only: bool,
67 pub alarms: Vec<i64>,
69 pub margins: crate::demands::Margins,
71 pub demands: crate::demands::Demands,
73 pub task: String,
75 pub turn: String,
77 pub remind: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84pub struct Attendee {
85 pub name: String,
86 pub address: String,
87 pub answer: String,
89}
90
91impl Occurrence {
92 pub fn holds_time(&self) -> bool {
96 !self.cancelled && !self.transparent && !self.declined
97 }
98}
99
100pub fn mark_declined(events: &mut [Occurrence], own: &[String]) {
102 for event in events.iter_mut() {
103 event.declined = event.attendees.iter().any(|a| a.answer == "declined" && own.iter().any(|o| o.trim().eq_ignore_ascii_case(a.address.trim())));
104 }
105}
106
107pub fn parse(text: &str) -> Option<ICalendar> {
109 match Parser::new(text).entry() {
110 Entry::ICalendar(calendar) => Some(calendar),
111 _ => None,
112 }
113}
114
115fn calcard_zone(zone: &TimeZone) -> calcard::common::timezone::Tz {
117 zone.iana_name().and_then(|name| name.parse::<chrono_tz::Tz>().ok()).map_or(calcard::common::timezone::Tz::Floating, Into::into)
118}
119
120fn text_of(component: &ICalendarComponent, property: &ICalendarProperty) -> String {
121 component.property(property).and_then(|e| e.values.first()).and_then(ICalendarValue::as_text).unwrap_or_default().trim().to_string()
122}
123
124fn demands_of(component: &ICalendarComponent) -> (crate::demands::Margins, crate::demands::Demands) {
126 let other = |name: &str| text_of(component, &ICalendarProperty::Other(name.to_string()));
127 let margins = crate::demands::Margins { before: crate::demands::minutes_of(&other(crate::demands::BEFORE)), after: crate::demands::minutes_of(&other(crate::demands::AFTER)) };
128 let mut demands = crate::demands::Demands::default();
129 demands.read_cost(&other(crate::demands::COST));
130 let gain = other(crate::demands::GAIN);
131 if !gain.is_empty() {
132 demands.read_gain(&gain);
133 }
134 (margins, demands)
135}
136
137fn is_all_day(component: &ICalendarComponent) -> bool {
139 component.property(&ICalendarProperty::Dtstart).is_some_and(|e| match e.values.first() {
140 Some(ICalendarValue::PartialDateTime(date)) => date.hour.is_none(),
141 _ => false,
142 })
143}
144
145fn address(value: &str) -> String {
147 let value = value.trim();
148 value.get(..7).filter(|p| p.eq_ignore_ascii_case("mailto:")).map_or(value, |_| &value[7..]).to_string()
149}
150
151fn people(component: &ICalendarComponent) -> (String, Vec<Attendee>) {
152 let person = |e: &calcard::icalendar::ICalendarEntry| {
153 let value = e.values.first().and_then(ICalendarValue::as_text).map(address).unwrap_or_default();
154 let name = e.params.iter().find(|p| p.name == ICalendarParameterName::Cn).and_then(|p| p.value.as_text()).unwrap_or_default().to_string();
155 let answer = e
156 .params
157 .iter()
158 .find(|p| p.name == ICalendarParameterName::Partstat)
159 .map(|p| match &p.value {
160 ICalendarParameterValue::Partstat(ICalendarParticipationStatus::Accepted) => "accepted",
161 ICalendarParameterValue::Partstat(ICalendarParticipationStatus::Declined) => "declined",
162 ICalendarParameterValue::Partstat(ICalendarParticipationStatus::Tentative) => "tentative",
163 _ => "needs-action",
164 })
165 .unwrap_or("needs-action");
166 Attendee { name, address: value, answer: answer.to_string() }
167 };
168 let organizer = component.property(&ICalendarProperty::Organizer).map(person).map(|p| if p.name.is_empty() { p.address } else { p.name }).unwrap_or_default();
169 let attendees = component.properties(&ICalendarProperty::Attendee).map(person).collect();
170 (organizer, attendees)
171}
172
173fn alarms_of(ical: &ICalendar, component: &ICalendarComponent, start: i64, length: i64) -> Vec<i64> {
176 let mut out = Vec::new();
177 for child in component.component_ids.iter().filter_map(|id| ical.components.get(*id as usize)) {
178 if child.component_type != ICalendarComponentType::VAlarm {
179 continue;
180 }
181 let Some(trigger) = child.property(&ICalendarProperty::Trigger) else { continue };
182 let from_end = trigger.params.iter().any(|p| matches!(p.value, ICalendarParameterValue::Related(calcard::icalendar::ICalendarRelated::End)));
183 match trigger.values.first() {
184 Some(ICalendarValue::Duration(duration)) => out.push(duration.as_seconds() + if from_end { length } else { 0 }),
185 Some(ICalendarValue::PartialDateTime(at)) if !component.is_recurrent_or_override() => {
186 if let Some(at) = at.to_timestamp() {
187 out.push(at - start);
188 }
189 }
190 _ => {}
191 }
192 }
193 out.sort_unstable();
194 out.dedup();
195 out
196}
197
198fn for_expansion(text: &str) -> std::borrow::Cow<'_, str> {
205 if !text.contains("RECURRENCE-ID") {
206 return std::borrow::Cow::Borrowed(text);
207 }
208 let source = lines::unfold(text);
209 let Some(master) = master_range(&source) else { return std::borrow::Cow::Borrowed(text) };
210 let own = |range: &std::ops::Range<usize>| -> Vec<usize> {
211 let mut nested = 0usize;
212 let mut out = Vec::new();
213 for i in range.start + 1..range.end.saturating_sub(1) {
214 match lines::name(&source[i]).as_str() {
215 "BEGIN" => nested += 1,
216 "END" => nested = nested.saturating_sub(1),
217 "SEQUENCE" if nested == 0 => out.push(i),
218 _ => {}
219 }
220 }
221 out
222 };
223 let series = own(&master).first().map(|&i| source[i].clone());
224 let mut out: Vec<String> = Vec::with_capacity(source.len() + 4);
225 let mut next = 0;
226 for range in event_ranges(&source).into_iter().filter(|r| *r != master) {
227 let theirs = own(&range);
228 out.extend(source[next..range.start + 1].iter().cloned());
229 out.extend(series.clone());
230 out.extend((range.start + 1..range.end).filter(|i| !theirs.contains(i)).map(|i| source[i].clone()));
231 next = range.end;
232 }
233 out.extend(source[next..].iter().cloned());
234 std::borrow::Cow::Owned(lines::fold(&out))
235}
236
237fn from_window(text: &str, from: i64) -> std::borrow::Cow<'_, str> {
249 use std::borrow::Cow;
250 if !text.contains("RRULE") {
251 return Cow::Borrowed(text);
252 }
253 let mut source = lines::unfold(text);
254 if source.iter().any(|l| lines::name(l) == "RECURRENCE-ID" && lines::param(l, "RANGE").is_some()) {
255 return Cow::Borrowed(text);
256 }
257 let Some(master) = master_range(&source) else { return Cow::Borrowed(text) };
258 let mut own = Vec::new();
260 let mut nested = 0usize;
261 for i in master.start + 1..master.end.saturating_sub(1) {
262 match lines::name(&source[i]).as_str() {
263 "BEGIN" => nested += 1,
264 "END" => nested = nested.saturating_sub(1),
265 _ if nested == 0 => own.push(i),
266 _ => {}
267 }
268 }
269 let find = |name: &str| own.iter().copied().find(|&i| lines::name(&source[i]) == name);
270 let (Some(rule), Some(start)) = (find("RRULE"), find("DTSTART")) else { return Cow::Borrowed(text) };
271 let parts: Vec<(String, String)> = lines::value(&source[rule]).split(';').filter_map(|p| p.split_once('=')).map(|(k, v)| (k.trim().to_ascii_uppercase(), v.trim().to_ascii_uppercase())).collect();
272 let part = |name: &str| parts.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str());
273 let unit = match part("FREQ") {
274 Some("SECONDLY") => 1,
275 Some("MINUTELY") => 60,
276 Some("HOURLY") => 3_600,
277 Some("DAILY") => 86_400,
278 Some("WEEKLY") => 7 * 86_400,
279 _ => return Cow::Borrowed(text),
280 };
281 if part("COUNT").is_some() {
282 return Cow::Borrowed(text);
283 }
284 let step = unit * part("INTERVAL").and_then(|n| n.parse::<i64>().ok()).filter(|n| *n > 0).unwrap_or(1);
285 let civil = |line: &str| DateTime::strptime("%Y%m%dT%H%M%S", lines::value(line).trim().trim_end_matches(['Z', 'z'])).ok();
287 let Some(begins) = civil(&source[start]) else { return Cow::Borrowed(text) };
288 let end = find("DTEND");
289 let length = match (end.and_then(|i| civil(&source[i])), find("DURATION")) {
290 (Some(ends), _) => begins.duration_until(ends).as_secs().max(0),
291 (None, Some(i)) => lines::value(&source[i]).trim().parse::<jiff::Span>().ok().and_then(|span| span.total((jiff::Unit::Second, begins)).ok()).map_or(0, |s| s.max(0.0) as i64),
292 (None, None) => 0,
293 };
294 let Some(window) = Timestamp::from_second(from).ok().map(|t| t.to_zoned(TimeZone::UTC).datetime()) else { return Cow::Borrowed(text) };
296 let before = window.checked_sub(jiff::SignedDuration::from_secs(2 * 86_400 + length + step)).unwrap_or(window);
297 let ahead = begins.duration_until(before).as_secs();
298 let periods = ahead.div_euclid(step);
299 if periods <= 0 {
300 return Cow::Borrowed(text);
301 }
302 let moved = jiff::SignedDuration::from_secs(periods.saturating_mul(step));
303 let rewrite = |line: &str, at: DateTime| {
304 let value = lines::value(line).trim();
305 let utc = if value.ends_with(['Z', 'z']) { "Z" } else { "" };
306 format!("{}:{}{utc}", head_of(line), at.strftime("%Y%m%dT%H%M%S"))
307 };
308 let Ok(new_start) = begins.checked_add(moved) else { return Cow::Borrowed(text) };
309 source[start] = rewrite(&source[start], new_start);
310 if let Some(i) = end
311 && let Some(ends) = civil(&source[i])
312 && let Ok(new_end) = ends.checked_add(moved)
313 {
314 source[i] = rewrite(&source[i], new_end);
315 }
316 Cow::Owned(lines::fold(&source))
317}
318
319pub fn file_occurrences(path: &Path, calendar: &Collection, from: i64, to: i64, zone: &TimeZone) -> Vec<Occurrence> {
323 let Some(text) = std::fs::read_to_string(path).ok() else { return Vec::new() };
324 let Some(ical) = parse(&for_expansion(&from_window(&text, from))) else { return Vec::new() };
325 let expanded = ical.expand_dates(calcard_zone(zone), EXPANSION_LIMIT);
326 let mut found = Vec::new();
327 for event in expanded.events {
328 let Some(component) = ical.components.get(event.comp_id as usize) else { continue };
329 if component.component_type != ICalendarComponentType::VEvent {
330 continue;
331 }
332 let start = event.start.timestamp();
333 let end = match event.end {
334 TimeOrDelta::Time(end) => end.timestamp(),
335 TimeOrDelta::Delta(delta) => start + delta.num_seconds(),
336 };
337 let all_day = is_all_day(component);
338 let end = if end > start { end } else if all_day { start + 86_400 } else { start };
340 if end <= from || start >= to {
341 continue;
342 }
343 let status = text_of(component, &ICalendarProperty::Status).to_ascii_uppercase();
344 let (organizer, attendees) = people(component);
345 let (margins, demands) = demands_of(component);
346 found.push(Occurrence {
347 key: path.display().to_string(),
348 uid: component.uid().unwrap_or_default().to_string(),
349 summary: lines::unescape(&text_of(component, &ICalendarProperty::Summary)),
350 location: lines::unescape(&text_of(component, &ICalendarProperty::Location)),
351 notes: lines::unescape(&text_of(component, &ICalendarProperty::Description)),
352 start,
353 end,
354 all_day,
355 recurring: component.is_recurrent_or_override(),
356 calendar: calendar.name.clone(),
357 color: calendar.color.clone(),
358 cancelled: status == "CANCELLED",
359 tentative: status == "TENTATIVE",
360 transparent: text_of(component, &ICalendarProperty::Transp).eq_ignore_ascii_case("TRANSPARENT"),
361 declined: false,
362 organizer,
363 attendees,
364 read_only: calendar.read_only,
365 alarms: alarms_of(&ical, component, start, end - start),
366 margins,
367 demands,
368 task: text_of(component, &ICalendarProperty::Other(crate::blocks::TASK.to_string())),
369 turn: text_of(component, &ICalendarProperty::Other(crate::blocks::TURN.to_string())),
370 remind: remind_of(&ical, component),
371 });
372 }
373 found
374}
375
376pub fn occurrences(from: i64, to: i64) -> Vec<Occurrence> {
379 let zone = TimeZone::system();
380 let mut all: Vec<Occurrence> = vdir::collections(Kind::Calendars)
381 .iter()
382 .flat_map(|calendar| calendar.items().into_iter().flat_map(|path| file_occurrences(&path, calendar, from, to, &zone)).collect::<Vec<_>>())
383 .collect();
384 if all.iter().any(|e| e.attendees.iter().any(|a| a.answer == "declined")) {
385 let own: Vec<String> = crate::config::Config::load(&crate::config::default_path()).map(|c| c.every_account().filter_map(|a| a.address.clone()).collect()).unwrap_or_default();
386 mark_declined(&mut all, &own);
387 }
388 all.sort_by(|a, b| (a.start, a.all_day == false, &a.summary).cmp(&(b.start, b.all_day == false, &b.summary)));
389 all
390}
391
392#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
394pub struct EventEdit {
395 pub title: String,
396 #[serde(default)]
397 pub location: String,
398 #[serde(default)]
399 pub notes: String,
400 pub start: String,
402 pub end: String,
404 #[serde(default)]
405 pub all_day: bool,
406 #[serde(default)]
408 pub repeat: String,
409 #[serde(default)]
411 pub margins: crate::demands::Margins,
412 #[serde(default)]
414 pub demands: crate::demands::Demands,
415 #[serde(default)]
417 pub remind: String,
418}
419
420fn remind_word(value: &str) -> String {
423 match crate::reminders::Remind::read(value) {
424 crate::reminders::Remind::Usual => String::new(),
425 crate::reminders::Remind::Never => "none".to_string(),
426 crate::reminders::Remind::Minutes(minutes) => minutes.to_string(),
427 }
428}
429
430fn remind_of(ical: &ICalendar, component: &ICalendarComponent) -> String {
433 let name = ICalendarProperty::Other(crate::reminders::REMIND.to_string());
434 let own = text_of(component, &name);
435 if !own.is_empty() || component.property(&ICalendarProperty::RecurrenceId).is_none() {
436 return own;
437 }
438 ical.components
439 .iter()
440 .find(|c| c.component_type == ICalendarComponentType::VEvent && c.property(&ICalendarProperty::RecurrenceId).is_none() && c.uid() == component.uid())
441 .map(|series| text_of(series, &name))
442 .unwrap_or_default()
443}
444
445pub fn edit_of(path: &Path) -> Option<EventEdit> {
447 edit_of_text(&std::fs::read_to_string(path).ok()?, &TimeZone::system())
448}
449
450pub fn edit_of_text(text: &str, zone: &TimeZone) -> Option<EventEdit> {
452 let ical = parse(&for_expansion(text))?;
453 let (zone, tz) = (zone.clone(), calcard_zone(zone));
454 let (index, master) = ical.components.iter().enumerate().find(|(_, c)| c.component_type == ICalendarComponentType::VEvent && c.property(&ICalendarProperty::RecurrenceId).is_none())?;
455 let first = ical.expand_dates(tz, FIRST_LOOKED_AT).events.into_iter().find(|e| e.comp_id as usize == index)?;
457 let start = Timestamp::from_second(first.start.timestamp()).ok()?.to_zoned(zone.clone());
458 let end_seconds = match first.end {
459 TimeOrDelta::Time(end) => end.timestamp(),
460 TimeOrDelta::Delta(delta) => first.start.timestamp() + delta.num_seconds(),
461 };
462 let end = Timestamp::from_second(end_seconds).ok()?.to_zoned(zone);
463 let all_day = is_all_day(master);
464 let rule = master
465 .property(&ICalendarProperty::Rrule)
466 .and_then(|e| e.values.first())
467 .map(|v| match v {
468 ICalendarValue::RecurrenceRule(rule) => rule.to_string(),
469 other => other.as_text().unwrap_or_default().to_string(),
470 })
471 .unwrap_or_default()
472 .to_ascii_uppercase();
473 let repeat = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"].into_iter().find(|f| rule.contains(&format!("FREQ={f}"))).map(str::to_lowercase).unwrap_or_default();
474 let (margins, demands) = demands_of(master);
475 Some(EventEdit {
476 title: lines::unescape(&text_of(master, &ICalendarProperty::Summary)),
477 location: lines::unescape(&text_of(master, &ICalendarProperty::Location)),
478 notes: lines::unescape(&text_of(master, &ICalendarProperty::Description)),
479 start: if all_day { start.date().to_string() } else { start.strftime("%Y-%m-%dT%H:%M").to_string() },
480 end: if all_day { end.date().checked_sub(1.day()).unwrap_or(end.date()).to_string() } else { end.strftime("%Y-%m-%dT%H:%M").to_string() },
482 all_day,
483 repeat,
484 margins,
485 demands,
486 remind: remind_word(&text_of(master, &ICalendarProperty::Other(crate::reminders::REMIND.to_string()))),
487 })
488}
489
490pub fn first_times(text: &str, zone: &TimeZone) -> Option<(i64, i64)> {
494 let ical = parse(&for_expansion(text))?;
495 let (index, _) = ical.components.iter().enumerate().find(|(_, c)| c.component_type == ICalendarComponentType::VEvent && c.property(&ICalendarProperty::RecurrenceId).is_none())?;
496 let first = ical.expand_dates(calcard_zone(zone), FIRST_LOOKED_AT).events.into_iter().find(|e| e.comp_id as usize == index)?;
497 let start = first.start.timestamp();
498 let end = match first.end {
499 TimeOrDelta::Time(end) => end.timestamp(),
500 TimeOrDelta::Delta(delta) => start + delta.num_seconds(),
501 };
502 Some((start, end))
503}
504
505fn instants(edit: &EventEdit, zone: &TimeZone) -> Result<(Zoned, Zoned), String> {
507 let bad = |what: &str| format!("{what}: ?");
508 if edit.all_day {
509 let start: Date = edit.start.get(..10).unwrap_or(&edit.start).parse().map_err(|_| bad(&edit.start))?;
510 let last: Date = edit.end.get(..10).unwrap_or(&edit.end).parse().unwrap_or(start);
511 let last = if last < start { start } else { last };
512 let start = start.to_zoned(zone.clone()).map_err(|e| e.to_string())?;
513 let end = last.checked_add(1.day()).map_err(|e| e.to_string())?.to_zoned(zone.clone()).map_err(|e| e.to_string())?;
514 return Ok((start, end));
515 }
516 let start: DateTime = edit.start.parse().map_err(|_| bad(&edit.start))?;
517 let end: DateTime = edit.end.parse().unwrap_or(start);
518 let start = start.to_zoned(zone.clone()).map_err(|e| e.to_string())?;
519 let end = end.to_zoned(zone.clone()).map_err(|e| e.to_string())?;
520 let end = if end <= start { start.checked_add(1.hour()).map_err(|e| e.to_string())? } else { end };
522 Ok((start, end))
523}
524
525fn utc(at: &Zoned) -> String {
526 at.timestamp().strftime("%Y%m%dT%H%M%SZ").to_string()
527}
528
529fn time_lines(edit: &EventEdit, zone: &TimeZone) -> Result<(Vec<String>, Option<String>), String> {
532 let (start, end) = instants(edit, zone)?;
533 if edit.all_day {
534 return Ok((vec![format!("DTSTART;VALUE=DATE:{}", start.date().strftime("%Y%m%d")), format!("DTEND;VALUE=DATE:{}", end.date().strftime("%Y%m%d"))], None));
535 }
536 match zone.iana_name().filter(|_| !edit.repeat.is_empty()) {
537 Some(name) => Ok((
538 vec![format!("DTSTART;TZID={name}:{}", start.strftime("%Y%m%dT%H%M%S")), format!("DTEND;TZID={name}:{}", end.strftime("%Y%m%dT%H%M%S"))],
539 Some(name.to_string()),
540 )),
541 None => Ok((vec![format!("DTSTART:{}", utc(&start)), format!("DTEND:{}", utc(&end))], None)),
542 }
543}
544
545fn rule_line(repeat: &str) -> Option<String> {
546 match repeat {
547 "daily" | "weekly" | "monthly" | "yearly" => Some(format!("RRULE:FREQ={}", repeat.to_ascii_uppercase())),
548 _ => None,
549 }
550}
551
552pub fn vtimezone(zone: &TimeZone, name: &str, year: i16) -> Vec<String> {
556 let mut out = vec!["BEGIN:VTIMEZONE".to_string(), format!("TZID:{name}")];
557 let Some(from) = Date::new(year, 1, 1).ok().and_then(|d| d.to_zoned(zone.clone()).ok()) else { return out };
558 let transitions: Vec<_> = zone.following(from.timestamp()).take_while(|t| t.timestamp().to_zoned(zone.clone()).year() == year).take(2).collect();
559 let offset = |o: jiff::tz::Offset| {
560 let seconds = o.seconds();
561 let sign = if seconds < 0 { '-' } else { '+' };
562 format!("{sign}{:02}{:02}", seconds.abs() / 3600, (seconds.abs() % 3600) / 60)
563 };
564 if transitions.len() < 2 {
565 let now = zone.to_offset(from.timestamp());
566 out.extend(["BEGIN:STANDARD".to_string(), "DTSTART:19700101T000000".to_string(), format!("TZOFFSETFROM:{}", offset(now)), format!("TZOFFSETTO:{}", offset(now)), "END:STANDARD".to_string()]);
567 } else {
568 for transition in transitions {
569 let before = zone.to_offset(transition.timestamp().checked_sub(1.second()).unwrap_or(transition.timestamp()));
570 let after = transition.offset();
571 let local = transition.timestamp().to_zoned(TimeZone::fixed(before));
573 let weekday = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"][local.weekday().to_monday_zero_offset() as usize];
574 let days_in_month = local.date().days_in_month();
575 let nth = if local.day() + 7 > days_in_month { "-1".to_string() } else { ((local.day() - 1) / 7 + 1).to_string() };
576 let part = if transition.dst().is_dst() { "DAYLIGHT" } else { "STANDARD" };
577 out.extend([
578 format!("BEGIN:{part}"),
579 format!("DTSTART:{}", local.strftime("%Y%m%dT%H%M%S")),
580 format!("RRULE:FREQ=YEARLY;BYMONTH={};BYDAY={nth}{weekday}", local.month()),
581 format!("TZOFFSETFROM:{}", offset(before)),
582 format!("TZOFFSETTO:{}", offset(after)),
583 format!("TZNAME:{}", transition.abbreviation()),
584 format!("END:{part}"),
585 ]);
586 }
587 }
588 out.push("END:VTIMEZONE".to_string());
589 out
590}
591
592pub fn new_event(edit: &EventEdit, zone: &TimeZone) -> Result<String, String> {
594 let zone = zone.clone();
595 let (times, tzid) = time_lines(edit, &zone)?;
596 let mut out = vec!["BEGIN:VCALENDAR".to_string(), "VERSION:2.0".to_string(), "PRODID:-//Sioul//Sioul//EN".to_string(), "CALSCALE:GREGORIAN".to_string()];
597 if let Some(name) = &tzid {
598 out.extend(vtimezone(&zone, name, Zoned::now().with_time_zone(zone.clone()).year()));
599 }
600 out.extend(["BEGIN:VEVENT".to_string(), format!("UID:{}", vdir::new_name()), format!("DTSTAMP:{}", utc(&Zoned::now()))]);
601 out.extend(times);
602 out.extend(content_lines(edit, Changed::ALL));
603 out.extend(["END:VEVENT".to_string(), "END:VCALENDAR".to_string()]);
604 Ok(lines::fold(&out))
605}
606
607#[derive(Clone, Copy)]
609struct Changed {
610 title: bool,
611 location: bool,
612 notes: bool,
613 rule: bool,
614 margins: bool,
615 demands: bool,
616 remind: bool,
617}
618
619impl Changed {
620 const ALL: Changed = Changed { title: true, location: true, notes: true, rule: true, margins: true, demands: true, remind: true };
621}
622
623fn content_lines(edit: &EventEdit, changed: Changed) -> Vec<String> {
625 let mut out = Vec::new();
626 if changed.title {
627 out.push(format!("SUMMARY:{}", lines::escape(edit.title.trim())));
628 }
629 if changed.location && !edit.location.trim().is_empty() {
630 out.push(format!("LOCATION:{}", lines::escape(edit.location.trim())));
631 }
632 if changed.notes && !edit.notes.trim().is_empty() {
633 out.push(format!("DESCRIPTION:{}", lines::escape(edit.notes.trim())));
634 }
635 if changed.rule {
636 out.extend(rule_line(&edit.repeat));
637 }
638 if changed.margins {
639 if edit.margins.before > 0 {
640 out.push(format!("{}:{}", crate::demands::BEFORE, edit.margins.before.min(24 * 60)));
641 }
642 if edit.margins.after > 0 {
643 out.push(format!("{}:{}", crate::demands::AFTER, edit.margins.after.min(24 * 60)));
644 }
645 }
646 if changed.demands {
647 if edit.demands.has_cost() {
648 out.push(format!("{}:{}", crate::demands::COST, edit.demands.cost_value()));
649 }
650 if let Some(gain) = edit.demands.gain {
651 out.push(format!("{}:{}", crate::demands::GAIN, gain.min(10)));
652 }
653 }
654 if changed.remind
655 && let Some(value) = crate::reminders::Remind::read(&edit.remind).value()
656 {
657 out.push(format!("{}:{value}", crate::reminders::REMIND));
658 }
659 out
660}
661
662const EDITED: &[&str] = &["DTSTAMP", "LAST-MODIFIED", "SEQUENCE"];
664const TIMES: &[&str] = &["DTSTART", "DTEND", "DURATION"];
667
668pub fn apply(text: &str, edit: &EventEdit, zone: &TimeZone) -> Result<String, String> {
674 let zone = zone.clone();
675 let before = edit_of_text(text, &zone);
676 if before.as_ref() == Some(edit) {
678 return Ok(text.to_string());
679 }
680 let times_changed = before.as_ref().is_none_or(|b| (&b.start, &b.end, b.all_day) != (&edit.start, &edit.end, edit.all_day));
681 let rule_changed = before.as_ref().is_none_or(|b| b.repeat != edit.repeat);
682 let changed = match &before {
683 Some(b) => Changed { title: b.title.trim() != edit.title.trim(), location: b.location.trim() != edit.location.trim(), notes: b.notes.trim() != edit.notes.trim(), rule: rule_changed, margins: b.margins != edit.margins, demands: b.demands != edit.demands, remind: crate::reminders::Remind::read(&b.remind) != crate::reminders::Remind::read(&edit.remind) },
684 None => Changed::ALL,
685 };
686 let (times, tzid) = if times_changed || rule_changed { time_lines(edit, &zone)? } else { (Vec::new(), None) };
687 let rewritten = |name: &str| {
688 EDITED.contains(&name)
689 || ((times_changed || rule_changed) && TIMES.contains(&name))
690 || (rule_changed && name == "RRULE")
691 || (changed.title && name == "SUMMARY")
692 || (changed.location && name == "LOCATION")
693 || (changed.notes && name == "DESCRIPTION")
694 || (changed.margins && (name == crate::demands::BEFORE || name == crate::demands::AFTER))
695 || (changed.demands && (name == crate::demands::COST || name == crate::demands::GAIN))
696 || (changed.remind && name == crate::reminders::REMIND)
697 };
698 let source = lines::unfold(text);
699 let mut out: Vec<String> = Vec::with_capacity(source.len() + 8);
700 let mut depth_in_event = false;
701 let mut nested = 0usize;
702 let mut is_override = false;
703 let mut event: Vec<String> = Vec::new();
704 let mut has_zone = false;
705 for line in source {
706 let name = lines::name(&line);
707 let value = lines::value(&line).trim().to_ascii_uppercase();
708 if name == "BEGIN" && value == "VTIMEZONE" && tzid.as_deref().is_some_and(|z| text.contains(&format!("TZID:{z}"))) {
709 has_zone = true;
710 }
711 if !depth_in_event {
712 if name == "BEGIN" && value == "VEVENT" {
713 depth_in_event = true;
714 nested = 0;
715 is_override = false;
716 event = vec![line];
717 } else {
718 out.push(line);
719 }
720 continue;
721 }
722 if name == "BEGIN" {
724 nested += 1;
725 }
726 if name == "END" && nested > 0 {
727 nested -= 1;
728 event.push(line);
729 continue;
730 }
731 if name == "END" && value == "VEVENT" {
732 depth_in_event = false;
733 if is_override {
734 event.push(line);
735 out.append(&mut event);
736 continue;
737 }
738 let sequence = event.iter().find(|l| lines::name(l) == "SEQUENCE").and_then(|l| lines::value(l).trim().parse::<u32>().ok()).unwrap_or(0);
739 let mut kept: Vec<String> = event.drain(..).filter(|l| !rewritten(&lines::name(l))).collect();
740 let at = kept.iter().position(|l| lines::name(l) == "BEGIN" && lines::value(l).trim().eq_ignore_ascii_case("VALARM")).unwrap_or(kept.len());
741 let mut added = vec![format!("DTSTAMP:{}", utc(&Zoned::now())), format!("LAST-MODIFIED:{}", utc(&Zoned::now())), format!("SEQUENCE:{}", sequence.saturating_add(1))];
742 added.extend(times.clone());
743 added.extend(content_lines(edit, changed));
744 kept.splice(at..at, added);
745 kept.push(line);
746 out.append(&mut kept);
747 continue;
748 }
749 if nested == 0 && name == "RECURRENCE-ID" {
750 is_override = true;
751 }
752 event.push(line);
753 }
754 if let (Some(name), false) = (&tzid, has_zone) {
756 let at = out.iter().position(|l| lines::name(l) == "BEGIN" && lines::value(l).trim().eq_ignore_ascii_case("VEVENT")).unwrap_or(out.len());
757 let zone_lines = vtimezone(&zone, name, Zoned::now().year());
758 out.splice(at..at, zone_lines);
759 }
760 Ok(lines::fold(&out))
761}
762
763pub fn skip_occurrence(text: &str, start: i64) -> Option<String> {
768 let zone = TimeZone::system();
769 let mut source = lines::unfold(text);
770 if let Some((place, true)) = occurrence_at(text, start, &zone) {
771 let range = event_ranges(&source).get(place)?.clone();
772 let id = source[range.clone()].iter().find(|l| lines::name(l) == "RECURRENCE-ID")?.clone();
773 let params: String = lines::params(&id).into_iter().filter(|(name, _)| name != "RANGE").map(|(name, value)| format!(";{name}={}", lines::param_value(&value))).collect();
775 let value = lines::value(&id).trim().to_string();
776 source.drain(range);
777 let master = master_range(&source)?;
778 source.insert(master.end - 1, format!("EXDATE{params}:{value}"));
779 return Some(lines::fold(&source));
780 }
781 let at = Timestamp::from_second(start).ok()?;
782 let master = master_range(&source)?;
783 let dtstart = source[master.clone()].iter().find(|l| lines::name(l) == "DTSTART")?.clone();
784 let end = master.end - 1;
785 source.insert(end, written_like(&dtstart, "EXDATE", at, &zone));
786 Some(lines::fold(&source))
787}
788
789fn occurrence_at(text: &str, start: i64, zone: &TimeZone) -> Option<(usize, bool)> {
794 let ical = parse(&for_expansion(&from_window(text, start)))?;
795 let events: Vec<usize> = ical.components.iter().enumerate().filter(|(_, c)| c.component_type == ICalendarComponentType::VEvent).map(|(i, _)| i).collect();
796 let found = ical.expand_dates(calcard_zone(zone), EXPANSION_LIMIT).events.into_iter().find(|e| e.start.timestamp() == start && events.contains(&(e.comp_id as usize)))?;
797 let place = events.iter().position(|&i| i == found.comp_id as usize)?;
798 Some((place, ical.components[found.comp_id as usize].property(&ICalendarProperty::RecurrenceId).is_some()))
799}
800
801fn event_ranges(source: &[String]) -> Vec<std::ops::Range<usize>> {
803 let (mut out, mut start, mut nested) = (Vec::new(), None, 0usize);
804 for (i, line) in source.iter().enumerate() {
805 let name = lines::name(line);
806 let value = lines::value(line).trim().to_ascii_uppercase();
807 match (name.as_str(), start) {
808 ("BEGIN", None) if value == "VEVENT" => {
809 start = Some(i);
810 nested = 0;
811 }
812 ("BEGIN", Some(_)) => nested += 1,
813 ("END", Some(_)) if nested > 0 => nested -= 1,
814 ("END", Some(begin)) if value == "VEVENT" => {
815 out.push(begin..i + 1);
816 start = None;
817 }
818 _ => {}
819 }
820 }
821 out
822}
823
824fn head_of(line: &str) -> &str {
826 line.strip_suffix(lines::value(line)).and_then(|h| h.strip_suffix(':')).unwrap_or(line)
827}
828
829fn written_value(like: &str, at: Timestamp, zone: &TimeZone) -> String {
832 let value = lines::value(like).trim();
833 if value.len() == 8 {
834 at.to_zoned(zone.clone()).strftime("%Y%m%d").to_string()
835 } else if value.ends_with(['Z', 'z']) {
836 at.strftime("%Y%m%dT%H%M%SZ").to_string()
837 } else {
838 let own = lines::param(like, "TZID").and_then(|t| TimeZone::get(t.trim_start_matches('/')).ok()).unwrap_or_else(|| zone.clone());
839 at.to_zoned(own).strftime("%Y%m%dT%H%M%S").to_string()
840 }
841}
842
843fn written_like(like: &str, name: &str, at: Timestamp, zone: &TimeZone) -> String {
845 let head = head_of(like);
846 let params = head.find(';').map_or("", |i| &head[i..]);
847 format!("{name}{params}:{}", written_value(like, at, zone))
848}
849
850fn moment_in(line: &str, value: &str, zone: &TimeZone) -> Option<Zoned> {
853 let value = value.trim();
854 if value.len() == 8 {
855 return Date::strptime("%Y%m%d", value).ok()?.to_zoned(zone.clone()).ok();
856 }
857 if let Some(utc) = value.strip_suffix(['Z', 'z']) {
858 return DateTime::strptime("%Y%m%dT%H%M%S", utc).ok()?.to_zoned(TimeZone::UTC).ok().map(|z| z.with_time_zone(zone.clone()));
859 }
860 let own = lines::param(line, "TZID").and_then(|t| TimeZone::get(t.trim_start_matches('/')).ok()).unwrap_or_else(|| zone.clone());
861 DateTime::strptime("%Y%m%dT%H%M%S", value).ok()?.to_zoned(own).ok().map(|z| z.with_time_zone(zone.clone()))
862}
863
864fn retimed(block: &[String], like: &str, from: Timestamp, to: Timestamp, recurrence: Option<String>, drop: &[&str], zone: &TimeZone) -> Vec<String> {
872 let kept = recurrence.is_some();
873 let sequence = block.iter().find(|l| lines::name(l) == "SEQUENCE").and_then(|l| lines::value(l).trim().parse::<u32>().ok());
874 let last = block.len().saturating_sub(1);
875 let mut nested = 0usize;
876 let mut out: Vec<String> = Vec::with_capacity(block.len() + 6);
877 for (i, line) in block.iter().enumerate() {
878 if i == 0 || i == last {
879 out.push(line.clone());
880 continue;
881 }
882 match lines::name(line).as_str() {
883 "BEGIN" => nested += 1,
884 "END" => nested = nested.saturating_sub(1),
885 name if nested == 0 && (matches!(name, "DTSTART" | "DTEND" | "DURATION" | "DTSTAMP" | "LAST-MODIFIED") || (name == "SEQUENCE" && !kept) || drop.contains(&name)) => continue,
886 _ => {}
887 }
888 out.push(line.clone());
889 }
890 let now = utc(&Zoned::now());
891 let mut own = Vec::new();
892 own.extend(recurrence);
893 own.extend([written_like(like, "DTSTART", from, zone), written_like(like, "DTEND", to, zone), format!("DTSTAMP:{now}"), format!("LAST-MODIFIED:{now}")]);
894 if !kept {
895 own.push(format!("SEQUENCE:{}", sequence.unwrap_or(0).saturating_add(1)));
896 }
897 let at = out.iter().enumerate().skip(1).find(|(_, l)| lines::name(l) == "BEGIN").map_or(out.len().saturating_sub(1), |(i, _)| i);
899 out.splice(at..at, own);
900 out
901}
902
903fn rule_moved(rule: &str, span: jiff::Span, zone: &TimeZone) -> Option<String> {
909 let days = span.get_days();
910 let timed = span.get_hours() != 0 || span.get_minutes() != 0 || span.get_seconds() != 0;
911 let weekly = rule.to_ascii_uppercase().split(';').any(|p| p.trim() == "FREQ=WEEKLY");
912 const WEEK: [&str; 7] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
913 let mut parts = Vec::new();
914 for part in rule.split(';') {
915 let (key, value) = part.split_once('=').unwrap_or((part, ""));
916 let upper = key.trim().to_ascii_uppercase();
917 let moved = match upper.as_str() {
918 "BYDAY" if days == 0 => part.to_string(),
919 "BYDAY" if weekly => {
920 let shifted: Option<Vec<&str>> = value.split(',').map(|d| WEEK.iter().position(|w| w.eq_ignore_ascii_case(d.trim())).map(|i| WEEK[(i as i64 + i64::from(days)).rem_euclid(7) as usize])).collect();
921 format!("{key}={}", shifted?.join(","))
922 }
923 "BYDAY" | "BYMONTHDAY" | "BYYEARDAY" | "BYWEEKNO" | "BYMONTH" | "BYSETPOS" if days != 0 => return None,
924 "BYHOUR" | "BYMINUTE" | "BYSECOND" if timed => return None,
925 "UNTIL" => {
926 let until = moment_in("", value, zone)?;
927 let shifted = until.datetime().checked_add(span).ok()?.to_zoned(zone.clone()).ok()?;
928 let written = if value.trim().len() == 8 { shifted.strftime("%Y%m%d").to_string() } else { shifted.timestamp().strftime("%Y%m%dT%H%M%SZ").to_string() };
929 format!("{key}={written}")
930 }
931 _ => part.to_string(),
932 };
933 parts.push(moved);
934 }
935 Some(parts.join(";"))
936}
937
938#[derive(Debug, Clone, PartialEq, Eq)]
940pub enum MoveProblem {
941 Gone,
943 SetDays,
946 Unreadable(String),
948}
949
950pub fn moved(text: &str, start: i64, new_start: i64, new_end: i64, only_this: bool, zone: &TimeZone) -> Result<String, MoveProblem> {
960 let unreadable = |e: &dyn std::fmt::Display| MoveProblem::Unreadable(e.to_string());
961 let (place, changed) = occurrence_at(text, start, zone).ok_or(MoveProblem::Gone)?;
962 let (from, to) = (Timestamp::from_second(new_start).map_err(|e| unreadable(&e))?, Timestamp::from_second(new_end).map_err(|e| unreadable(&e))?);
963 if to <= from {
964 return Err(MoveProblem::Unreadable(format!("{new_start}–{new_end}")));
965 }
966 let mut source = lines::unfold(text);
967 let master = master_range(&source).ok_or(MoveProblem::Gone)?;
968 let repeats = source[master.clone()].iter().any(|l| matches!(lines::name(l).as_str(), "RRULE" | "RDATE"));
969 let ranges = event_ranges(&source);
970 if changed && (only_this || !repeats) {
972 let range = ranges.get(place).cloned().ok_or(MoveProblem::Gone)?;
973 let like = source[range.clone()].iter().find(|l| lines::name(l) == "DTSTART").cloned().ok_or(MoveProblem::Gone)?;
974 let block = retimed(&source[range.clone()], &like, from, to, None, &[], zone);
975 source.splice(range, block);
976 return Ok(lines::fold(&source));
977 }
978 if repeats && only_this {
979 let like = source[master.clone()].iter().find(|l| lines::name(l) == "DTSTART").cloned().ok_or(MoveProblem::Gone)?;
980 let instance = Timestamp::from_second(start).map_err(|e| unreadable(&e))?;
981 let recurrence = written_like(&like, "RECURRENCE-ID", instance, zone);
982 let block = retimed(&source[master.clone()], &like, from, to, Some(recurrence), &["RRULE", "RDATE", "EXDATE", "EXRULE", "RECURRENCE-ID"], zone);
983 source.splice(master.end..master.end, block);
984 return Ok(lines::fold(&source));
985 }
986 let mut edit = edit_of_text(text, zone).ok_or(MoveProblem::Gone)?;
987 let local = |at: Timestamp| at.to_zoned(zone.clone()).strftime("%Y-%m-%dT%H:%M").to_string();
988 if !repeats {
989 edit.start = local(from);
990 edit.end = local(to);
991 return apply(text, &edit, zone).map_err(|e| unreadable(&e));
992 }
993 let was = Timestamp::from_second(start).map_err(|e| unreadable(&e))?.to_zoned(zone.clone()).datetime();
995 let span = was.until((jiff::Unit::Day, from.to_zoned(zone.clone()).datetime())).map_err(|e| unreadable(&e))?;
996 let shift = |at: &Zoned| -> Option<Timestamp> { at.datetime().checked_add(span).ok()?.to_zoned(zone.clone()).ok().map(|z| z.timestamp()) };
997 for line in source[master.clone()].iter().filter(|l| lines::name(l) == "RRULE") {
999 rule_moved(lines::value(line).trim(), span, zone).ok_or(MoveProblem::SetDays)?;
1000 }
1001 let first: DateTime = edit.start.parse().map_err(|e| unreadable(&e))?;
1002 let first = first.checked_add(span).map_err(|e| unreadable(&e))?;
1003 let last = first.checked_add(jiff::Span::new().seconds(new_end - new_start)).map_err(|e| unreadable(&e))?;
1004 edit.start = first.strftime("%Y-%m-%dT%H:%M").to_string();
1005 edit.end = last.strftime("%Y-%m-%dT%H:%M").to_string();
1006 let written = apply(text, &edit, zone).map_err(|e| unreadable(&e))?;
1009 let mut source = lines::unfold(&written);
1010 let master = master_range(&source).ok_or(MoveProblem::Gone)?;
1011 for range in event_ranges(&source) {
1012 let own = range.clone();
1013 let mut nested = 0usize;
1014 for i in own.clone() {
1015 let name = lines::name(&source[i]);
1016 match name.as_str() {
1017 "BEGIN" if i > own.start => nested += 1,
1018 "END" if i + 1 < own.end => nested = nested.saturating_sub(1),
1019 _ => {}
1020 }
1021 if nested > 0 {
1022 continue;
1023 }
1024 let line = source[i].clone();
1025 if range == master && name == "RRULE" {
1026 let rule = rule_moved(lines::value(&line).trim(), span, zone).ok_or(MoveProblem::SetDays)?;
1027 source[i] = format!("{}:{rule}", head_of(&line));
1028 continue;
1029 }
1030 let wanted = if range == master { matches!(name.as_str(), "EXDATE" | "RDATE") } else { name == "RECURRENCE-ID" };
1031 if !wanted {
1032 continue;
1033 }
1034 let values: Option<Vec<String>> = lines::value(&line).split(',').map(|v| moment_in(&line, v, zone).and_then(|z| shift(&z)).map(|at| written_value(&line, at, zone))).collect();
1035 if let Some(values) = values {
1036 source[i] = format!("{}:{}", head_of(&line), values.join(","));
1037 }
1038 }
1039 }
1040 if changed {
1042 let range = event_ranges(&source).get(place).cloned().ok_or(MoveProblem::Gone)?;
1043 let like = source[range.clone()].iter().find(|l| lines::name(l) == "DTSTART").cloned().ok_or(MoveProblem::Gone)?;
1044 let block = retimed(&source[range.clone()], &like, from, to, None, &[], zone);
1045 source.splice(range, block);
1046 }
1047 Ok(lines::fold(&source))
1048}
1049
1050pub fn add_lines(text: &str, added: &[String]) -> Option<String> {
1054 let mut source = lines::unfold(text);
1055 let master = master_range(&source)?;
1056 let new: Vec<String> = added.iter().filter(|l| !source[master.clone()].contains(l)).cloned().collect();
1057 if new.is_empty() {
1058 return Some(text.to_string());
1059 }
1060 let at = source[master.clone()].iter().position(|l| lines::name(l) == "BEGIN" && !lines::value(l).trim().eq_ignore_ascii_case("VEVENT")).map_or(master.end - 1, |i| master.start + i);
1061 source.splice(at..at, new);
1062 Some(lines::fold(&source))
1063}
1064
1065pub fn remove_lines(text: &str, drop: impl Fn(&str) -> bool) -> Option<String> {
1068 let mut source = lines::unfold(text);
1069 let master = master_range(&source)?;
1070 let last = master.len() - 1;
1071 let mut nested = 0usize;
1072 let kept: Vec<String> = source[master.clone()]
1073 .iter()
1074 .enumerate()
1075 .filter(|(i, line)| {
1076 if *i == 0 || *i == last {
1077 return true;
1078 }
1079 match lines::name(line).as_str() {
1080 "BEGIN" => nested += 1,
1081 "END" => nested = nested.saturating_sub(1),
1082 _ if nested == 0 => return !drop(line),
1083 _ => {}
1084 }
1085 true
1086 })
1087 .map(|(_, line)| line.clone())
1088 .collect();
1089 if kept.len() == master.len() {
1090 return Some(text.to_string());
1091 }
1092 source.splice(master, kept);
1093 Some(lines::fold(&source))
1094}
1095
1096fn master_range(source: &[String]) -> Option<std::ops::Range<usize>> {
1098 let mut start = None;
1099 let mut nested = 0;
1100 let mut is_override = false;
1101 for (i, line) in source.iter().enumerate() {
1102 let name = lines::name(line);
1103 let value = lines::value(line).trim().to_ascii_uppercase();
1104 match (name.as_str(), start) {
1105 ("BEGIN", None) if value == "VEVENT" => {
1106 start = Some(i);
1107 is_override = false;
1108 }
1109 ("BEGIN", Some(_)) => nested += 1,
1110 ("END", Some(_)) if nested > 0 => nested -= 1,
1111 ("RECURRENCE-ID", Some(_)) if nested == 0 => is_override = true,
1112 ("END", Some(begin)) if value == "VEVENT" => {
1113 if !is_override {
1114 return Some(begin..i + 1);
1115 }
1116 start = None;
1117 }
1118 _ => {}
1119 }
1120 }
1121 None
1122}
1123
1124#[derive(Debug, Clone, Default, PartialEq, Eq)]
1127pub struct EventRef {
1128 pub key: String,
1129 pub uid: String,
1130 pub summary: String,
1131 pub start: i64,
1133 pub links: Vec<crate::tasks::Link>,
1134 pub related: Vec<crate::tasks::Relation>,
1135 pub projects: Vec<String>,
1136 pub people: Vec<String>,
1138}
1139
1140pub fn event_ref(text: &str, key: &str, zone: &TimeZone) -> Option<EventRef> {
1142 let source = lines::unfold(text);
1143 let master = master_range(&source)?;
1144 let mut found = EventRef { key: key.to_string(), ..EventRef::default() };
1145 let mut nested = 0usize;
1146 for line in &source[master.start + 1..master.end - 1] {
1147 let name = lines::name(line);
1148 match name.as_str() {
1149 "BEGIN" => nested += 1,
1150 "END" => nested = nested.saturating_sub(1),
1151 _ if nested > 0 => {}
1152 "UID" => found.uid = lines::value(line).trim().to_string(),
1153 "SUMMARY" => found.summary = lines::unescape(lines::value(line).trim()),
1154 "LINK" => found.links.push(crate::tasks::link_of(line)),
1155 "RELATED-TO" => found.related.push(crate::tasks::relation_of(line)),
1156 "REFID" => found.projects.push(lines::unescape(lines::value(line).trim())),
1157 "ATTENDEE" | "ORGANIZER" => found.people.push(address(lines::value(line))),
1158 _ => {}
1159 }
1160 }
1161 found.start = edit_of_text(text, zone).and_then(|e| {
1162 let at: DateTime = if e.all_day { format!("{}T00:00", e.start).parse().ok()? } else { e.start.parse().ok()? };
1163 at.to_zoned(zone.clone()).ok().map(|z| z.timestamp().as_second())
1164 }).unwrap_or(0);
1165 Some(found)
1166}
1167
1168pub fn event_refs() -> Vec<EventRef> {
1170 let zone = TimeZone::system();
1171 vdir::collections(Kind::Calendars)
1172 .iter()
1173 .flat_map(|c| c.items())
1174 .filter_map(|path| {
1175 let text = std::fs::read_to_string(&path).ok()?;
1176 text.to_ascii_uppercase().contains("BEGIN:VEVENT").then(|| event_ref(&text, &path.display().to_string(), &zone)).flatten()
1177 })
1178 .collect()
1179}
1180
1181pub fn default_calendar() -> Option<Collection> {
1184 let writable: Vec<Collection> = vdir::collections(Kind::Calendars).into_iter().filter(|c| !c.read_only && c.holds("VEVENT")).collect();
1185 writable.iter().find(|c| c.id != crate::blocks::CALENDAR).or(writable.first()).cloned()
1186}
1187
1188pub fn new_path(calendar: &Collection) -> PathBuf {
1190 calendar.dir.join(format!("{}.ics", vdir::new_name()))
1191}
1192
1193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1195pub struct Invitation {
1196 pub method: String,
1198 pub uid: String,
1199 pub summary: String,
1200 pub location: String,
1201 pub start: i64,
1202 pub end: i64,
1203 pub all_day: bool,
1204 pub organizer: String,
1205 pub organizer_address: String,
1206 pub attendees: Vec<Attendee>,
1207}
1208
1209pub fn invitation(text: &str, zone: &TimeZone) -> Option<Invitation> {
1212 let ical = parse(text)?;
1213 let method = ical
1214 .components
1215 .iter()
1216 .find(|c| c.component_type == ICalendarComponentType::VCalendar)
1217 .and_then(|c| c.property(&ICalendarProperty::Method))
1218 .and_then(|e| e.values.first())
1219 .map(|v| match v {
1220 ICalendarValue::Method(m) => format!("{m:?}").to_ascii_uppercase(),
1221 other => other.as_text().unwrap_or_default().to_ascii_uppercase(),
1222 })
1223 .unwrap_or_else(|| "PUBLISH".into());
1224 let tz = calcard_zone(zone);
1225 let (index, event) = ical.components.iter().enumerate().find(|(_, c)| c.component_type == ICalendarComponentType::VEvent)?;
1226 let first = ical.expand_dates(tz, FIRST_LOOKED_AT).events.into_iter().find(|e| e.comp_id as usize == index)?;
1227 let start = first.start.timestamp();
1228 let end = match first.end {
1229 TimeOrDelta::Time(end) => end.timestamp(),
1230 TimeOrDelta::Delta(delta) => start + delta.num_seconds(),
1231 };
1232 let (organizer, attendees) = people(event);
1233 let organizer_address = event.property(&ICalendarProperty::Organizer).and_then(|e| e.values.first()).and_then(ICalendarValue::as_text).map(address).unwrap_or_default();
1234 Some(Invitation {
1235 method,
1236 uid: event.uid().unwrap_or_default().to_string(),
1237 summary: lines::unescape(&text_of(event, &ICalendarProperty::Summary)),
1238 location: lines::unescape(&text_of(event, &ICalendarProperty::Location)),
1239 start,
1240 end,
1241 all_day: is_all_day(event),
1242 organizer,
1243 organizer_address,
1244 attendees,
1245 })
1246}
1247
1248pub fn reply(text: &str, me: &str, answer: &str) -> Option<String> {
1252 let source = lines::unfold(text);
1253 let master = master_range(&source)?;
1254 let mine = |line: &str| address(lines::value(line)).eq_ignore_ascii_case(me);
1255 let attendee = source[master.clone()].iter().find(|l| lines::name(l) == "ATTENDEE" && mine(l)).cloned().unwrap_or_else(|| format!("ATTENDEE:mailto:{me}"));
1256 let attendee = with_answer(&attendee, answer);
1257 let kept = ["UID", "SEQUENCE", "DTSTART", "DTEND", "DURATION", "SUMMARY", "ORGANIZER", "RECURRENCE-ID"];
1258 let mut out = vec!["BEGIN:VCALENDAR".to_string(), "VERSION:2.0".to_string(), "PRODID:-//Sioul//Sioul//EN".to_string(), "METHOD:REPLY".to_string()];
1259 let mut in_zone = false;
1261 for line in &source {
1262 let name = lines::name(line);
1263 let upper = lines::value(line).trim().to_ascii_uppercase();
1264 if name == "BEGIN" && upper == "VTIMEZONE" {
1265 in_zone = true;
1266 }
1267 if in_zone {
1268 out.push(line.clone());
1269 }
1270 if name == "END" && upper == "VTIMEZONE" {
1271 in_zone = false;
1272 }
1273 }
1274 out.push("BEGIN:VEVENT".to_string());
1275 out.extend(source[master].iter().filter(|l| kept.contains(&lines::name(l).as_str())).cloned());
1276 out.push(format!("DTSTAMP:{}", utc(&Zoned::now())));
1277 out.push(attendee);
1278 out.extend(["END:VEVENT".to_string(), "END:VCALENDAR".to_string()]);
1279 Some(lines::fold(&out))
1280}
1281
1282fn with_answer(attendee: &str, answer: &str) -> String {
1286 let at = attendee.len().checked_sub(lines::value(attendee).len() + 1).filter(|&at| attendee.as_bytes().get(at) == Some(&b':')).unwrap_or(attendee.len());
1287 let (head, value) = attendee.split_at(at);
1288 let params: Vec<&str> = head.split(';').skip(1).filter(|p| !p.to_ascii_uppercase().starts_with("PARTSTAT=") && !p.to_ascii_uppercase().starts_with("RSVP=")).collect();
1289 let mut out = String::from("ATTENDEE");
1290 for param in params {
1291 out.push(';');
1292 out.push_str(param);
1293 }
1294 out.push_str(&format!(";PARTSTAT={answer}{value}"));
1295 out
1296}
1297
1298pub fn stored_answered(text: &str, me: &str, answer: &str) -> String {
1306 let mut source = lines::unfold(&stored(text));
1307 if let Some(master) = master_range(&source) {
1308 for line in &mut source[master] {
1309 if lines::name(line) == "ATTENDEE" && !me.is_empty() && address(lines::value(line)).eq_ignore_ascii_case(me) {
1310 *line = with_answer(line, answer);
1311 }
1312 }
1313 }
1314 lines::fold(&source)
1315}
1316
1317pub fn answers(text: &str) -> BTreeMap<String, String> {
1321 let source = lines::unfold(text);
1322 let Some(master) = master_range(&source) else { return BTreeMap::new() };
1323 source[master]
1324 .iter()
1325 .filter(|l| lines::name(l) == "ATTENDEE")
1326 .map(|l| (address(lines::value(l)).to_ascii_lowercase(), lines::param(l, "PARTSTAT").map_or_else(|| "NEEDS-ACTION".to_string(), |p| p.to_ascii_uppercase())))
1327 .collect()
1328}
1329
1330pub fn stored(text: &str) -> String {
1333 let kept: Vec<String> = lines::unfold(text).into_iter().filter(|l| lines::name(l) != "METHOD").collect();
1334 lines::fold(&kept)
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use super::*;
1340
1341 #[test]
1342 fn a_link_leaves_an_event() {
1343 let text = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:e\r\nSUMMARY:Meeting\r\nLINK;LINKREL=related;VALUE=URI:sioul:task/a\r\nLINK;LINKREL=related;VALUE=URI:sioul:task/b\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:sioul:task/a\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
1344 let less = remove_lines(text, |l| l.ends_with("sioul:task/a")).unwrap();
1345 assert!(!less.contains("VALUE=URI:sioul:task/a"));
1346 assert!(less.contains("VALUE=URI:sioul:task/b"));
1347 assert!(less.contains("DESCRIPTION:sioul:task/a"), "the alarm is left alone");
1348 assert_eq!(remove_lines(text, |_| false).unwrap(), text);
1349 }
1350
1351 fn calendar() -> Collection {
1352 Collection { kind: Kind::Calendars, account: "a".into(), id: "personal".into(), dir: PathBuf::from("/tmp"), name: "Personal".into(), color: Some("#4c6b5c".into()), read_only: false, components: vec![] }
1353 }
1354
1355 #[test]
1361 fn an_event_repeating_for_years_reaches_today() {
1362 let event = |extra: &str, start: &str, end: &str, rule: &str| {
1363 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Demo//EN\r\nBEGIN:VEVENT\r\nUID:demo-repeat\r\nDTSTAMP:20190101T000000Z\r\nDTSTART;TZID=Europe/Paris:{start}\r\nDTEND;TZID=Europe/Paris:{end}\r\nRRULE:{rule}\r\nSUMMARY:Demo\r\n{extra}END:VEVENT\r\nEND:VCALENDAR\r\n")
1364 };
1365 let uncapped = |text: &str, from: i64, to: i64| -> Vec<(i64, i64)> {
1367 let ical = parse(&for_expansion(text)).unwrap();
1368 let mut out: Vec<(i64, i64)> = ical
1369 .expand_dates(calcard_zone(&paris()), 80_000)
1370 .events
1371 .into_iter()
1372 .map(|e| {
1373 let start = e.start.timestamp();
1374 let end = match e.end {
1375 TimeOrDelta::Time(end) => end.timestamp(),
1376 TimeOrDelta::Delta(delta) => start + delta.num_seconds(),
1377 };
1378 (start, end)
1379 })
1380 .filter(|(start, end)| *end > from && *start < to)
1381 .collect();
1382 out.sort_unstable();
1383 out
1384 };
1385 let read = |name: &str, text: &str, from: i64, to: i64| -> Vec<(i64, i64)> {
1386 let mut out: Vec<(i64, i64)> = file_occurrences(&write_temp(name, text), &calendar(), from, to, &paris()).into_iter().map(|o| (o.start, o.end)).collect();
1387 out.sort_unstable();
1388 out
1389 };
1390 let hourly = event("", "20190101T000000", "20190101T001500", "FREQ=HOURLY");
1392 let (monday, tuesday) = (at("2026-10-05T00:00"), at("2026-10-06T00:00"));
1393 let found = read("hourly.ics", &hourly, monday, tuesday);
1394 assert_eq!(found.len(), 24, "{found:?}");
1395 assert_eq!((found[0], found[23].0), ((monday, monday + 900), at("2026-10-05T23:00")));
1396 assert_eq!(found, uncapped(&hourly, monday, tuesday));
1397 let (sunday, after) = (at("2026-10-25T00:00"), at("2026-10-26T00:00"));
1399 assert_eq!(read("hourly.ics", &hourly, sunday, after), uncapped(&hourly, sunday, after));
1400 let changed = "EXDATE;TZID=Europe/Paris:20261007T180000\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:demo-repeat\r\nRECURRENCE-ID;TZID=Europe/Paris:20261019T180000\r\nDTSTART;TZID=Europe/Paris:20261019T200000\r\nDTEND;TZID=Europe/Paris:20261019T210000\r\nSUMMARY:Demo later\r\n";
1402 let weekly = event(changed, "20190107T180000", "20190107T190000", "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE");
1403 let (october, november) = (at("2026-10-01T00:00"), at("2026-11-01T00:00"));
1404 let found = read("weekly.ics", &weekly, october, november);
1405 assert_eq!(found, uncapped(&weekly, october, november));
1406 assert!(found.contains(&(at("2026-10-19T20:00"), at("2026-10-19T21:00"))) && !found.iter().any(|o| o.0 == at("2026-10-07T18:00")), "{found:?}");
1407 let minutes = event("", "20190101T000000", "20190101T000500", "FREQ=MINUTELY;INTERVAL=15");
1409 let found = read("minutes.ics", &minutes, monday, tuesday);
1410 assert_eq!((found.len(), found.first().map(|o| o.0)), (96, Some(monday)));
1411 let counted = event("", "20190101T000000", "20190101T001500", "FREQ=HOURLY;COUNT=20");
1413 assert!(read("counted.ics", &counted, monday, tuesday).is_empty());
1414 assert_eq!(from_window(&counted, monday), counted.as_str());
1415 let monthly = event("", "19900115T090000", "19900115T100000", "FREQ=MONTHLY");
1417 assert_eq!(from_window(&monthly, monday), monthly.as_str());
1418 assert_eq!(read("monthly.ics", &monthly, october, november).len(), 1);
1419 }
1420
1421 const WEEKLY: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\nBEGIN:VEVENT\r\nUID:yoga-1\r\nDTSTAMP:20260101T000000Z\r\n\
1422 DTSTART;TZID=Europe/Paris:20260105T180000\r\nDTEND;TZID=Europe/Paris:20260105T193000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
1423 EXDATE;TZID=Europe/Paris:20261012T180000\r\nSUMMARY:Yoga\r\nLOCATION:Salle des fêtes\r\nX-APP-THING:keep\r\n\
1424 BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Yoga\r\nEND:VALARM\r\nEND:VEVENT\r\n\
1425 BEGIN:VEVENT\r\nUID:yoga-1\r\nRECURRENCE-ID;TZID=Europe/Paris:20261019T180000\r\nDTSTART;TZID=Europe/Paris:20261019T190000\r\n\
1426 DTEND;TZID=Europe/Paris:20261019T203000\r\nSUMMARY:Yoga (later)\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
1427
1428 fn write_temp(name: &str, text: &str) -> PathBuf {
1429 let dir = std::env::temp_dir().join(format!("sioul-agenda-{}", std::process::id()));
1430 std::fs::create_dir_all(&dir).unwrap();
1431 let path = dir.join(name);
1432 std::fs::write(&path, text).unwrap();
1433 path
1434 }
1435
1436 fn paris() -> TimeZone {
1437 TimeZone::get("Europe/Paris").unwrap()
1438 }
1439
1440 fn at(text: &str) -> i64 {
1441 let local: DateTime = text.parse().unwrap();
1442 local.to_zoned(TimeZone::get("Europe/Paris").unwrap()).unwrap().timestamp().as_second()
1443 }
1444
1445 #[test]
1446 fn repeats_with_exceptions_and_changes() {
1447 let path = write_temp("yoga.ics", WEEKLY);
1448 let found = file_occurrences(&path, &calendar(), at("2026-10-01T00:00"), at("2026-10-27T00:00"), &paris());
1449 let starts: Vec<(i64, &str)> = found.iter().map(|o| (o.start, o.summary.as_str())).collect();
1451 assert_eq!(starts, vec![(at("2026-10-05T18:00"), "Yoga"), (at("2026-10-19T19:00"), "Yoga (later)"), (at("2026-10-26T18:00"), "Yoga")]);
1452 assert!(found[0].recurring && found[0].location == "Salle des fêtes" && found[0].end - found[0].start == 5400);
1453 }
1454
1455 #[test]
1456 fn a_new_title_leaves_the_rule_and_the_zone_alone() {
1457 let mut edit = edit_of_text(WEEKLY, &paris()).unwrap();
1458 assert_eq!((edit.start.as_str(), edit.repeat.as_str()), ("2026-01-05T18:00", "weekly"));
1459 edit.title = "Yoga with Marie".into();
1460 let text = apply(WEEKLY, &edit, &paris()).unwrap();
1461 assert!(text.contains("RRULE:FREQ=WEEKLY;BYDAY=MO") && text.contains("DTSTART;TZID=Europe/Paris:20260105T180000"), "{text}");
1462 assert!(text.contains("SUMMARY:Yoga with Marie"), "{text}");
1463 let skipped = skip_occurrence(WEEKLY, at("2026-01-05T18:00")).unwrap();
1465 assert_eq!(edit_of_text(&skipped, &paris()).map(|e| e.start), Some("2026-01-12T18:00".to_string()));
1466 }
1467
1468 #[test]
1469 fn edits_keep_alarms_overrides_and_unknown_lines() {
1470 let edit = EventEdit { title: "Yoga, new room".into(), location: "Gymnase".into(), start: "2026-01-05T18:30".into(), end: "2026-01-05T20:00".into(), repeat: "weekly".into(), ..EventEdit::default() };
1471 let text = apply(WEEKLY, &edit, &paris()).unwrap();
1472 assert!(text.contains("SUMMARY:Yoga\\, new room") && text.contains("LOCATION:Gymnase") && text.contains("SEQUENCE:1"), "{text}");
1473 assert!(text.contains("X-APP-THING:keep") && text.contains("TRIGGER:-PT15M") && text.contains("SUMMARY:Yoga (later)"), "{text}");
1474 assert!(text.contains("DTSTART;TZID=Europe/Paris:20260105T183000") && text.contains("RRULE:FREQ=WEEKLY;BYDAY=MO"), "{text}");
1476 assert!(text.contains("EXDATE;TZID=Europe/Paris:20261012T180000"), "{text}");
1477 assert!(parse(&text).is_some());
1478 let skipped = skip_occurrence(WEEKLY, at("2026-10-05T18:00")).unwrap();
1479 assert!(skipped.contains("EXDATE;TZID=Europe/Paris:20261005T180000"), "{skipped}");
1480 }
1481
1482 #[test]
1483 fn new_events_and_answers() {
1484 let one = new_event(&EventEdit { title: "Dentist".into(), start: "2026-10-07T09:00".into(), end: "2026-10-07T09:30".into(), ..EventEdit::default() }, &paris()).unwrap();
1485 assert!(one.contains("SUMMARY:Dentist") && one.contains("DTSTART:20261007T070000Z") && parse(&one).is_some(), "{one}");
1486 let weekly = new_event(&EventEdit { title: "Choir".into(), start: "2026-10-07T20:00".into(), end: "2026-10-07T22:00".into(), repeat: "weekly".into(), ..EventEdit::default() }, &paris()).unwrap();
1487 assert!(weekly.contains("DTSTART;TZID=Europe/Paris:20261007T200000") && weekly.contains("BEGIN:VTIMEZONE") && weekly.contains("RRULE:FREQ=WEEKLY"), "{weekly}");
1488 let day = new_event(&EventEdit { title: "Holidays".into(), start: "2026-12-24".into(), end: "2026-12-26".into(), all_day: true, ..EventEdit::default() }, &paris()).unwrap();
1489 assert!(day.contains("DTSTART;VALUE=DATE:20261224") && day.contains("DTEND;VALUE=DATE:20261227"), "{day}");
1490 let invite = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nMETHOD:REQUEST\r\nBEGIN:VEVENT\r\nUID:meet-1\r\nSEQUENCE:0\r\nDTSTAMP:20261001T000000Z\r\n\
1491 DTSTART:20261008T080000Z\r\nDTEND:20261008T090000Z\r\nSUMMARY:Review\r\nORGANIZER;CN=Jane:mailto:jane@example.org\r\n\
1492 ATTENDEE;CN=Me;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:me@example.net\r\nATTENDEE;CN=Paul:mailto:paul@example.org\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
1493 let asked = invitation(invite, &paris()).unwrap();
1494 assert_eq!((asked.method.as_str(), asked.summary.as_str(), asked.organizer.as_str()), ("REQUEST", "Review", "Jane"));
1495 assert_eq!(asked.end - asked.start, 3600);
1496 let answer = reply(invite, "me@example.net", "ACCEPTED").unwrap();
1497 assert!(answer.contains("METHOD:REPLY") && answer.contains("ATTENDEE;CN=Me;PARTSTAT=ACCEPTED:mailto:me@example.net"), "{answer}");
1498 assert!(!answer.contains("paul@example.org") && answer.contains("ORGANIZER;CN=Jane:mailto:jane@example.org"), "{answer}");
1499 assert!(!stored(invite).contains("METHOD"));
1500 let kept = stored_answered(invite, "Me@Example.net", "TENTATIVE");
1502 assert!(!kept.contains("METHOD") && kept.contains("ATTENDEE;CN=Me;PARTSTAT=TENTATIVE:mailto:me@example.net"), "{kept}");
1503 assert!(kept.contains("ATTENDEE;CN=Paul:mailto:paul@example.org"), "{kept}");
1504 let said = answers(&kept);
1505 assert_eq!((said["me@example.net"].as_str(), said["paul@example.org"].as_str()), ("TENTATIVE", "NEEDS-ACTION"));
1506 assert_ne!(answers(&stored_answered(invite, "me@example.net", "ACCEPTED")), said);
1507 assert_eq!(stored_answered(invite, "someone@example.com", "ACCEPTED"), stored(invite), "not among the guests: as sent");
1508 }
1509
1510 #[test]
1511 fn the_zone_repeats_its_changes_of_hour() {
1512 let zone = TimeZone::get("Europe/Paris").unwrap();
1513 let text = vtimezone(&zone, "Europe/Paris", 2026).join("\n");
1514 assert!(text.contains("BEGIN:DAYLIGHT\nDTSTART:20260329T020000\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0200"), "{text}");
1515 assert!(text.contains("BEGIN:STANDARD\nDTSTART:20261025T030000\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\nTZOFFSETFROM:+0200\nTZOFFSETTO:+0100"), "{text}");
1516 }
1517
1518 fn october(name: &str, text: &str) -> Vec<(i64, i64, String)> {
1520 let path = write_temp(name, text);
1521 file_occurrences(&path, &calendar(), at("2026-10-01T00:00"), at("2026-10-27T00:00"), &paris()).into_iter().map(|o| (o.start, o.end, o.summary)).collect()
1522 }
1523
1524 #[test]
1525 fn a_single_event_moved_or_stretched() {
1526 let one = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:d-1\r\nDTSTAMP:20261001T000000Z\r\nDTSTART:20261007T070000Z\r\n\
1527 DTEND:20261007T073000Z\r\nSUMMARY:Dentist\r\nX-APP-THING:keep\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
1528 let text = moved(one, at("2026-10-07T09:00"), at("2026-10-07T10:15"), at("2026-10-07T10:45"), false, &paris()).unwrap();
1530 assert!(text.contains("DTSTART:20261007T081500Z") && text.contains("DTEND:20261007T084500Z") && text.contains("SEQUENCE:1"), "{text}");
1531 assert!(text.contains("X-APP-THING:keep") && text.contains("SUMMARY:Dentist"), "{text}");
1532 let text = moved(&text, at("2026-10-07T10:15"), at("2026-10-07T10:15"), at("2026-10-07T11:00"), true, &paris()).unwrap();
1533 assert!(text.contains("DTEND:20261007T090000Z") && !text.contains("RECURRENCE-ID"), "{text}");
1534 assert_eq!(moved(one, at("2026-10-07T08:00"), at("2026-10-07T10:00"), at("2026-10-07T11:00"), false, &paris()), Err(MoveProblem::Gone));
1536 }
1537
1538 #[test]
1539 fn one_time_of_a_repeating_event_moved() {
1540 let text = moved(WEEKLY, at("2026-10-05T18:00"), at("2026-10-06T19:00"), at("2026-10-06T20:30"), true, &paris()).unwrap();
1542 assert!(text.contains("RECURRENCE-ID;TZID=Europe/Paris:20261005T180000") && text.contains("DTSTART;TZID=Europe/Paris:20261006T190000"), "{text}");
1543 assert!(text.contains("RRULE:FREQ=WEEKLY;BYDAY=MO") && text.contains("DTSTART;TZID=Europe/Paris:20260105T180000"), "the series as it was: {text}");
1544 let yoga = |at_: &str, until: &str, title: &str| (at(at_), at(until), title.to_string());
1545 assert_eq!(october("moved-once.ics", &text), vec![yoga("2026-10-06T19:00", "2026-10-06T20:30", "Yoga"), yoga("2026-10-19T19:00", "2026-10-19T20:30", "Yoga (later)"), yoga("2026-10-26T18:00", "2026-10-26T19:30", "Yoga")]);
1546 assert_eq!((text.matches("TRIGGER:-PT15M").count(), text.matches("X-APP-THING:keep").count()), (2, 2), "{text}");
1548 assert!(parse(&text).is_some());
1549 let again = moved(&text, at("2026-10-06T19:00"), at("2026-10-06T20:00"), at("2026-10-06T21:00"), true, &paris()).unwrap();
1551 assert_eq!(again.matches("RECURRENCE-ID").count(), 2, "the 5th's and the 19th's: {again}");
1552 assert!(again.contains("DTSTART;TZID=Europe/Paris:20261006T200000") && !again.contains("20261006T190000"), "{again}");
1553 let skipped = skip_occurrence(&again, at("2026-10-06T20:00")).unwrap();
1555 assert!(skipped.contains("EXDATE;TZID=Europe/Paris:20261005T180000") && skipped.matches("RECURRENCE-ID").count() == 1, "{skipped}");
1556 assert_eq!(october("moved-skipped.ics", &skipped).first().map(|o| o.0), Some(at("2026-10-19T19:00")));
1557 }
1558
1559 #[test]
1560 fn every_time_of_a_repeating_event_moved() {
1561 let text = moved(WEEKLY, at("2026-10-05T18:00"), at("2026-10-05T18:30"), at("2026-10-05T20:00"), false, &paris()).unwrap();
1563 assert!(text.contains("DTSTART;TZID=Europe/Paris:20260105T183000") && text.contains("EXDATE;TZID=Europe/Paris:20261012T183000"), "{text}");
1564 assert!(text.contains("RECURRENCE-ID;TZID=Europe/Paris:20261019T183000") && text.contains("RRULE:FREQ=WEEKLY;BYDAY=MO"), "{text}");
1565 let starts = |name: &str, text: &str| october(name, text).into_iter().map(|o| (o.0, o.2)).collect::<Vec<_>>();
1566 assert_eq!(starts("moved-all.ics", &text), vec![(at("2026-10-05T18:30"), "Yoga".into()), (at("2026-10-19T19:00"), "Yoga (later)".into()), (at("2026-10-26T18:30"), "Yoga".into())]);
1567 let text = moved(WEEKLY, at("2026-10-19T19:00"), at("2026-10-19T19:30"), at("2026-10-19T21:00"), false, &paris()).unwrap();
1569 assert_eq!(starts("moved-all-changed.ics", &text), vec![(at("2026-10-05T18:30"), "Yoga".into()), (at("2026-10-19T19:30"), "Yoga (later)".into()), (at("2026-10-26T18:30"), "Yoga".into())]);
1570 let text = moved(WEEKLY, at("2026-10-05T18:00"), at("2026-10-06T18:00"), at("2026-10-06T19:30"), false, &paris()).unwrap();
1572 assert!(text.contains("RRULE:FREQ=WEEKLY;BYDAY=TU") && text.contains("DTSTART;TZID=Europe/Paris:20260106T180000"), "{text}");
1573 assert_eq!(starts("moved-tuesdays.ics", &text).first().map(|o| o.0), Some(at("2026-10-06T18:00")));
1574 let monthly = WEEKLY.replace("RRULE:FREQ=WEEKLY;BYDAY=MO", "RRULE:FREQ=MONTHLY;BYDAY=1MO");
1576 assert_eq!(moved(&monthly, at("2026-10-05T18:00"), at("2026-10-06T18:00"), at("2026-10-06T19:30"), false, &paris()), Err(MoveProblem::SetDays));
1577 assert!(moved(&monthly, at("2026-10-05T18:00"), at("2026-10-05T19:00"), at("2026-10-05T20:30"), false, &paris()).is_ok(), "another time the same day");
1578 }
1579}