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