Skip to main content

sioul_core/
agenda.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Calendars: events from iCalendar files (RFC 5545), one event and its
5//! exceptions per file as CalDAV keeps them (RFC 4791 §4.1).
6//!
7//! What comes is computed for a stretch of days: repeating events are
8//! expanded (RRULE, RDATE, EXDATE and changed occurrences, calcard's
9//! `expand_dates`), each in its own time zone, then shown in yours. Nothing is
10//! "late" or "overdue": the past is simply not shown (docs/client.md, rule 9).
11//!
12//! Events are changed line by line (`lines`): what Sioul does not edit (alarms,
13//! guests, another application's fields) is kept exactly. New events are
14//! written in UTC; a repeating one keeps its local time across the change of
15//! hour with its time zone, written out in a VTIMEZONE.
16
17use 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
29/// Occurrences of a repeating event looked at, at most, per file: a safety
30/// net. Expansion starts just before the stretch asked for (`from_window`),
31/// so an event repeating every hour for years still reaches today.
32const EXPANSION_LIMIT: usize = 20_000;
33
34/// Occurrences looked at to find an event's first one still there: occurrences
35/// left out (EXDATE) count against the limit, so one would find none when the
36/// first was removed, and the event could no longer be opened.
37const FIRST_LOOKED_AT: usize = 100;
38
39/// One occurrence of an event, as the agenda shows it.
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
41pub struct Occurrence {
42    /// The file, to open and change the event.
43    pub key: String,
44    pub uid: String,
45    pub summary: String,
46    pub location: String,
47    pub notes: String,
48    /// Unix seconds; an all-day event starts at midnight where you are.
49    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    /// TRANSP:TRANSPARENT: shown as any event, but holds no time (a reminder
58    /// of a holiday, "working from home"): the plan lays tasks over it (`holds_time`).
59    pub transparent: bool,
60    /// You declined it: the guest that is you (one of your accounts' addresses)
61    /// answered PARTSTAT=DECLINED (`occurrences`, `mark_declined`). Shown as
62    /// before; holds no time (`holds_time`).
63    pub declined: bool,
64    pub organizer: String,
65    pub attendees: Vec<Attendee>,
66    pub read_only: bool,
67    /// Its alarms (VALARM), in seconds from its start: -900 for a quarter of an hour before.
68    pub alarms: Vec<i64>,
69    /// Minutes kept before and after it: getting there and back (`demands`).
70    pub margins: crate::demands::Margins,
71    /// What it costs and gives back, rated 0 to 10 (`demands`).
72    pub demands: crate::demands::Demands,
73    /// The task it is the time block of (`X-SIOUL-TASK`, `blocks`), by UID; "" for an event of its own.
74    pub task: String,
75    /// The turn of a repeating task that block pins (`X-SIOUL-TURN`): "2026-10-09"; "" otherwise.
76    pub turn: String,
77    /// Its reminder before, as it says it (`reminders::REMIND`): "" as usual,
78    /// "NONE", "15"; a changed occurrence's own, else its series'.
79    pub remind: String,
80}
81
82/// Someone invited, and what they answered.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84pub struct Attendee {
85    pub name: String,
86    pub address: String,
87    /// "accepted", "declined", "tentative", "needs-action".
88    pub answer: String,
89}
90
91impl Occurrence {
92    /// Whether it takes time from the plan, your hours and your meals
93    /// (`plan::event_spans`, `capacity`): not cancelled, not transparent, not
94    /// declined by you. Whole days are left out where times are counted.
95    pub fn holds_time(&self) -> bool {
96        !self.cancelled && !self.transparent && !self.declined
97    }
98}
99
100/// Each event `own` (your addresses) declined marked so (`Occurrence::declined`).
101pub 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
107/// Parses an iCalendar object.
108pub fn parse(text: &str) -> Option<ICalendar> {
109    match Parser::new(text).entry() {
110        Entry::ICalendar(calendar) => Some(calendar),
111        _ => None,
112    }
113}
114
115/// A zone as calcard knows it: times written without a zone are taken in it.
116fn 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
124/// Its margins, costs and gain, from Sioul's own properties (`demands`).
125fn 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
137/// A date without a time: the event lasts whole days.
138fn 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
145/// "mailto:jane@example.org" → "jane@example.org".
146fn 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
173/// An event's alarms, in seconds from its start (`length` turns those set from
174/// its end); one set at a fixed time counts only for an event that does not repeat.
175fn 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
198/// The text as calcard should expand it. calcard puts a changed occurrence
199/// (RECURRENCE-ID) in place of its time in the series only when both have the
200/// same SEQUENCE, where RFC 5545 matches them by RECURRENCE-ID alone: a series
201/// edited since, or an occurrence changed by an application that counts its own
202/// changes, would show twice, at its old time and its new one. So each changed
203/// occurrence is read with the series' SEQUENCE; the file is left as it is.
204fn 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
237/// The text as calcard should expand it for occurrences from `from` (Unix
238/// seconds) on. calcard expands a repeating event from its start, at most
239/// `EXPANSION_LIMIT` occurrences, so an event repeating every hour begun years
240/// ago would never reach today. Its start (and its end) moved forward by whole
241/// periods of its rule, to a couple of days and a period before `from`, give
242/// the same occurrences from there on: done for rules repeating by the second,
243/// minute, hour, day or week (their periods are all as long; a month or a year
244/// reaches far enough from the start), without COUNT (counted from the true
245/// start), and without a change to "this and those after" (RANGE, whose
246/// offset starts at its own occurrence). A start without a time, or one that
247/// does not read, is left as it is. The file is left as it is.
248fn 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    // The master's own lines, its alarms left out.
259    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    // A time as written, on its own clock (TZID, UTC or none): moved as calcard counts, on that clock.
286    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    // `from` on any clock: within fourteen hours of UTC; two days spare.
295    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
319/// The occurrences of one file's events between `from` and `to` (Unix seconds);
320/// times written without a zone are taken in `zone`, yours. Expanded from just
321/// before `from` (`from_window`), not from each event's start.
322pub 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        // An instant event still takes its minute; an all-day one its day.
339        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
376/// Everything between `from` and `to`, from every calendar, by start; those
377/// you declined marked (`declined`), by your accounts' addresses.
378pub 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/// What the event form gives back. Times are local, in your time zone.
393#[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    /// "2026-10-05T09:00", or "2026-10-05" for a whole day.
401    pub start: String,
402    /// The same; for whole days, the last day it covers.
403    pub end: String,
404    #[serde(default)]
405    pub all_day: bool,
406    /// "", "daily", "weekly", "monthly", "yearly".
407    #[serde(default)]
408    pub repeat: String,
409    /// Minutes kept before and after it.
410    #[serde(default)]
411    pub margins: crate::demands::Margins,
412    /// What it costs and gives back, 0 to 10 each; none unsaid.
413    #[serde(default)]
414    pub demands: crate::demands::Demands,
415    /// Its reminder before: "" as usual, "none" not this one, "15" minutes before (`reminders::REMIND`).
416    #[serde(default)]
417    pub remind: String,
418}
419
420/// An event's reminder before (`reminders::REMIND`), as the form says it:
421/// "" as usual, "none", or minutes ("15").
422fn 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
430/// What an occurrence says of its reminder: its own, else, a changed one
431/// (RECURRENCE-ID) saying nothing, its series'.
432fn 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
445/// An event as the form shows it, from its file, in your time zone.
446pub fn edit_of(path: &Path) -> Option<EventEdit> {
447    edit_of_text(&std::fs::read_to_string(path).ok()?, &TimeZone::system())
448}
449
450/// An event as the form shows it, from its text, in `zone`.
451pub 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    // Its first occurrence still there: the very first may have been left out (EXDATE) or changed.
456    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        // A whole-day event ends the morning after its last day.
481        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
490/// The main event's first occurrence still there, as the agenda shows it:
491/// (start, end) in Unix seconds, to the second; None when the text holds no
492/// event. Times without a zone are in `zone`.
493pub 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
505/// The start and end the form means, in your time zone.
506fn 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    // An end before the start means an hour.
521    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
529/// DTSTART and DTEND lines for the form's times: dates for whole days; UTC for
530/// one-off events; local time with the zone for repeating ones.
531fn 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
552/// The VTIMEZONE of a zone, as its changes of hour go this year: one STANDARD
553/// and one DAYLIGHT part, each repeating yearly on the same weekday of the
554/// same month ("last Sunday of October"); a single part when it has no summer time.
555pub 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            // The local time of the change, as clocks showed it just before.
572            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
592/// A new event from the form, its times in `zone` (yours), with its own UID.
593pub 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/// What the form changed, field by field: a field unchanged keeps its line.
608#[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
623/// SUMMARY, LOCATION, DESCRIPTION and the repeat rule, those that changed.
624fn 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
662/// Lines of the main event (not a changed occurrence) rewritten at every change.
663const EDITED: &[&str] = &["DTSTAMP", "LAST-MODIFIED", "SEQUENCE"];
664/// Rewritten only when the form changed the time: an event written in another
665/// zone keeps it, and so does its time.
666const TIMES: &[&str] = &["DTSTART", "DTEND", "DURATION"];
667
668/// The event's text with what the form edits changed in its main VEVENT;
669/// the rest, changed occurrences and alarms included, kept as it was. The
670/// times and the repeat rule are written again only when they changed: a rule
671/// the form cannot show ("every other Monday and Wednesday") stays whole while
672/// the title changes. The SEQUENCE goes up, as RFC 5545 §3.8.7.4 asks.
673pub 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    // Nothing changed: nothing is written, not even the stamps.
677    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        // Inside a VEVENT: its alarms are nested components, kept as they are.
723        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    // A repeating event in your zone needs the zone written once.
755    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
763/// Leaves one occurrence of a repeating event out (EXDATE), in the form DTSTART is written.
764/// One changed already (RECURRENCE-ID: moved by a drag in the agenda, or by another
765/// application) goes with its change: its own VEVENT is taken out, and the time it
766/// replaced in the series is left out.
767pub 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        // Its parameters but RANGE, which an EXDATE has not.
774        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
789/// Which occurrence of a file's event starts at `start` (Unix seconds), as the
790/// agenda shows it: the place of the VEVENT holding it among the text's
791/// VEVENTs (calcard keeps them in the text's order), and whether that one is a
792/// changed occurrence (RECURRENCE-ID). Times without a zone are in `zone`.
793fn 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
801/// The text's VEVENTs, BEGIN to END, in order (their alarms inside them).
802fn 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
824/// A line's name and parameters, as written: "DTSTART;TZID=Europe/Paris".
825fn head_of(line: &str) -> &str {
826    line.strip_suffix(lines::value(line)).and_then(|h| h.strip_suffix(':')).unwrap_or(line)
827}
828
829/// `at` as `like` writes its time (a DTSTART line): a date, UTC, or the clock
830/// in its TZID; a time without a zone, on the clock in `zone`.
831fn 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
843/// A line `name` for `at`, with `like`'s parameters, written as `like` writes its time.
844fn 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
850/// The moment one value of `line` means (a DATE-TIME list's item): in its
851/// TZID, UTC with a Z, a date at midnight; a time without a zone, in `zone`.
852fn 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
864/// A VEVENT's lines with its own times set: `from` to `to`, written as its
865/// DTSTART was (`like`), DURATION replaced by DTEND; its stamps written again.
866/// Its SEQUENCE goes one up (RFC 5545 §3.8.7.4), unless `recurrence` makes it a
867/// new changed occurrence of the series: then it keeps the series' own, as
868/// readers that match the two by it want (calcard). Its alarms are kept as
869/// they are; `drop` names the other lines left out (a new changed occurrence
870/// leaves the rule).
871fn 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    // Before its alarms, else before its end.
898    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
903/// A repeating event's rule, its occurrences moved by `span` on the clock: a
904/// weekly rule's days (BYDAY=MO,WE) move with them, and its end (UNTIL) by as
905/// much. None when the move would not keep what the rule says: days set
906/// otherwise (a day of the month, "the first Monday") moved to another day,
907/// or hours set in the rule (BYHOUR) moved to another time.
908fn 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/// Why an event stayed where it was (`moved`).
939#[derive(Debug, Clone, PartialEq, Eq)]
940pub enum MoveProblem {
941    /// No occurrence starts at that time any more: changed meanwhile, here or elsewhere.
942    Gone,
943    /// Every time, to other days, for a rule that sets its days other than by
944    /// the week ("the first Monday", "the 5th"): its form says how it repeats.
945    SetDays,
946    /// The file or a time does not read.
947    Unreadable(String),
948}
949
950/// The event moved as a drag in the agenda asks: its occurrence starting at
951/// `start` (Unix seconds, as shown) now from `new_start` to `new_end`. A single
952/// event takes the new times. A repeating one moves that time alone
953/// (`only_this`: a changed occurrence, RECURRENCE-ID, RFC 5545 §3.8.4.4; one
954/// changed already is changed again), or every time by as much on the clock:
955/// its first time, the times left out (EXDATE) and added (RDATE), and the
956/// times its changed occurrences replace go together, a weekly rule's days
957/// with them; the changed occurrences keep their own times, but the one moved.
958/// What Sioul does not edit is kept (`apply`); times without a zone are in `zone`.
959pub 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    // That time alone: its changed occurrence, made or changed again.
971    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    // Every time: by as much on the clock as the one dragged (days, then hours).
994    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    // A rule that would not keep what it says stops here: nothing is written.
998    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    // Its first time and its length, as the form writes them; then, around them,
1007    // the rule's days, the times left out and added, the times changed occurrences replace.
1008    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    // The one dragged, changed already: its own times too.
1041    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
1050/// The event with `added` lines in its main VEVENT (a LINK, a REFID), those
1051/// not there yet, before its alarms; None when it holds no event. Nothing
1052/// else changes: the stamps stay, as a link is not a change of the event.
1053pub 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
1065/// The main VEVENT without the lines `drop` picks (its alarms untouched);
1066/// None when the text holds no event.
1067pub 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
1096/// The lines of the main VEVENT, BEGIN to END.
1097fn 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/// What an event file ties to: its links (LINK, RELATED-TO), its projects
1125/// (REFID) and its guests, read from its main VEVENT.
1126#[derive(Debug, Clone, Default, PartialEq, Eq)]
1127pub struct EventRef {
1128    pub key: String,
1129    pub uid: String,
1130    pub summary: String,
1131    /// Its first start, Unix seconds; 0 when unreadable.
1132    pub start: i64,
1133    pub links: Vec<crate::tasks::Link>,
1134    pub related: Vec<crate::tasks::Relation>,
1135    pub projects: Vec<String>,
1136    /// The guests' and organizer's addresses.
1137    pub people: Vec<String>,
1138}
1139
1140/// The ties of one event's text; None when it holds no event.
1141pub 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
1168/// The ties of every event, in every calendar.
1169pub 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
1181/// Where a new event goes: the first calendar that can be written to and takes
1182/// events, the one made for time blocks last (`blocks::CALENDAR`).
1183pub 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
1188/// The file a new event gets in a calendar.
1189pub fn new_path(calendar: &Collection) -> PathBuf {
1190    calendar.dir.join(format!("{}.ics", vdir::new_name()))
1191}
1192
1193/// An invitation (iTIP, RFC 5546) carried by a message.
1194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1195pub struct Invitation {
1196    /// "REQUEST", "CANCEL", "REPLY", "PUBLISH"…
1197    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
1209/// What an invitation asks, from the text of its `text/calendar` part; times
1210/// without a zone are taken in `zone`, yours.
1211pub 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
1248/// Your answer to an invitation (iTIP REPLY, RFC 5546 §3.2.3): the event with
1249/// you alone among the guests, your answer in PARTSTAT. `answer`: "ACCEPTED",
1250/// "DECLINED" or "TENTATIVE".
1251pub 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    // The zones the event's times name.
1260    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
1282/// A guest's line with `answer` in its PARTSTAT, RSVP dropped: an answer
1283/// asks for none. A line without its colon (a broken file) has no value: all
1284/// of it is the head.
1285fn 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
1298/// An invitation's event as your calendar keeps it once you answered: as the
1299/// organizer sent it (`stored`), your own line among the guests (`me`, by
1300/// address) saying your answer ("ACCEPTED", "TENTATIVE"), as an attendee's
1301/// copy says it (RFC 5546 §3.2.3). Your other devices, and any calendar
1302/// reading the server, then know what you answered; two devices answering
1303/// the same invitation can tell whether they said the same (`answers`).
1304/// Unchanged but for METHOD when you are not among the guests by that address.
1305pub 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
1317/// Each guest's answer in an event, by address (lower case): its PARTSTAT,
1318/// "NEEDS-ACTION" when it says none; the event's own guests, not those of a
1319/// changed occurrence. Empty for what has no guests (a contact, a task).
1320pub 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
1330/// Adds an invitation's event to a calendar file, as the organizer sent it,
1331/// METHOD removed (RFC 4791 §4.1 forbids it in stored objects).
1332pub 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    /// A repeating event begun years ago reaches the stretch asked for: it is
1356    /// expanded from just before it (`from_window`), with the same occurrences
1357    /// there as an expansion from its true start without a cap would give:
1358    /// every hour, across the change of hour, every other week on two days,
1359    /// a day left out and a changed occurrence; a counted rule as it was.
1360    #[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        // As calcard gives them from the true start, with no cap that matters here.
1366        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        // Every hour since 1 January 2019: 24 on Monday 5 October 2026 (68,000 hours on).
1391        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        // The night the clocks go back, 25 hours: as calcard counts them from the start.
1398        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        // Every other week, Monday and Wednesday at 18:00, since 2019; one left out, one moved.
1401        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        // Every quarter of an hour: the cap alone would stop in 2019.
1408        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        // Counted from its start: left as it was (all its 20 are long over).
1412        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        // A rule by the month reaches far enough from its start: left as it was.
1416        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        // 5, 12 (left out), 19 (moved to 19:00), 26.
1450        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        // Its first occurrence left out: still opened, from the next one.
1464        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        // New times, the same rule: it was still "weekly".
1475        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        // Kept answered: your line says it, Paul's as it was; the guests' answers read back.
1501        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    /// Each occurrence between 1 and 27 October as (start, end, title).
1519    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        // Dragged from 9:00 to 10:15, its half hour kept; then its end to 11:00.
1529        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        // Changed meanwhile: no occurrence starts at 8:00 any more.
1535        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        // Monday 5 October's yoga to Tuesday 6 at 19:00, still an hour and a half; the others stay.
1541        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        // Its alarm, and what Sioul does not know, go with it.
1547        assert_eq!((text.matches("TRIGGER:-PT15M").count(), text.matches("X-APP-THING:keep").count()), (2, 2), "{text}");
1548        assert!(parse(&text).is_some());
1549        // Moved again: the same changed occurrence, not a second one.
1550        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        // Left out: the changed occurrence goes, and the time it replaced is left out of the series.
1554        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        // Half an hour later every time: its first time, the time left out and the time the changed one replaces go along.
1562        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        // The one changed already, dragged: every time half an hour later, and it where it was dropped.
1568        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        // To Tuesdays: a weekly rule's day goes along.
1571        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        // The first Monday of the month, to another day: its form says how it repeats.
1575        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}