diff --git a/README.md b/README.md index eda3a28..db26028 100755 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ use text\ical\{ Event, Organizer, Attendee, - Date, + IDate, Text, Method, Role, @@ -93,8 +93,8 @@ $calendar= Calendar::with() ->value('MAILTO:attendee3@example.com') ->create() ]) - ->dtstart(new Date(null, '20160524T183000Z')) - ->dtend(new Date(null, '20160524T190000Z')) + ->dtstart(new IDate(null, '20160524T183000Z')) + ->dtend(new IDate(null, '20160524T190000Z')) ->location(new Text('de-DE', 'BS 50 EG 0102')) ->summary(new Text('de-DE', 'Treffen')) ->create() diff --git a/src/main/php/autoload.php b/src/main/php/autoload.php index 832c1dd..651458f 100755 --- a/src/main/php/autoload.php +++ b/src/main/php/autoload.php @@ -1,3 +1,3 @@ method= $method; $this->prodid= $prodid; $this->version= $version; $this->events= $events; - $this->timezone= $timezone; + $this->timezones= $timezones; $this->properties= $properties; } @@ -35,8 +37,8 @@ public function prodid() { return $this->prodid; } /** @return string */ public function version() { return $this->version; } - /** @return text.ical.TimeZone */ - public function timezone() { return $this->timezone; } + /** @return text.ical.ITimeZone[] */ + public function timezones() { return $this->timezones; } /** @return text.ical.Events */ public function events() { return new Events(...(array)$this->events); } @@ -44,7 +46,7 @@ public function events() { return new Events(...(array)$this->events); } /** @return object */ public static function with() { return new class() { - private $method, $prodid, $version, $events, $timezone, $properties= []; + private $method, $prodid, $version, $events, $timezones, $properties= []; public function method($value) { $this->method= $value; return $this; } @@ -52,18 +54,35 @@ public function prodid($value) { $this->prodid= $value; return $this; } public function version($value) { $this->version= $value; return $this; } - public function timezone($value) { $this->timezone= $value; return $this; } + public function timezones($value) { $this->timezones= $value; return $this; } public function events($value) { $this->events= $value; return $this; } public function properties($value) { $this->properties= $value; return $this; } public function create() { - return new Calendar($this->method, $this->prodid, $this->version, $this->events, $this->timezone, $this->properties); + return new Calendar($this->method, $this->prodid, $this->version, $this->events, $this->timezones, $this->properties); } }; } + /** + * Converts a calendar date value to a date instance + * + * @param text.ical.IDate $date + * @return util.Date + * @throws lang.IllegalStateException if the date's timezone is not defined + */ + public function date(IDate $date) { + if (null === ($tzid= $date->tzid())) return new Date($date->value()); + + foreach ($this->timezones as $timezone) { + if ($tzid === $timezone->tzid()) return $timezone->convert($date->value()); + } + + throw new IllegalStateException('No timezone definition in calendar for "'.$tzid.'"'); + } + /** * Write this object * @@ -77,7 +96,7 @@ public function write($out, $name) { 'prodid' => $this->prodid, 'version' => $this->version, 'event' => $this->events, - 'timezone' => $this->timezone + 'timezone' => $this->timezones ])); } diff --git a/src/main/php/text/ical/Creation.class.php b/src/main/php/text/ical/Creation.class.php index d4499fc..a6a44c2 100755 --- a/src/main/php/text/ical/Creation.class.php +++ b/src/main/php/text/ical/Creation.class.php @@ -8,7 +8,7 @@ class Creation { const CHECK = true; private static $definitions= [null, null, [ - 'calendar' => [Calendar::class, ['events' => 'event'], [ + 'calendar' => [Calendar::class, ['events' => 'event', 'timezones' => 'timezone'], [ 'event' => [Event::class, ['attendees' => 'attendee'], [ 'organizer' => [Organizer::class], 'attendee' => [Attendee::class], @@ -16,14 +16,14 @@ class Creation { 'description' => [Text::class], 'comment' => [Text::class], 'location' => [Text::class], - 'dtstart' => [Date::class], - 'dtstamp' => [Date::class], - 'dtend' => [Date::class], + 'dtstart' => [IDate::class], + 'dtstamp' => [IDate::class], + 'dtend' => [IDate::class], 'alarm' => [Alarm::class, null, [ 'trigger' => [Trigger::class] ]] ]], - 'timezone' => [TimeZone::class, null, [ + 'timezone' => [ITimeZone::class, null, [ 'standard' => [TimeZoneInfo::class], 'daylight' => [TimeZoneInfo::class], ]], diff --git a/src/main/php/text/ical/Event.class.php b/src/main/php/text/ical/Event.class.php index e01258d..4502d43 100755 --- a/src/main/php/text/ical/Event.class.php +++ b/src/main/php/text/ical/Event.class.php @@ -14,9 +14,9 @@ class Event implements IObject { * @param text.ical.Text $description * @param text.ical.Text $summary * @param text.ical.Text $comment - * @param text.ical.Date $dtstart - * @param text.ical.Date $dtend - * @param text.ical.Date $dtstamp + * @param text.ical.IDate $dtstart + * @param text.ical.IDate $dtend + * @param text.ical.IDate $dtstamp * @param string $uid * @param string $class * @param string $priority @@ -60,13 +60,13 @@ public function summary() { return $this->summary; } /** @return text.ical.Text */ public function comment() { return $this->comment; } - /** @return text.ical.Date */ + /** @return text.ical.IDate */ public function dtstart() { return $this->dtstart; } - /** @return text.ical.Date */ + /** @return text.ical.IDate */ public function dtend() { return $this->dtend; } - /** @return text.ical.Date */ + /** @return text.ical.IDate */ public function dtstamp() { return $this->dtstamp; } /** @return string */ diff --git a/src/main/php/text/ical/Date.class.php b/src/main/php/text/ical/IDate.class.php similarity index 97% rename from src/main/php/text/ical/Date.class.php rename to src/main/php/text/ical/IDate.class.php index d15c388..45f7901 100755 --- a/src/main/php/text/ical/Date.class.php +++ b/src/main/php/text/ical/IDate.class.php @@ -2,7 +2,7 @@ use util\Objects; -class Date implements IObject { +class IDate implements IObject { private $tzid, $value; /** diff --git a/src/main/php/text/ical/TimeZone.class.php b/src/main/php/text/ical/ITimeZone.class.php similarity index 69% rename from src/main/php/text/ical/TimeZone.class.php rename to src/main/php/text/ical/ITimeZone.class.php index edc7303..32b17d7 100755 --- a/src/main/php/text/ical/TimeZone.class.php +++ b/src/main/php/text/ical/ITimeZone.class.php @@ -1,8 +1,8 @@ standard= $value; return $this; } public function daylight($value) { $this->daylight= $value; return $this; } public function create() { - return new TimeZone($this->tzid, $this->standard, $this->daylight); + return new ITimeZone($this->tzid, $this->standard, $this->daylight); } }; } + /** + * Converts a date + * + * @param string $input `YYYYMMDD"T"HHMMSS` + * @return util.Date + */ + public function convert($input) { + $date= sscanf($input, '%4d%2d%2dT%2d%2d%d'); + + $rel= gmmktime($date[3], $date[4], $date[5], $date[1], $date[2], $date[0]); + $daylight= $this->daylight->start($date[0]); + $standard= $this->standard->start($date[0]); + + if ($rel >= $standard + $this->standard->adjust() || $rel < $daylight + $this->daylight->adjust()) { + return new Date(gmdate('Y-m-d H:i:s'.$this->standard->tzoffsetto(), $rel)); + } else { + return new Date(gmdate('Y-m-d H:i:s'.$this->daylight->tzoffsetto(), $rel)); + } + } + /** * Write this object * diff --git a/src/main/php/text/ical/TimeZoneInfo.class.php b/src/main/php/text/ical/TimeZoneInfo.class.php index 3508353..42b0daf 100755 --- a/src/main/php/text/ical/TimeZoneInfo.class.php +++ b/src/main/php/text/ical/TimeZoneInfo.class.php @@ -1,5 +1,6 @@ tzoffsetto, "%c%2d:%d", $sign, $h, $m); + return ('-' === $sign ? -1 : 1) * ($h * 3600 + $m * 60); + } + + /** @return int */ + public function adjust() { + sscanf($this->tzoffsetfrom, "%c%2d:%d", $sign, $h, $m); + $from= ('-' === $sign ? -1 : 1) * ($h * 3600 + $m * 60); + sscanf($this->tzoffsetto, "%c%2d:%d", $sign, $h, $m); + $to= ('-' === $sign ? -1 : 1) * ($h * 3600 + $m * 60); + return $to - $from; + } + + /** + * Returns start of this time for a given year in GMT + * + * @param int $year + * @return int + */ + public function start($year) { + static $days= ['MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6, 'SU' => 0]; + + $start= sscanf($this->dtstart, '%4d%2d%2dT%2d%2d%d'); + if (null === $this->rrule) { + return gmmktime($start[3], $start[4], $start[5], $start[1], $start[2], $year); + } else { + + // RRULE: https://tools.ietf.org/html/rfc5545#section-3.3.10 + $r= []; + foreach (explode(';', $this->rrule) as $attributes) { + sscanf($attributes, "%[^=]=%[^\r]", $key, $value); + $r[$key]= $value; + } + + if ('YEARLY' !== $r['FREQ']) { + throw new IllegalStateException('Unexpected frequency '.$r['FREQ']); + } + + // -1SU = "Last Sunday in month" + // 1SU = "First Sunday in month" + // 2SU = "Second Sunday in month" + if ('-' === $r['BYDAY'][0]) { + $month= (int)$r['BYMONTH'] + 1; + $by= $days[substr($r['BYDAY'], 2)]; + $last= idate('w', gmmktime(0, 0, 0, $month, -1, $year)); + $day= $by - $last - 1; + } else { + $month= (int)$r['BYMONTH']; + $by= $days[substr($r['BYDAY'], 1)]; + $first= idate('w', gmmktime(0, 0, 0, $month, 0, $year)); + $day= $by + $first + 1 + 7 * ($r['BYDAY'][0] - 1); + } + + return gmmktime($start[3], $start[4], $start[5], $month, $day, $year); + } + } + /** * Write this object * diff --git a/src/test/php/text/ical/unittest/Fixtures.class.php b/src/test/php/text/ical/unittest/Fixtures.class.php index 8038610..b11142c 100755 --- a/src/test/php/text/ical/unittest/Fixtures.class.php +++ b/src/test/php/text/ical/unittest/Fixtures.class.php @@ -1,8 +1,9 @@ value('MAILTO:attendee3@example.com') ->create() ]) - ->dtstart(new Date('W. Europe Standard Time', '20160524T183000')) - ->dtend(new Date('W. Europe Standard Time', '20160524T190000')) + ->dtstart(new IDate('W. Europe Standard Time', '20160524T183000')) + ->dtend(new IDate('W. Europe Standard Time', '20160524T190000')) ->location(new Text('de-DE', 'BS 50 EG 0102')) ->comment(new Text('de-DE', "\n")) ->summary(new Text('de-DE', 'Treffen')) @@ -106,7 +107,7 @@ static function __static() { END:VTIMEZONE END:VCALENDAR ', - Calendar::with()->timezone(new TimeZone( + Calendar::with()->timezones([new ITimeZone( 'W. Europe Standard Time', TimeZoneInfo::with() ->dtstart('16010101T030000') @@ -121,7 +122,7 @@ static function __static() { ->tzoffsetto('+0200') ->rrule('FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3') ->create() - )) + )]) ->create() ); diff --git a/src/test/php/text/ical/unittest/ICalendarTest.class.php b/src/test/php/text/ical/unittest/ICalendarTest.class.php index 85c8717..fb13fa0 100755 --- a/src/test/php/text/ical/unittest/ICalendarTest.class.php +++ b/src/test/php/text/ical/unittest/ICalendarTest.class.php @@ -4,6 +4,7 @@ use lang\{ElementNotFoundException, FormatException}; use text\ical\ICalendar; use unittest\{Expect, Test, Values}; +use util\Date; class ICalendarTest extends \unittest\TestCase { @@ -178,4 +179,43 @@ public function escaping() { ); $this->assertEquals("BS50, 1303; coolest room\\on earth\n", $calendar->events()->first()->summary()->value()); } + + #[Test] + public function convert_date_without_timezone() { + $calendar= (new ICalendar())->read( + "BEGIN:VCALENDAR\r\n". + "BEGIN:VEVENT\r\n". + "DTSTART:19970714T173000Z\r\n". + "END:VEVENT\r\n". + "END:VCALENDAR" + ); + $this->assertEquals(new Date('1997-07-14 17:30:00 GMT'), $calendar->date($calendar->events()->first()->dtstart())); + } + + #[Test] + public function convert_date_with_timezone() { + $calendar= (new ICalendar())->read( + "BEGIN:VCALENDAR\r\n". + "BEGIN:VTIMEZONE\r\n". + "TZID:W. Europe Standard Time\r\n". + "BEGIN:STANDARD\r\n". + "DTSTART:16010101T030000\r\n". + "TZOFFSETFROM:+0200\r\n". + "TZOFFSETTO:+0100\r\n". + "RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10\r\n". + "END:STANDARD\r\n". + "BEGIN:DAYLIGHT\r\n". + "DTSTART:16010101T020000\r\n". + "TZOFFSETFROM:+0100\r\n". + "TZOFFSETTO:+0200\r\n". + "RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3\r\n". + "END:DAYLIGHT\r\n". + "END:VTIMEZONE\r\n". + "BEGIN:VEVENT\r\n". + "DTSTART;TZID=W. Europe Standard Time:19970714T173000Z\r\n". + "END:VEVENT\r\n". + "END:VCALENDAR" + ); + $this->assertEquals(new Date('1997-07-14 17:30:00 Europe/Berlin'), $calendar->date($calendar->events()->first()->dtstart())); + } } \ No newline at end of file diff --git a/src/test/php/text/ical/unittest/TimeZonesTest.class.php b/src/test/php/text/ical/unittest/TimeZonesTest.class.php new file mode 100755 index 0000000..2890d15 --- /dev/null +++ b/src/test/php/text/ical/unittest/TimeZonesTest.class.php @@ -0,0 +1,135 @@ +read($source)->timezones()[0]; + } + + /** @return iterable */ + private function europeBerlin() { + yield ['20180101T000000', '2018-01-01 00:00:00 Europe/Berlin', 'Standard']; + yield ['20180330T192800', '2018-03-30 19:28:00 Europe/Berlin', 'Daylight']; + yield ['20180325T015959', '2018-03-25 01:59:59 Europe/Berlin', 'One second before transition to daylight']; + yield ['20180325T020000', '2018-03-25 03:00:00 Europe/Berlin', 'Transition to daylight']; + yield ['20180325T020001', '2018-03-25 03:00:01 Europe/Berlin', 'One second after transition to daylight']; + yield ['20180325T030001', '2018-03-25 03:00:01 Europe/Berlin', 'One second after transition to daylight']; + yield ['20181028T025959', '2018-10-28 02:59:59 Europe/Berlin', 'One second before transition to standard']; + yield ['20181028T030000', '2018-10-28 03:00:00 Europe/Berlin', 'Transition to standard']; + yield ['20181028T030001', '2018-10-28 03:00:01 Europe/Berlin', 'One second after transition to standard']; + } + + /** @return iterable */ + private function americaNY() { + yield ['20180101T000000', '2018-01-01 00:00:00 America/New_York', 'Standard']; + yield ['20180401T115500', '2018-04-01 11:55:00 America/New_York', 'Daylight']; + yield ['20180311T015959', '2018-03-11 01:59:59 America/New_York', 'One second before transition to daylight']; + yield ['20180311T020000', '2018-03-11 03:00:00 America/New_York', 'Transition to daylight']; + yield ['20180311T020001', '2018-03-11 03:00:01 America/New_York', 'One second after transition to daylight']; + yield ['20180311T030001', '2018-03-11 03:00:01 America/New_York', 'One second after transition to daylight']; + yield ['20181104T025959', '2018-11-04 02:59:59 America/New_York', 'One second before transition to standard']; + yield ['20181104T030000', '2018-11-04 03:00:00 America/New_York', 'Transition to standard']; + yield ['20181104T030001', '2018-11-04 03:00:01 America/New_York', 'One second after transition to standard']; + } + + /** @return iterable */ + private function europeParis() { + yield ['20070101T000000', '2007-01-01 00:00:00 Europe/Paris', 'Standard']; + yield ['20061029T020000', '2006-10-29 02:00:00 Europe/Paris', 'Standard']; + yield ['20070325T020000', '2007-03-25 02:00:00 Europe/Paris', 'Daylight']; + } + + #[Test, Values('europeBerlin')] + public function west_europe_standard_time_with_rrule($input, $expected) { + $tz= $this->parse(' + BEGIN:VTIMEZONE + TZID:W. Europe Standard Time + BEGIN:STANDARD + DTSTART:16010101T030000 + TZOFFSETFROM:+0200 + TZOFFSETTO:+0100 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:16010101T020000 + TZOFFSETFROM:+0100 + TZOFFSETTO:+0200 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 + END:DAYLIGHT + END:VTIMEZONE + '); + $this->assertEquals(new Date($expected), $tz->convert($input)); + } + + #[Test, Values('americaNY')] + public function new_york_time_with_rrule($input, $expected) { + $tz= $this->parse(' + BEGIN:VTIMEZONE + TZID:America/New_York + LAST-MODIFIED:20050809T050000Z + TZURL:http://zones.example.com/tz/America-New_York.ics + BEGIN:STANDARD + DTSTART:20071104T020000 + RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU + TZOFFSETFROM:-0400 + TZOFFSETTO:-0500 + TZNAME:EST + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:20070311T020000 + RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU + TZOFFSETFROM:-0500 + TZOFFSETTO:-0400 + TZNAME:EDT + END:DAYLIGHT + END:VTIMEZONE + '); + $this->assertEquals(new Date($expected), $tz->convert($input)); + } + + #[Test, Values('europeParis')] + public function europe_paris_without_rrule($input, $expected) { + $tz= $this->parse(' + BEGIN:VTIMEZONE + TZID:Europe/Paris + LAST-MODIFIED:20070430T230046Z + BEGIN:STANDARD + DTSTART:20061029T010000 + TZOFFSETTO:+0100 + TZOFFSETFROM:+0000 + TZNAME:CET + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:20070325T020000 + TZOFFSETTO:+0200 + TZOFFSETFROM:+0100 + TZNAME:CEST + END:DAYLIGHT + END:VTIMEZONE + '); + $this->assertEquals(new Date($expected), $tz->convert($input)); + } +} \ No newline at end of file