Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ See [UPGRADING.md](UPGRADING.md). The fixes of 4.1.4 (#37, #88) are included.

### Fixed

- VTIMEZONE rules with `UNTIL` are compared in UTC: definitions east of UTC whose daylight saving time ended (e.g. Europe/Moscow
before 2011) are resolved again, west of UTC no transition after `UNTIL` is kept (#110)
- `RDATE` without `RRULE` no longer fails with `TypeError` and adds no yearly occurrences (#37, also in 4.1.4)
- `RDATE` values are always part of the recurrence set (one was lost together with `COUNT`)
- a `RECURRENCE-ID` replaces only the matching instance of the same UID, compared as an instant in any timezone
Expand Down
29 changes: 19 additions & 10 deletions src/TimezoneGuesser.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,26 +60,26 @@ public static function guess(array $observances, ?DateTimeImmutable $reference =
* @return array{int, list<array{int, int}>}
*/
private static function transitions(array $observances, int $from, int $until): array {
$utc = new DateTimeZone('UTC');
$all = [];
foreach ($observances as $observance) {
$offsetFrom = self::offset($observance['offsetFrom']);
$offsetTo = self::offset($observance['offsetTo']);
// wall-clock times are expanded as if they were UTC, then shifted by TZOFFSETFROM
$start = new DateTimeImmutable($observance['start'], $utc);
$local = [];
// local times are in TZOFFSETFROM; the UNTIL of a rule is in UTC (RFC 5545, section 3.3.10),
// so the rule is expanded in the fixed offset and gives UTC timestamps
$zone = self::fixedOffsetZone($offsetFrom);
$start = new DateTimeImmutable($observance['start'], $zone);
$instants = [$start->getTimestamp()];
if (!empty($observance['rrule'])) {
$instants = [];
foreach (new Expander(Rule::fromString($observance['rrule'], true), $start, $until + 2 * 86400) as $timestamp) {
$local[] = $timestamp; // includes DTSTART
$instants[] = $timestamp; // includes DTSTART
}
} else {
$local[] = $start->getTimestamp();
}
foreach ($observance['rdates'] ?? [] as $rdate) {
$local[] = (new DateTimeImmutable($rdate, $utc))->getTimestamp();
$instants[] = (new DateTimeImmutable($rdate, $zone))->getTimestamp();
}
foreach ($local as $timestamp) {
$all[] = [$timestamp - $offsetFrom, $offsetTo];
foreach ($instants as $timestamp) {
$all[] = [$timestamp, $offsetTo];
}
}
usort($all, static fn(array $a, array $b): int => $a[0] <=> $b[0]);
Expand Down Expand Up @@ -145,6 +145,15 @@ private static function fixedOffset(int $offset): DateTimeZone {
return new DateTimeZone(sprintf('%s%02d:%02d', $sign, intdiv($offset, 3600), intdiv($offset % 3600, 60)));
}

/**
* Timezone with a fixed offset, e.g. "+03:00", or "+00:19:32" for a local mean time.
*/
private static function fixedOffsetZone(int $offset): DateTimeZone {
$seconds = abs($offset);
$name = sprintf('%s%02d:%02d', $offset < 0 ? '-' : '+', intdiv($seconds, 3600), intdiv($seconds % 3600, 60));
return new DateTimeZone($seconds % 60 === 0 ? $name : sprintf('%s:%02d', $name, $seconds % 60));
}

/**
* Parse a UTC-OFFSET value ("+0100", "-053000").
*/
Expand Down
32 changes: 32 additions & 0 deletions tests/Unit/Timezone/resolvers.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,38 @@ test('A definition with RDATE transitions', function () {
Assert::same(3600, (new DateTimeImmutable('2026-12-01', $resolved->timezone))->getOffset());
});

test('UNTIL of an observance is in UTC, also east of UTC (#110)', function () {
// Moscow 2008-2013 under a name the IANA resolver does not know
$calendar = calendarWith(implode("\r\n", [
'BEGIN:VTIMEZONE', 'TZID:Custom/Moscow',
'BEGIN:STANDARD', 'DTSTART:19961027T030000', 'TZOFFSETFROM:+0400', 'TZOFFSETTO:+0300', 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20101030T230000Z', 'END:STANDARD',
'BEGIN:DAYLIGHT', 'DTSTART:19930328T020000', 'TZOFFSETFROM:+0300', 'TZOFFSETTO:+0400', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=20100327T230000Z', 'END:DAYLIGHT',
'BEGIN:STANDARD', 'DTSTART:20110327T020000', 'TZOFFSETFROM:+0300', 'TZOFFSETTO:+0400', 'END:STANDARD',
'BEGIN:STANDARD', 'DTSTART:20141026T020000', 'TZOFFSETFROM:+0400', 'TZOFFSETTO:+0300', 'END:STANDARD',
'END:VTIMEZONE',
]));
foreach (['2010-06-01', '2011-06-01'] as $reference) {
$resolved = (new VTimezoneResolver(reference: new DateTimeImmutable($reference)))->resolve('Custom/Moscow', $calendar);
Assert::notNull($resolved, $reference);
Assert::same(TimezoneSource::VTimezone, $resolved->source);
foreach (['2010-03-27 12:00' => 10800, '2010-07-01' => 14400, '2010-12-01' => 10800, '2011-07-01' => 14400, '2012-01-01' => 14400] as $date => $offset) {
Assert::same($offset, (new DateTimeImmutable($date, $resolved->timezone))->getOffset(), "$reference $date");
}
}
});

test('UNTIL of an observance is in UTC, west of UTC no transition after it is kept (#110)', function () {
// daylight time until 2009: 2010-03-14 02:00 local is 07:00 UTC, after UNTIL
$calendar = calendarWith(implode("\r\n", [
'BEGIN:VTIMEZONE', 'TZID:Custom/Eastern',
'BEGIN:STANDARD', 'DTSTART:20071104T020000', 'TZOFFSETFROM:-0400', 'TZOFFSETTO:-0500', 'RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU', 'END:STANDARD',
'BEGIN:DAYLIGHT', 'DTSTART:20070311T020000', 'TZOFFSETFROM:-0500', 'TZOFFSETTO:-0400', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU;UNTIL=20100314T030000Z', 'END:DAYLIGHT',
'END:VTIMEZONE',
]));
$resolved = (new VTimezoneResolver(reference: new DateTimeImmutable('2009-06-01')))->resolve('Custom/Eastern', $calendar);
Assert::null($resolved); // not America/New_York, which has daylight time in 2010
});

test('Invalid definitions are not resolved', function () {
$calendar = calendarWith("BEGIN:VTIMEZONE\r\nTZID:Broken\r\nBEGIN:STANDARD\r\nDTSTART:x\r\nTZOFFSETFROM:+01\r\nTZOFFSETTO:bad\r\nEND:STANDARD\r\nEND:VTIMEZONE");
Assert::null((new VTimezoneResolver())->resolve('Broken', $calendar));
Expand Down
27 changes: 27 additions & 0 deletions tests/Unit/Timezone/vtimezone-builder.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use om\ICal\Component;
use om\ICal\Event;
use om\ICal\Serializer;
use om\ICal\Timezone\VTimezoneBuilder;
use om\ICal\Timezone\VTimezoneResolver;
use om\ICal\Value\UtcOffset;
use om\RRule\Expander;
use om\RRule\Rule;
Expand Down Expand Up @@ -165,3 +166,29 @@ test('forComponents(): a definition for every TZID used, for the range of the da
Assert::same(['Europe/Prague', 'America/New_York'], array_map(fn(Component $c) => $c->property('TZID')->value, $definitions));
Assert::same([], VTimezoneBuilder::forComponents([new Component('VEVENT', [om\ICal\Property::create('DTSTART', '20260105T093000', ['TZID' => 'Custom Zone'])])]), 'only IANA timezones');
});

test('Definitions with UNTIL resolve east and west of UTC under another TZID (#110)', function () {
$offsets = function (DateTimeZone $timezone, int $from, int $to): array {
$result = [];
foreach (array_slice($timezone->getTransitions($from, $to) ?: [], 1) as $transition) {
if (($result === [] ? null : end($result)[1]) !== $transition['offset']) {
$result[] = [$transition['ts'], $transition['offset']];
}
}
return $result;
};
foreach (['Europe/Moscow' => [2008, 2013], 'Europe/Minsk' => [2008, 2013], 'America/Sao_Paulo' => [2015, 2022], 'Europe/Istanbul' => [2013, 2019], 'Europe/Amsterdam' => [1934, 1940]] as $name => [$first, $last]) {
$timezone = new DateTimeZone($name);
$vtimezone = VTimezoneBuilder::build($timezone, new DateTimeImmutable("$first-01-01", $timezone), new DateTimeImmutable("$last-12-31", $timezone), 'Custom/Zone');
$vtimezone = new Component('VTIMEZONE', array_values(array_filter($vtimezone->properties, fn($property) => $property->name !== 'X-LIC-LOCATION')), $vtimezone->components);
Assert::contains('UNTIL=', Serializer::serialize($vtimezone), $name);
$calendar = new Component('VCALENDAR', [], [$vtimezone]);
for ($year = $first + 1; $year <= $last - 2; $year++) {
$resolved = (new VTimezoneResolver(reference: new DateTimeImmutable("$year-06-01")))->resolve('Custom/Zone', $calendar);
Assert::notNull($resolved, "$name $year");
$from = gmmktime(0, 0, 0, 1, 1, $year - 1);
$to = gmmktime(0, 0, 0, 1, 1, $year + 2);
Assert::same($offsets($timezone, $from, $to), $offsets($resolved->timezone, $from, $to), "$name $year");
}
}
});
Loading