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    /// That task's list, by name, once it is known here (`mark_tasks`): what
76    /// every agenda says of it, the window's, the phone's and the home card.
77    pub list: String,
78    /// The turn of a repeating task that block pins (`X-SIOUL-TURN`): "2026-10-09"; "" otherwise.
79    pub turn: String,
80    /// Its reminder before, as it says it (`reminders::REMIND`): "" as usual,
81    /// "NONE", "15"; a changed occurrence's own, else its series'.
82    pub remind: String,
83}
84
85/// Someone invited, and what they answered.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct Attendee {
88    pub name: String,
89    pub address: String,
90    /// "accepted", "declined", "tentative", "needs-action".
91    pub answer: String,
92}
93
94impl Occurrence {
95    /// Whether it takes time from the plan, your hours and your meals
96    /// (`plan::event_spans`, `capacity`): not cancelled, not transparent, not
97    /// declined by you. Whole days are left out where times are counted.
98    pub fn holds_time(&self) -> bool {
99        !self.cancelled && !self.transparent && !self.declined
100    }
101}
102
103/// Each event `own` (your addresses) declined marked so (`Occurrence::declined`).
104pub 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
110/// Parses an iCalendar object.
111pub fn parse(text: &str) -> Option<ICalendar> {
112    match Parser::new(text).entry() {
113        Entry::ICalendar(calendar) => Some(calendar),
114        _ => None,
115    }
116}
117
118/// A zone as calcard knows it: times written without a zone are taken in it.
119fn 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
127/// Its margins, costs and gain, from Sioul's own properties (`demands`).
128fn 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
140/// A date without a time: the event lasts whole days.
141fn 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
148/// "mailto:jane@example.org" → "jane@example.org".
149fn 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
176/// An event's alarms, in seconds from its start (`length` turns those set from
177/// its end); one set at a fixed time counts only for an event that does not repeat.
178fn 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
201/// The text as calcard should expand it. calcard puts a changed occurrence
202/// (RECURRENCE-ID) in place of its time in the series only when both have the
203/// same SEQUENCE, where RFC 5545 matches them by RECURRENCE-ID alone: a series
204/// edited since, or an occurrence changed by an application that counts its own
205/// changes, would show twice, at its old time and its new one. So each changed
206/// occurrence is read with the series' SEQUENCE; the file is left as it is.
207fn 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
240/// The text as calcard should expand it for occurrences from `from` (Unix
241/// seconds) on. calcard expands a repeating event from its start, at most
242/// `EXPANSION_LIMIT` occurrences, so an event repeating every hour begun years
243/// ago would never reach today. Its start (and its end) moved forward by whole
244/// periods of its rule, to a couple of days and a period before `from`, give
245/// the same occurrences from there on: done for rules repeating by the second,
246/// minute, hour, day or week (their periods are all as long; a month or a year
247/// reaches far enough from the start), without COUNT (counted from the true
248/// start), and without a change to "this and those after" (RANGE, whose
249/// offset starts at its own occurrence). A start without a time, or one that
250/// does not read, is left as it is. The file is left as it is.
251fn 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    // The master's own lines, its alarms left out.
262    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    // A time as written, on its own clock (TZID, UTC or none): moved as calcard counts, on that clock.
289    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    // `from` on any clock: within fourteen hours of UTC; two days spare.
298    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
322/// The occurrences of one file's events between `from` and `to` (Unix seconds);
323/// times written without a zone are taken in `zone`, yours. Expanded from just
324/// before `from` (`from_window`), not from each event's start.
325pub 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        // An instant event still takes its minute; an all-day one its day.
342        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
380/// Everything between `from` and `to`, from every calendar, by start; those
381/// you declined marked (`declined`), by your accounts' addresses.
382pub fn occurrences(from: i64, to: i64) -> Vec<Occurrence> {
383    let zone = TimeZone::system();
384    // The calendars that take events (a task list's tasks are the task views'; its tasks show here by their time blocks).
385    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
398/// Time blocks shown as the tasks they are, in every agenda alike (the
399/// window's, the phone's and the home card, 21 October 2026): a block of a
400/// task known here says its task's list; one whose task is not here (deleted,
401/// its list off) is an event of its own, as the plan reads it
402/// (`blocks::without_blocks`). Tasks themselves are never an agenda's: one
403/// shows only by its time block, an event that travels as events do.
404pub 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/// What the event form gives back. Times are local, in your time zone.
418#[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    /// "2026-10-05T09:00", or "2026-10-05" for a whole day.
426    pub start: String,
427    /// The same; for whole days, the last day it covers.
428    pub end: String,
429    #[serde(default)]
430    pub all_day: bool,
431    /// "", "daily", "weekly", "monthly", "yearly".
432    #[serde(default)]
433    pub repeat: String,
434    /// Minutes kept before and after it.
435    #[serde(default)]
436    pub margins: crate::demands::Margins,
437    /// What it costs and gives back, 0 to 10 each; none unsaid.
438    #[serde(default)]
439    pub demands: crate::demands::Demands,
440    /// Its reminder before: "" as usual, "none" not this one, "15" minutes before (`reminders::REMIND`).
441    #[serde(default)]
442    pub remind: String,
443}
444
445/// An event's reminder before (`reminders::REMIND`), as the form says it:
446/// "" as usual, "none", or minutes ("15").
447fn 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
455/// What an occurrence says of its reminder: its own, else, a changed one
456/// (RECURRENCE-ID) saying nothing, its series'.
457fn 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
470/// An event as the form shows it, from its file, in your time zone.
471pub fn edit_of(path: &Path) -> Option<EventEdit> {
472    edit_of_text(&std::fs::read_to_string(path).ok()?, &TimeZone::system())
473}
474
475/// An event as the form shows it, from its text, in `zone`.
476pub 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    // Its first occurrence still there: the very first may have been left out (EXDATE) or changed.
481    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        // A whole-day event ends the morning after its last day.
506        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
515/// The main event's first occurrence still there, as the agenda shows it:
516/// (start, end) in Unix seconds, to the second; None when the text holds no
517/// event. Times without a zone are in `zone`.
518pub 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
530/// The start and end the form means, in your time zone.
531fn 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    // An end before the start means an hour.
546    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
554/// DTSTART and DTEND lines for the form's times: dates for whole days; UTC for
555/// one-off events; local time with the zone for repeating ones.
556fn 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
577/// The VTIMEZONE of a zone, as its changes of hour go this year: one STANDARD
578/// and one DAYLIGHT part, each repeating yearly on the same weekday of the
579/// same month ("last Sunday of October"); a single part when it has no summer time.
580pub 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            // The local time of the change, as clocks showed it just before.
597            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
617/// A new event from the form, its times in `zone` (yours), with its own UID.
618pub fn new_event(edit: &EventEdit, zone: &TimeZone) -> Result<String, String> {
619    new_event_with_uid(edit, &vdir::new_name(), zone)
620}
621
622/// A new event with the UID `uid` (`sioul tasks import`: the same key, the
623/// same event, imported again), one line.
624pub 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/// What the form changed, field by field: a field unchanged keeps its line.
643#[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
658/// SUMMARY, LOCATION, DESCRIPTION and the repeat rule, those that changed.
659fn 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
697/// Lines of the main event (not a changed occurrence) rewritten at every change.
698const EDITED: &[&str] = &["DTSTAMP", "LAST-MODIFIED", "SEQUENCE"];
699/// Rewritten only when the form changed the time: an event written in another
700/// zone keeps it, and so does its time.
701const TIMES: &[&str] = &["DTSTART", "DTEND", "DURATION"];
702
703/// The event's text with what the form edits changed in its main VEVENT;
704/// the rest, changed occurrences and alarms included, kept as it was. The
705/// times and the repeat rule are written again only when they changed: a rule
706/// the form cannot show ("every other Monday and Wednesday") stays whole while
707/// the title changes. The SEQUENCE goes up, as RFC 5545 §3.8.7.4 asks.
708pub 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    // Nothing changed: nothing is written, not even the stamps.
712    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        // Inside a VEVENT: its alarms are nested components, kept as they are.
758        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    // A repeating event in your zone needs the zone written once.
790    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
798/// Leaves one occurrence of a repeating event out (EXDATE), in the form DTSTART is written.
799/// One changed already (RECURRENCE-ID: moved by a drag in the agenda, or by another
800/// application) goes with its change: its own VEVENT is taken out, and the time it
801/// replaced in the series is left out.
802pub 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        // Its parameters but RANGE, which an EXDATE has not.
809        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
824/// Which occurrence of a file's event starts at `start` (Unix seconds), as the
825/// agenda shows it: the place of the VEVENT holding it among the text's
826/// VEVENTs (calcard keeps them in the text's order), and whether that one is a
827/// changed occurrence (RECURRENCE-ID). Times without a zone are in `zone`.
828fn 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
836/// The text's VEVENTs, BEGIN to END, in order (their alarms inside them).
837fn 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
859/// A line's name and parameters, as written: "DTSTART;TZID=Europe/Paris".
860fn head_of(line: &str) -> &str {
861    line.strip_suffix(lines::value(line)).and_then(|h| h.strip_suffix(':')).unwrap_or(line)
862}
863
864/// `at` as `like` writes its time (a DTSTART line): a date, UTC, or the clock
865/// in its TZID; a time without a zone, on the clock in `zone`.
866fn 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
878/// A line `name` for `at`, with `like`'s parameters, written as `like` writes its time.
879fn 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
885/// The moment one value of `line` means (a DATE-TIME list's item): in its
886/// TZID, UTC with a Z, a date at midnight; a time without a zone, in `zone`.
887fn 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
899/// A VEVENT's lines with its own times set: `from` to `to`, written as its
900/// DTSTART was (`like`), DURATION replaced by DTEND; its stamps written again.
901/// Its SEQUENCE goes one up (RFC 5545 §3.8.7.4), unless `recurrence` makes it a
902/// new changed occurrence of the series: then it keeps the series' own, as
903/// readers that match the two by it want (calcard). Its alarms are kept as
904/// they are; `drop` names the other lines left out (a new changed occurrence
905/// leaves the rule).
906fn 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    // Before its alarms, else before its end.
933    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
938/// A repeating event's rule, its occurrences moved by `span` on the clock: a
939/// weekly rule's days (BYDAY=MO,WE) move with them, and its end (UNTIL) by as
940/// much. None when the move would not keep what the rule says: days set
941/// otherwise (a day of the month, "the first Monday") moved to another day,
942/// or hours set in the rule (BYHOUR) moved to another time.
943fn 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/// Why an event stayed where it was (`moved`).
974#[derive(Debug, Clone, PartialEq, Eq)]
975pub enum MoveProblem {
976    /// No occurrence starts at that time any more: changed meanwhile, here or elsewhere.
977    Gone,
978    /// Every time, to other days, for a rule that sets its days other than by
979    /// the week ("the first Monday", "the 5th"): its form says how it repeats.
980    SetDays,
981    /// The file or a time does not read.
982    Unreadable(String),
983}
984
985/// The event moved as a drag in the agenda asks: its occurrence starting at
986/// `start` (Unix seconds, as shown) now from `new_start` to `new_end`. A single
987/// event takes the new times. A repeating one moves that time alone
988/// (`only_this`: a changed occurrence, RECURRENCE-ID, RFC 5545 §3.8.4.4; one
989/// changed already is changed again), or every time by as much on the clock:
990/// its first time, the times left out (EXDATE) and added (RDATE), and the
991/// times its changed occurrences replace go together, a weekly rule's days
992/// with them; the changed occurrences keep their own times, but the one moved.
993/// What Sioul does not edit is kept (`apply`); times without a zone are in `zone`.
994pub 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    // That time alone: its changed occurrence, made or changed again.
1006    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    // Every time: by as much on the clock as the one dragged (days, then hours).
1029    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    // A rule that would not keep what it says stops here: nothing is written.
1033    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    // Its first time and its length, as the form writes them; then, around them,
1042    // the rule's days, the times left out and added, the times changed occurrences replace.
1043    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    // The one dragged, changed already: its own times too.
1076    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
1085/// The event with `added` lines in its main VEVENT (a LINK, a REFID), those
1086/// not there yet, before its alarms; None when it holds no event. Nothing
1087/// else changes: the stamps stay, as a link is not a change of the event.
1088pub 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
1100/// The main VEVENT without the lines `drop` picks (its alarms untouched);
1101/// None when the text holds no event.
1102pub 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
1131/// The lines of the main VEVENT, BEGIN to END.
1132fn 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/// What an event file ties to: its links (LINK, RELATED-TO), its projects
1160/// (REFID) and its guests, read from its main VEVENT.
1161#[derive(Debug, Clone, Default, PartialEq, Eq)]
1162pub struct EventRef {
1163    pub key: String,
1164    pub uid: String,
1165    pub summary: String,
1166    /// Its first start, Unix seconds; 0 when unreadable.
1167    pub start: i64,
1168    pub links: Vec<crate::tasks::Link>,
1169    pub related: Vec<crate::tasks::Relation>,
1170    pub projects: Vec<String>,
1171    /// The guests' and organizer's addresses.
1172    pub people: Vec<String>,
1173}
1174
1175/// The ties of one event's text; None when it holds no event.
1176pub 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
1203/// The ties of every event, in every calendar.
1204pub 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
1216/// Where a new event goes: the calendar chosen in Settings ▸ Agenda
1217/// (`agenda.calendar`), while it can be written to and is shown here; else
1218/// the first calendar that can be written to and takes events, the one made
1219/// for time blocks last (`blocks::CALENDAR`).
1220pub 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
1225/// `default_calendar`, the choice given.
1226pub 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
1234/// The file a new event gets in a calendar.
1235pub fn new_path(calendar: &Collection) -> PathBuf {
1236    calendar.dir.join(format!("{}.ics", vdir::new_name()))
1237}
1238
1239/// An invitation (iTIP, RFC 5546) carried by a message.
1240#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1241pub struct Invitation {
1242    /// "REQUEST", "CANCEL", "REPLY", "PUBLISH"…
1243    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
1255/// What an invitation asks, from the text of its `text/calendar` part; times
1256/// without a zone are taken in `zone`, yours.
1257pub 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
1294/// Your answer to an invitation (iTIP REPLY, RFC 5546 §3.2.3): the event with
1295/// you alone among the guests, your answer in PARTSTAT. `answer`: "ACCEPTED",
1296/// "DECLINED" or "TENTATIVE".
1297pub 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    // The zones the event's times name.
1306    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
1328/// A guest's line with `answer` in its PARTSTAT, RSVP dropped: an answer
1329/// asks for none. A line without its colon (a broken file) has no value: all
1330/// of it is the head.
1331fn 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
1344/// An invitation's event as your calendar keeps it once you answered: as the
1345/// organizer sent it (`stored`), your own line among the guests (`me`, by
1346/// address) saying your answer ("ACCEPTED", "TENTATIVE"), as an attendee's
1347/// copy says it (RFC 5546 §3.2.3). Your other devices, and any calendar
1348/// reading the server, then know what you answered; two devices answering
1349/// the same invitation can tell whether they said the same (`answers`).
1350/// Unchanged but for METHOD when you are not among the guests by that address.
1351pub 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
1363/// Each guest's answer in an event, by address (lower case): its PARTSTAT,
1364/// "NEEDS-ACTION" when it says none; the event's own guests, not those of a
1365/// changed occurrence. Empty for what has no guests (a contact, a task).
1366pub 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
1376/// Adds an invitation's event to a calendar file, as the organizer sent it,
1377/// METHOD removed (RFC 4791 §4.1 forbids it in stored objects).
1378pub 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    /// The agenda reads the calendars that take events (21 October 2026),
1388    /// and an event slipped into a list said to take tasks only shows too:
1389    /// what a folder holds counts, nothing is hidden (F3, 22 October 2026).
1390    #[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    /// A repeating event begun years ago reaches the stretch asked for: it is
1425    /// expanded from just before it (`from_window`), with the same occurrences
1426    /// there as an expansion from its true start without a cap would give:
1427    /// every hour, across the change of hour, every other week on two days,
1428    /// a day left out and a changed occurrence; a counted rule as it was.
1429    #[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        // As calcard gives them from the true start, with no cap that matters here.
1435        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        // Every hour since 1 January 2019: 24 on Monday 5 October 2026 (68,000 hours on).
1460        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        // The night the clocks go back, 25 hours: as calcard counts them from the start.
1467        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        // Every other week, Monday and Wednesday at 18:00, since 2019; one left out, one moved.
1470        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        // Every quarter of an hour: the cap alone would stop in 2019.
1477        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        // Counted from its start: left as it was (all its 20 are long over).
1481        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        // A rule by the month reaches far enough from its start: left as it was.
1485        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        // 5, 12 (left out), 19 (moved to 19:00), 26.
1519        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        // Its first occurrence left out: still opened, from the next one.
1533        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        // New times, the same rule: it was still "weekly".
1544        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        // Kept answered: your line says it, Paul's as it was; the guests' answers read back.
1570        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    /// Each occurrence between 1 and 27 October as (start, end, title).
1588    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        // Dragged from 9:00 to 10:15, its half hour kept; then its end to 11:00.
1598        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        // Changed meanwhile: no occurrence starts at 8:00 any more.
1604        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        // Monday 5 October's yoga to Tuesday 6 at 19:00, still an hour and a half; the others stay.
1610        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        // Its alarm, and what Sioul does not know, go with it.
1616        assert_eq!((text.matches("TRIGGER:-PT15M").count(), text.matches("X-APP-THING:keep").count()), (2, 2), "{text}");
1617        assert!(parse(&text).is_some());
1618        // Moved again: the same changed occurrence, not a second one.
1619        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        // Left out: the changed occurrence goes, and the time it replaced is left out of the series.
1623        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        // Half an hour later every time: its first time, the time left out and the time the changed one replaces go along.
1631        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        // The one changed already, dragged: every time half an hour later, and it where it was dropped.
1637        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        // To Tuesdays: a weekly rule's day goes along.
1640        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        // The first Monday of the month, to another day: its form says how it repeats.
1644        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}