Skip to content

Commit

Permalink
Allow easier extension of the timezone guessing
Browse files Browse the repository at this point in the history
This will ease customization of timezone-guessing as it is now gets easier
to extend that process with own implementations (as long as they
implement the appropriate interface)

This is espechially necessary when wanting to actually guess a timezone
via the rules defined in the VTIMEZONE-entry (which is currently not
done)
  • Loading branch information
heiglandreas authored and phil-davis committed Nov 15, 2021
1 parent a7460c5 commit 87c8def
Show file tree
Hide file tree
Showing 9 changed files with 496 additions and 132 deletions.
261 changes: 134 additions & 127 deletions lib/TimeZoneUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

namespace Sabre\VObject;

use DateTimeZone;
use InvalidArgumentException;
use Sabre\VObject\TimezoneGuesser\FindFromOffset;
use Sabre\VObject\TimezoneGuesser\FindFromTimezoneIdentifier;
use Sabre\VObject\TimezoneGuesser\FindFromTimezoneMap;
use Sabre\VObject\TimezoneGuesser\GuessFromLicEntry;
use Sabre\VObject\TimezoneGuesser\GuessFromMsTzId;
use Sabre\VObject\TimezoneGuesser\TimezoneFinder;
use Sabre\VObject\TimezoneGuesser\TimezoneGuesser;

/**
* Time zone name translation.
*
Expand All @@ -14,17 +24,136 @@
*/
class TimeZoneUtil
{
/** @var self */
private static $instance = null;

/** @var TimezoneGuesser[] */
private $timezoneGuessers = [];

/** @var TimezoneFinder[] */
private $timezoneFinders = [];

private function __construct()
{
$this->addGuesser('lic', new GuessFromLicEntry());
$this->addGuesser('msTzId', new GuessFromMsTzId());
$this->addFinder('tzid', new FindFromTimezoneIdentifier());
$this->addFinder('tzmap', new FindFromTimezoneMap());
$this->addFinder('offset', new FindFromOffset());
}

private static function getInstance(): self
{
if (null === self::$instance) {
self::$instance = new self();
}

return self::$instance;
}

private function addGuesser(string $key, TimezoneGuesser $guesser): void
{
$this->timezoneGuessers[$key] = $guesser;
}

private function addFinder(string $key, TimezoneFinder $finder): void
{
$this->timezoneFinders[$key] = $finder;
}

/**
* This method will try to find out the correct timezone for an iCalendar
* date-time value.
*
* You must pass the contents of the TZID parameter, as well as the full
* calendar.
*
* If the lookup fails, this method will return the default PHP timezone
* (as configured using date_default_timezone_set, or the date.timezone ini
* setting).
*
* Alternatively, if $failIfUncertain is set to true, it will throw an
* exception if we cannot accurately determine the timezone.
*/
private function findTimeZone(string $tzid, Component $vcalendar = null, bool $failIfUncertain = false): DateTimeZone
{
foreach ($this->timezoneFinders as $timezoneFinder) {
$timezone = $timezoneFinder->find($tzid, $failIfUncertain);
if (!$timezone instanceof DateTimeZone) {
continue;
}

return $timezone;
}

if ($vcalendar) {
// If that didn't work, we will scan VTIMEZONE objects
foreach ($vcalendar->select('VTIMEZONE') as $vtimezone) {
if ((string) $vtimezone->TZID === $tzid) {
foreach ($this->timezoneGuessers as $timezoneGuesser) {
$timezone = $timezoneGuesser->guess($vtimezone, $failIfUncertain);
if (!$timezone instanceof DateTimeZone) {
continue;
}

return $timezone;
}
}
}
}

if ($failIfUncertain) {
throw new InvalidArgumentException('We were unable to determine the correct PHP timezone for tzid: '.$tzid);
}

// If we got all the way here, we default to whatever has been set as the PHP default timezone.
return new DateTimeZone(date_default_timezone_get());
}

public static function addTimezoneGuesser(string $key, TimezoneGuesser $guesser): void
{
self::getInstance()->addGuesser($key, $guesser);
}

public static function addTimezoneFinder(string $key, TimezoneFinder $finder): void
{
self::getInstance()->addFinder($key, $finder);
}

/**
* @param string $tzid
* @param false $failIfUncertain
*
* @return DateTimeZone
*/
public static function getTimeZone($tzid, Component $vcalendar = null, $failIfUncertain = false)
{
return self::getInstance()->findTimeZone($tzid, $vcalendar, $failIfUncertain);
}

public static function clean(): void
{
self::$instance = null;
}

// Keeping things for backwards compatibility
/**
* @var array|null
*
* @deprecated
*/
public static $map = null;

/**
* List of microsoft exchange timezone ids.
*
* Source: http://msdn.microsoft.com/en-us/library/aa563018(loband).aspx
*
* @deprecated
*/
public static $microsoftExchangeMap = [
0 => 'UTC',
31 => 'Africa/Casablanca',

// Insanely, id #2 is used for both Europe/Lisbon, and Europe/Sarajevo.
// I'm not even kidding.. We handle this special case in the
// getTimeZone method.
Expand Down Expand Up @@ -103,135 +232,11 @@ class TimeZoneUtil
39 => 'Pacific/Kwajalein',
];

/**
* This method will try to find out the correct timezone for an iCalendar
* date-time value.
*
* You must pass the contents of the TZID parameter, as well as the full
* calendar.
*
* If the lookup fails, this method will return the default PHP timezone
* (as configured using date_default_timezone_set, or the date.timezone ini
* setting).
*
* Alternatively, if $failIfUncertain is set to true, it will throw an
* exception if we cannot accurately determine the timezone.
*
* @param string $tzid
* @param Sabre\VObject\Component $vcalendar
*
* @return \DateTimeZone
*/
public static function getTimeZone($tzid, Component $vcalendar = null, $failIfUncertain = false)
{
// First we will just see if the tzid is a support timezone identifier.
//
// The only exception is if the timezone starts with (. This is to
// handle cases where certain microsoft products generate timezone
// identifiers that for instance look like:
//
// (GMT+01.00) Sarajevo/Warsaw/Zagreb
//
// Since PHP 5.5.10, the first bit will be used as the timezone and
// this method will return just GMT+01:00. This is wrong, because it
// doesn't take DST into account.
if ('(' !== $tzid[0]) {
// PHP has a bug that logs PHP warnings even it shouldn't:
// https://bugs.php.net/bug.php?id=67881
//
// That's why we're checking if we'll be able to successfully instantiate
// \DateTimeZone() before doing so. Otherwise we could simply instantiate
// and catch the exception.
$tzIdentifiers = \DateTimeZone::listIdentifiers();

try {
if (
(in_array($tzid, $tzIdentifiers)) ||
(preg_match('/^GMT(\+|-)([0-9]{4})$/', $tzid, $matches)) ||
(in_array($tzid, self::getIdentifiersBC()))
) {
return new \DateTimeZone($tzid);
}
} catch (\Exception $e) {
}
}

self::loadTzMaps();

// Next, we check if the tzid is somewhere in our tzid map.
if (isset(self::$map[$tzid])) {
return new \DateTimeZone(self::$map[$tzid]);
}

// Some Microsoft products prefix the offset first, so let's strip that off
// and see if it is our tzid map. We don't want to check for this first just
// in case there are overrides in our tzid map.
if (preg_match('/^\((UTC|GMT)(\+|\-)[\d]{2}\:[\d]{2}\) (.*)/', $tzid, $matches)) {
$tzidAlternate = $matches[3];
if (isset(self::$map[$tzidAlternate])) {
return new \DateTimeZone(self::$map[$tzidAlternate]);
}
}

// Maybe the author was hyper-lazy and just included an offset. We
// support it, but we aren't happy about it.
if (preg_match('/^GMT(\+|-)([0-9]{4})$/', $tzid, $matches)) {
// Note that the path in the source will never be taken from PHP 5.5.10
// onwards. PHP 5.5.10 supports the "GMT+0100" style of format, so it
// already gets returned early in this function. Once we drop support
// for versions under PHP 5.5.10, this bit can be taken out of the
// source.
// @codeCoverageIgnoreStart
return new \DateTimeZone('Etc/GMT'.$matches[1].ltrim(substr($matches[2], 0, 2), '0'));
// @codeCoverageIgnoreEnd
}

if ($vcalendar) {
// If that didn't work, we will scan VTIMEZONE objects
foreach ($vcalendar->select('VTIMEZONE') as $vtimezone) {
if ((string) $vtimezone->TZID === $tzid) {
// Some clients add 'X-LIC-LOCATION' with the olson name.
if (isset($vtimezone->{'X-LIC-LOCATION'})) {
$lic = (string) $vtimezone->{'X-LIC-LOCATION'};

// Libical generators may specify strings like
// "SystemV/EST5EDT". For those we must remove the
// SystemV part.
if ('SystemV/' === substr($lic, 0, 8)) {
$lic = substr($lic, 8);
}

return self::getTimeZone($lic, null, $failIfUncertain);
}
// Microsoft may add a magic number, which we also have an
// answer for.
if (isset($vtimezone->{'X-MICROSOFT-CDO-TZID'})) {
$cdoId = (int) $vtimezone->{'X-MICROSOFT-CDO-TZID'}->getValue();

// 2 can mean both Europe/Lisbon and Europe/Sarajevo.
if (2 === $cdoId && false !== strpos((string) $vtimezone->TZID, 'Sarajevo')) {
return new \DateTimeZone('Europe/Sarajevo');
}

if (isset(self::$microsoftExchangeMap[$cdoId])) {
return new \DateTimeZone(self::$microsoftExchangeMap[$cdoId]);
}
}
}
}
}

if ($failIfUncertain) {
throw new \InvalidArgumentException('We were unable to determine the correct PHP timezone for tzid: '.$tzid);
}

// If we got all the way here, we default to UTC.
return new \DateTimeZone(date_default_timezone_get());
}

/**
* This method will load in all the tz mapping information, if it's not yet
* done.
*
* @deprecated
*/
public static function loadTzMaps()
{
Expand All @@ -257,6 +262,8 @@ public static function loadTzMaps()
* (See timezonedata/php-bc.php and timezonedata php-workaround.php)
*
* @return array
*
* @deprecated
*/
public static function getIdentifiersBC()
{
Expand Down
31 changes: 31 additions & 0 deletions lib/TimezoneGuesser/FindFromOffset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Sabre\VObject\TimezoneGuesser;

use DateTimeZone;

/**
* Some clients add 'X-LIC-LOCATION' with the olson name.
*/
class FindFromOffset implements TimezoneFinder
{
public function find(string $tzid, bool $failIfUncertain = false): ?DateTimeZone
{
// Maybe the author was hyper-lazy and just included an offset. We
// support it, but we aren't happy about it.
if (preg_match('/^GMT(\+|-)([0-9]{4})$/', $tzid, $matches)) {
// Note that the path in the source will never be taken from PHP 5.5.10
// onwards. PHP 5.5.10 supports the "GMT+0100" style of format, so it
// already gets returned early in this function. Once we drop support
// for versions under PHP 5.5.10, this bit can be taken out of the
// source.
// @codeCoverageIgnoreStart
return new DateTimeZone('Etc/GMT'.$matches[1].ltrim(substr($matches[2], 0, 2), '0'));
// @codeCoverageIgnoreEnd
}

return null;
}
}
Loading

0 comments on commit 87c8def

Please sign in to comment.