Calendar: keep the UTC zone of a series that starts in UTC #305
No files matched your search
@@ -2,10 +2,13 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::{
|
||||
Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData, expand::resolve_local,
|
||||
Alarm, AlarmDelta, ArchivedAlarmDelta, ArchivedCalendarEventData,
|
||||
expand::{range_zones, resolve_local},
|
||||
};
|
||||
use calcard::{
|
||||
common::timezone::Tz,
|
||||
@@ -58,15 +61,12 @@ impl ArchivedCalendarEventData {
|
||||
let (offset_or_count, bytes_read) = instances.read_leb128::<u32>()?;
|
||||
|
||||
let duration = range.duration.to_native() as i64;
|
||||
let mut start_tz = Tz::from_id(range.start_tz.to_native())?;
|
||||
let mut end_tz = Tz::from_id(range.end_tz.to_native())?;
|
||||
|
||||
if start_tz.is_floating() && !default_tz.is_floating() {
|
||||
start_tz = default_tz;
|
||||
}
|
||||
if end_tz.is_floating() && !default_tz.is_floating() {
|
||||
end_tz = default_tz;
|
||||
}
|
||||
let (start_tz, end_tz) = range_zones(
|
||||
Tz::from_id(range.start_tz.to_native())?,
|
||||
Tz::from_id(range.end_tz.to_native())?,
|
||||
self.event.components.get(comp_id as usize),
|
||||
default_tz,
|
||||
);
|
||||
|
||||
if instances.len() > bytes_read {
|
||||
// Recurring event
|
||||
|
||||
@@ -102,6 +102,70 @@ fn resolve_recurrence_id(
|
||||
})
|
||||
}
|
||||
|
||||
// inbuxa: the zone of a DTSTART written with an offset ("...Z") and no TZID.
|
||||
// Series stored before calcard kept that zone have floating time ranges, and
|
||||
// read as floating they land in whatever zone the request asks for.
|
||||
pub trait ComponentStartOffset {
|
||||
fn start_offset_tz(&self) -> Option<Tz>;
|
||||
}
|
||||
|
||||
impl ComponentStartOffset for ArchivedICalendarComponent {
|
||||
fn start_offset_tz(&self) -> Option<Tz> {
|
||||
let entry = self.property(&ICalendarProperty::Dtstart)?;
|
||||
if entry.tz_id().is_some() {
|
||||
return None;
|
||||
}
|
||||
entry
|
||||
.values
|
||||
.first()?
|
||||
.as_partial_date_time()?
|
||||
.to_date_time()?
|
||||
.tz()
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentStartOffset for ICalendarComponent {
|
||||
fn start_offset_tz(&self) -> Option<Tz> {
|
||||
let entry = self.property(&ICalendarProperty::Dtstart)?;
|
||||
if entry.tz_id().is_some() {
|
||||
return None;
|
||||
}
|
||||
entry
|
||||
.values
|
||||
.first()?
|
||||
.as_partial_date_time()?
|
||||
.to_date_time()?
|
||||
.tz()
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: the zones to read a time range in. A floating range is in the
|
||||
// component's DTSTART offset if it has one (see `ComponentStartOffset`), and
|
||||
// otherwise in `default_tz`, the request's zone.
|
||||
pub fn range_zones(
|
||||
start_tz: Tz,
|
||||
end_tz: Tz,
|
||||
component: Option<&impl ComponentStartOffset>,
|
||||
default_tz: Tz,
|
||||
) -> (Tz, Tz) {
|
||||
let (mut start, mut end) = (start_tz, end_tz);
|
||||
if start.is_floating()
|
||||
&& let Some(offset_tz) = component.and_then(|c| c.start_offset_tz())
|
||||
{
|
||||
start = offset_tz;
|
||||
if end.is_floating() {
|
||||
end = offset_tz;
|
||||
}
|
||||
}
|
||||
if start.is_floating() && !default_tz.is_floating() {
|
||||
start = default_tz;
|
||||
}
|
||||
if end.is_floating() && !default_tz.is_floating() {
|
||||
end = default_tz;
|
||||
}
|
||||
(start, end)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CalendarEventExpansion {
|
||||
pub comp_id: u32,
|
||||
@@ -144,17 +208,14 @@ impl ArchivedCalendarEventData {
|
||||
.all(|prior| prior.id != range.id)
|
||||
.then(|| component.recurrence_id(component_tz))
|
||||
.flatten();
|
||||
let mut start_tz = component_tz;
|
||||
let mut end_tz = Tz::from_id(range.end_tz.to_native())?;
|
||||
let (start_tz, end_tz) = range_zones(
|
||||
component_tz,
|
||||
Tz::from_id(range.end_tz.to_native())?,
|
||||
Some(component),
|
||||
default_tz,
|
||||
);
|
||||
let is_todo = component.component_type.is_todo();
|
||||
|
||||
if start_tz.is_floating() && !default_tz.is_floating() {
|
||||
start_tz = default_tz;
|
||||
}
|
||||
if end_tz.is_floating() && !default_tz.is_floating() {
|
||||
end_tz = default_tz;
|
||||
}
|
||||
|
||||
if instances.len() > bytes_read {
|
||||
let unpacker =
|
||||
BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count);
|
||||
@@ -234,15 +295,12 @@ impl CalendarEventData {
|
||||
.all(|prior| prior.id != range.id)
|
||||
.then(|| component.recurrence_id(component_tz))
|
||||
.flatten();
|
||||
let mut start_tz = component_tz;
|
||||
let mut end_tz = Tz::from_id(range.end_tz)?;
|
||||
|
||||
if start_tz.is_floating() && !default_tz.is_floating() {
|
||||
start_tz = default_tz;
|
||||
}
|
||||
if end_tz.is_floating() && !default_tz.is_floating() {
|
||||
end_tz = default_tz;
|
||||
}
|
||||
let (start_tz, end_tz) = range_zones(
|
||||
component_tz,
|
||||
Tz::from_id(range.end_tz)?,
|
||||
Some(component),
|
||||
default_tz,
|
||||
);
|
||||
|
||||
let mut push_instance = |own_recurrence_id: Option<RecurrenceId>, start_offset: u32| {
|
||||
let start_date_naive = start_offset as i64 + base_offset;
|
||||
@@ -294,20 +352,15 @@ impl CalendarEventData {
|
||||
let instances = range.instances.as_ref();
|
||||
let (offset_or_count, bytes_read) = instances.read_leb128::<u32>()?;
|
||||
let component_tz = Tz::from_id(range.start_tz)?;
|
||||
let own_recurrence_id = self
|
||||
.event
|
||||
.components
|
||||
.get(comp_id as usize)
|
||||
.and_then(|component| component.recurrence_id(component_tz));
|
||||
let mut start_tz = component_tz;
|
||||
let mut end_tz = Tz::from_id(range.end_tz)?;
|
||||
|
||||
if start_tz.is_floating() && !default_tz.is_floating() {
|
||||
start_tz = default_tz;
|
||||
}
|
||||
if end_tz.is_floating() && !default_tz.is_floating() {
|
||||
end_tz = default_tz;
|
||||
}
|
||||
let component = self.event.components.get(comp_id as usize);
|
||||
let own_recurrence_id =
|
||||
component.and_then(|component| component.recurrence_id(component_tz));
|
||||
let (start_tz, end_tz) = range_zones(
|
||||
component_tz,
|
||||
Tz::from_id(range.end_tz)?,
|
||||
component,
|
||||
default_tz,
|
||||
);
|
||||
let start_offset = if instances.len() > bytes_read {
|
||||
let mut unpacker =
|
||||
BitpackIterator::from_bytes_and_offset(instances, bytes_read, offset_or_count);
|
||||
@@ -508,4 +561,75 @@ mod tests {
|
||||
.expect("expansion of an oversized RRULE must be refused, not attempted");
|
||||
assert_eq!(ranges, 1, "the base instance is still indexed");
|
||||
}
|
||||
|
||||
// inbuxa: a series starting in UTC is read in UTC whatever zone the
|
||||
// request asks for, including one stored before calcard kept the zone
|
||||
#[test]
|
||||
fn utc_series_ignores_the_request_zone() {
|
||||
let phoenix = Tz::from_str("America/Phoenix").unwrap();
|
||||
let ical = |dtstart: &str| {
|
||||
let Entry::ICalendar(ical) = Parser::new(&format!(
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTAMP:20270101T000000Z\r\n\
|
||||
DTSTART{dtstart}\r\nDURATION:PT1H\r\nRRULE:FREQ=WEEKLY;COUNT=3\r\n\
|
||||
END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
))
|
||||
.entry() else {
|
||||
panic!("failed to parse iCalendar");
|
||||
};
|
||||
CalendarEventData::new(ical, Tz::Floating, 1000, &mut None)
|
||||
};
|
||||
let starts = |data: &CalendarEventData, tz: Tz| {
|
||||
let first = data.expand_single(1, tz).expect("first instance").start;
|
||||
let mut keys = AHashSet::from_iter([key(2027, 3, 8, 9, 0)]);
|
||||
let second = data.expand_from_ids(&mut keys, tz).expect("expansion");
|
||||
assert_eq!(second.len(), 1);
|
||||
let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(data).unwrap();
|
||||
let archived =
|
||||
rkyv::access::<ArchivedCalendarEventData, rkyv::rancor::Error>(&bytes).unwrap();
|
||||
let all = archived
|
||||
.expand(tz, TimeRange::new(i64::MIN, i64::MAX))
|
||||
.expect("expansion")
|
||||
.into_iter()
|
||||
.map(|e| (e.start, e.end))
|
||||
.collect::<Vec<_>>();
|
||||
(first, second[0].start, all)
|
||||
};
|
||||
let utc = |day| naive(2027, 3, day, 9, 0, 0);
|
||||
let expected = (
|
||||
utc(1),
|
||||
utc(8),
|
||||
[1, 8, 15].map(|day| (utc(day), utc(day) + 3600)).to_vec(),
|
||||
);
|
||||
|
||||
let mut data = ical(":20270301T090000Z");
|
||||
assert!(
|
||||
data.time_ranges
|
||||
.iter()
|
||||
.all(|range| range.start_tz == Tz::UTC.as_id())
|
||||
);
|
||||
assert_eq!(starts(&data, phoenix), expected);
|
||||
assert_eq!(starts(&data, Tz::UTC), expected);
|
||||
|
||||
// As stored before the fix: floating ranges
|
||||
for range in &mut data.time_ranges {
|
||||
range.start_tz = Tz::Floating.as_id();
|
||||
range.end_tz = Tz::Floating.as_id();
|
||||
}
|
||||
assert_eq!(starts(&data, phoenix), expected);
|
||||
|
||||
// A floating series still follows the request's zone
|
||||
let floating = ical(":20270301T090000");
|
||||
let seven = 7 * 3600;
|
||||
assert_eq!(
|
||||
starts(&floating, phoenix),
|
||||
(
|
||||
utc(1) + seven,
|
||||
utc(8) + seven,
|
||||
[1, 8, 15]
|
||||
.map(|day| (utc(day) + seven, utc(day) + seven + 3600))
|
||||
.to_vec()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use groupware::{
|
||||
calendar::{
|
||||
CalendarEvent, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, EVENT_INVITE_SELF,
|
||||
PREF_USE_DEFAULT_ALERTS,
|
||||
expand::CalendarEventExpansion,
|
||||
expand::{CalendarEventExpansion, ComponentStartOffset},
|
||||
limit_component_depth,
|
||||
privacy::{event_view, hide_attendees, redact_private_event},
|
||||
},
|
||||
@@ -344,7 +344,12 @@ impl CalendarEventGet for Server {
|
||||
.collect::<Vec<_>>(),
|
||||
};
|
||||
|
||||
let tz = tz.unwrap_or(default_tz);
|
||||
// inbuxa: a series that starts in UTC ("...Z") keeps
|
||||
// that zone, as the base event does, rather than
|
||||
// taking the request's
|
||||
let tz = tz
|
||||
.or_else(|| source_component.start_offset_tz())
|
||||
.unwrap_or(default_tz);
|
||||
let tz_name = tz.name().unwrap_or_default().to_string();
|
||||
|
||||
let start_timestamp = DateTime::from_timestamp(expansion.start, 0)
|
||||
@@ -443,11 +448,13 @@ impl CalendarEventGet for Server {
|
||||
}
|
||||
|
||||
if include_base_event {
|
||||
let mut event = std::mem::take(&mut calendar_event.data.event);
|
||||
|
||||
// Obtain UTC start/end if requested
|
||||
// inbuxa: before the event is taken, as the expansion reads
|
||||
// its DTSTART to place a series stored floating
|
||||
let expansion = if return_utc_dates
|
||||
&& let Some(expansion) = event
|
||||
&& let Some(expansion) = calendar_event
|
||||
.data
|
||||
.event
|
||||
.components
|
||||
.iter()
|
||||
.position(|c| {
|
||||
@@ -462,6 +469,7 @@ impl CalendarEventGet for Server {
|
||||
} else {
|
||||
CalendarEventExpansion::default()
|
||||
};
|
||||
let mut event = std::mem::take(&mut calendar_event.data.event);
|
||||
|
||||
// Remove recurrence ids
|
||||
if let Some(range) = &override_range {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -884,6 +886,8 @@ pub async fn test(test: &TestServer) {
|
||||
]
|
||||
);
|
||||
|
||||
utc_series(&account).await;
|
||||
|
||||
// Clean up
|
||||
for calendar_id in [&calendar_id, &series_calendar_id] {
|
||||
account
|
||||
@@ -896,6 +900,109 @@ pub async fn test(test: &TestServer) {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: a series starting in UTC keeps its times and zone whatever zone
|
||||
// the request asks for (inbuxa-server #303)
|
||||
async fn utc_series(account: &Account) {
|
||||
let calendar_id = account
|
||||
.jmap_create(
|
||||
MethodObject::Calendar,
|
||||
[json!({ "name": "UTC series" })],
|
||||
Vec::<(&str, &str)>::new(),
|
||||
)
|
||||
.await
|
||||
.created(0)
|
||||
.id()
|
||||
.to_string();
|
||||
let event_id = account
|
||||
.jmap_create(
|
||||
MethodObject::CalendarEvent,
|
||||
[json!({
|
||||
"@type": "Event",
|
||||
"uid": "[email protected]",
|
||||
"calendarIds": { &calendar_id: true },
|
||||
"title": "UTC series",
|
||||
"start": "2026-11-02T18:00:00",
|
||||
"duration": "PT1H",
|
||||
"timeZone": "Etc/UTC",
|
||||
"recurrenceRule": { "frequency": "weekly", "count": 3 }
|
||||
})],
|
||||
Vec::<(&str, &str)>::new(),
|
||||
)
|
||||
.await
|
||||
.created(0)
|
||||
.id()
|
||||
.to_string();
|
||||
let account_id = account.id_string();
|
||||
let properties = ["id", "baseEventId", "start", "timeZone", "utcStart"];
|
||||
|
||||
for time_zone in ["America/Phoenix", "Etc/UTC"] {
|
||||
let response = account
|
||||
.jmap_method_calls(json!([
|
||||
["CalendarEvent/get", {
|
||||
"accountId": account_id,
|
||||
"ids": [&event_id],
|
||||
"properties": properties,
|
||||
"timeZone": time_zone,
|
||||
}, "0"],
|
||||
["CalendarEvent/query", {
|
||||
"accountId": account_id,
|
||||
"filter": {
|
||||
"inCalendar": &calendar_id,
|
||||
"after": "2026-11-01T00:00:00",
|
||||
"before": "2026-12-01T00:00:00",
|
||||
},
|
||||
"sort": [{ "property": "start" }],
|
||||
"expandRecurrences": true,
|
||||
"timeZone": time_zone,
|
||||
}, "1"],
|
||||
["CalendarEvent/get", {
|
||||
"accountId": account_id,
|
||||
"#ids": { "resultOf": "1", "name": "CalendarEvent/query", "path": "/ids" },
|
||||
"properties": properties,
|
||||
"timeZone": time_zone,
|
||||
}, "2"],
|
||||
]))
|
||||
.await;
|
||||
let base = &response.list()[0];
|
||||
assert_eq!(base.text_field("timeZone"), "Etc/UTC", "{time_zone}");
|
||||
assert_eq!(
|
||||
base.text_field("utcStart"),
|
||||
"2026-11-02T18:00:00Z",
|
||||
"{time_zone}"
|
||||
);
|
||||
|
||||
let instances = response.response_at(2)["list"].as_array().unwrap();
|
||||
let found = instances
|
||||
.iter()
|
||||
.map(|instance| {
|
||||
assert_eq!(instance.text_field("baseEventId"), event_id);
|
||||
(
|
||||
instance.text_field("start"),
|
||||
instance.text_field("timeZone"),
|
||||
instance.text_field("utcStart"),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
("2026-11-02T18:00:00", "Etc/UTC", "2026-11-02T18:00:00Z"),
|
||||
("2026-11-09T18:00:00", "Etc/UTC", "2026-11-09T18:00:00Z"),
|
||||
("2026-11-16T18:00:00", "Etc/UTC", "2026-11-16T18:00:00Z"),
|
||||
],
|
||||
"{time_zone}"
|
||||
);
|
||||
}
|
||||
|
||||
account
|
||||
.jmap_destroy(
|
||||
MethodObject::Calendar,
|
||||
[&calendar_id],
|
||||
[("onDestroyRemoveEvents", true)],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn expand_instances(account: &Account, calendar_id: &str) -> Vec<Value> {
|
||||
let ids = account
|
||||
.jmap_query(
|
||||
|
||||
Vendored
+7
@@ -44,6 +44,13 @@ Every changed file carries a Coffey Labs notice in its header.
|
||||
limit is reached (they couldn't add anything). The `budget_tests` in
|
||||
`dates.rs` cover it, and `rule_expansion_shares_one_budget` in
|
||||
`crates/groupware` fails to build if the server stops using this copy.
|
||||
- `src/icalendar/dates.rs`: a component whose DTSTART carries an offset
|
||||
(`DTSTART:20261102T180000Z`) has that zone as its start zone. Upstream
|
||||
took the default zone, which the server passes as floating, so every
|
||||
instance of a UTC rule came out floating (one-off events were already
|
||||
right), and the server then read the series in the zone each request asked
|
||||
for. The `start_zone_tests` in `dates.rs` cover it. calcard 0.4 makes the
|
||||
same change.
|
||||
|
||||
## Updating
|
||||
|
||||
|
||||
+71
-1
@@ -393,7 +393,13 @@ impl ICalendarComponent {
|
||||
},
|
||||
};
|
||||
let mut event = None;
|
||||
let start_tz = tz_resolver.resolve_or_default(dt_start_tzid);
|
||||
// inbuxa: a DTSTART written with an offset ("...Z") is in that zone.
|
||||
// Taking the default (floating, as the server stores events) made
|
||||
// every instance of a UTC rule floating, so readers then placed it in
|
||||
// whatever zone the client asked for.
|
||||
let start_tz = dt_start
|
||||
.tz()
|
||||
.unwrap_or_else(|| tz_resolver.resolve_or_default(dt_start_tzid));
|
||||
let dt_start_tz = dt_start
|
||||
.to_date_time_with_tz(start_tz)
|
||||
.ok_or(CalendarErrorType::InvalidDtStart)?;
|
||||
@@ -771,3 +777,67 @@ mod budget_tests {
|
||||
assert!(iterations < 4000, "{iterations}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod start_zone_tests {
|
||||
use crate::{
|
||||
common::timezone::Tz,
|
||||
icalendar::{ICalendar, dates::TimeOrDelta},
|
||||
};
|
||||
|
||||
fn instances(dtstart: &str, rrule: Option<&str>, default_tz: Tz) -> Vec<(Tz, i64)> {
|
||||
let rrule = rrule.map_or(String::new(), |rule| format!("RRULE:{rule}\r\n"));
|
||||
let ical = ICalendar::parse(format!(
|
||||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:zone\r\n\
|
||||
DTSTART{dtstart}\r\nDURATION:PT1H\r\n{rrule}END:VEVENT\r\nEND:VCALENDAR\r\n"
|
||||
))
|
||||
.unwrap();
|
||||
ical.expand_dates(default_tz, 10)
|
||||
.events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
if let TimeOrDelta::Time(end) = event.end {
|
||||
assert_eq!(end.timezone(), event.start.timezone());
|
||||
}
|
||||
(event.start.timezone(), event.start.timestamp())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// 2026-11-02 18:00 UTC and a week later
|
||||
const FIRST: i64 = 1793642400;
|
||||
const SECOND: i64 = FIRST + 7 * 86400;
|
||||
|
||||
#[test]
|
||||
fn utc_rule_instances_stay_utc() {
|
||||
for default_tz in [Tz::Floating, Tz::Tz(chrono_tz::America::Phoenix)] {
|
||||
assert_eq!(
|
||||
instances(":20261102T180000Z", Some("FREQ=WEEKLY;COUNT=2"), default_tz),
|
||||
vec![(Tz::UTC, FIRST), (Tz::UTC, SECOND)],
|
||||
"{default_tz:?}"
|
||||
);
|
||||
// as a one-off already was
|
||||
assert_eq!(
|
||||
instances(":20261102T180000Z", None, default_tz),
|
||||
vec![(Tz::UTC, FIRST)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_and_zoned_rules_unchanged() {
|
||||
assert_eq!(
|
||||
instances(":20261102T180000", Some("FREQ=WEEKLY;COUNT=2"), Tz::Floating),
|
||||
vec![(Tz::Floating, FIRST), (Tz::Floating, SECOND)]
|
||||
);
|
||||
let new_york = Tz::Tz(chrono_tz::America::New_York);
|
||||
assert_eq!(
|
||||
instances(
|
||||
";TZID=America/New_York:20261102T130000",
|
||||
Some("FREQ=WEEKLY;COUNT=2"),
|
||||
Tz::Floating
|
||||
),
|
||||
vec![(new_york, FIRST), (new_york, SECOND)]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user