1<?php 2/** 3 * Horde_Mapi_Util_Timezone:: 4 * 5 * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 6 * @copyright 2009-2017 Horde LLC (http://www.horde.org) 7 * @author Michael J Rubinsky <mrubinsk@horde.org> 8 * @package Mapi_Utils 9 */ 10/** 11 * Utility functions for dealing with Microsoft MAPI Timezone format. 12 * 13 * Copyright 2009-2017 Horde LLC (http://www.horde.org/) 14 * 15 * See the enclosed file COPYING for license information (LGPL). If you 16 * did not receive this file, see http://www.horde.org/licenses/lgpl21. 17 * 18 * Code dealing with searching for a timezone identifier from an AS timezone 19 * blob inspired by code in the Tine20 Project (http://tine20.org). 20 * 21 * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 22 * @copyright 2009-2017 Horde LLC (http://www.horde.org) 23 * @author Michael J Rubinsky <mrubinsk@horde.org> 24 * @package Mapi_Utils 25 */ 26class Horde_Mapi_Timezone 27{ 28 /** 29 * Date to use as start date when iterating through offsets looking for a 30 * transition. 31 * 32 * @var Horde_Date 33 */ 34 protected $_startDate; 35 36 /** 37 * Convert a timezone from the MAPI base64 structure to a TZ offset 38 * hash. 39 * 40 * @param base64 encoded timezone structure defined by MS as: 41 * <pre> 42 * typedef struct TIME_ZONE_INFORMATION { 43 * LONG Bias; 44 * WCHAR StandardName[32]; 45 * SYSTEMTIME StandardDate; 46 * LONG StandardBias; 47 * WCHAR DaylightName[32]; 48 * SYSTEMTIME DaylightDate; 49 * LONG DaylightBias;}; 50 * </pre> 51 * 52 * With the SYSTEMTIME format being: 53 * <pre> 54 * typedef struct _SYSTEMTIME { 55 * WORD wYear; 56 * WORD wMonth; 57 * WORD wDayOfWeek; 58 * WORD wDay; (Used as week number) 59 * WORD wHour; 60 * WORD wMinute; 61 * WORD wSecond; 62 * WORD wMilliseconds; 63 * } SYSTEMTIME, *PSYSTEMTIME; 64 * </pre> 65 * 66 * See: http://msdn.microsoft.com/en-us/library/ms724950%28VS.85%29.aspx 67 * and: http://msdn.microsoft.com/en-us/library/ms725481%28VS.85%29.aspx 68 * 69 * @return array Hash of offset information 70 */ 71 public static function getOffsetsFromSyncTZ($data) 72 { 73 if (version_compare(PHP_VERSION, '5.5', '>=')) { 74 $format = 'lbias/Z64stdname/vstdyear/vstdmonth/vstdday/vstdweek/vstdhour/vstdminute/vstdsecond/vstdmillis/' 75 . 'lstdbias/Z64dstname/vdstyear/vdstmonth/vdstday/vdstweek/vdsthour/vdstminute/vdstsecond/vdstmillis/' 76 . 'ldstbias'; 77 } else { 78 $format = 'lbias/a64stdname/vstdyear/vstdmonth/vstdday/vstdweek/vstdhour/vstdminute/vstdsecond/vstdmillis/' 79 . 'lstdbias/a64dstname/vdstyear/vdstmonth/vdstday/vdstweek/vdsthour/vdstminute/vdstsecond/vdstmillis/' 80 . 'ldstbias'; 81 } 82 $tz = unpack($format, base64_decode($data)); 83 $tz['timezone'] = $tz['bias']; 84 $tz['timezonedst'] = $tz['dstbias']; 85 86 if (!Horde_Mapi::isLittleEndian()) { 87 $tz['bias'] = Horde_Mapi::chbo($tz['bias']); 88 $tz['stdbias'] = Horde_Mapi::chbo($tz['stdbias']); 89 $tz['dstbias'] = Horde_Mapi::chbo($tz['dstbias']); 90 } 91 92 return $tz; 93 } 94 95 /** 96 * Build an MAPI TZ blob given a TZ Offset hash. 97 * 98 * @param array $offsets A TZ offset hash 99 * 100 * @return string A base64_encoded MAPI Timezone structure suitable 101 * for transmitting via wbxml. 102 */ 103 public static function getSyncTZFromOffsets(array $offsets) 104 { 105 if (!Horde_Mapi::isLittleEndian()) { 106 $offsets['bias'] = Horde_Mapi::chbo($offsets['bias']); 107 $offsets['stdbias'] = Horde_Mapi::chbo($offsets['stdbias']); 108 $offsets['dstbias'] = Horde_Mapi::chbo($offsets['dstbias']); 109 } 110 111 $packed = pack('la64vvvvvvvvla64vvvvvvvvl', 112 $offsets['bias'], '', 0, $offsets['stdmonth'], $offsets['stdday'], $offsets['stdweek'], $offsets['stdhour'], $offsets['stdminute'], $offsets['stdsecond'], $offsets['stdmillis'], 113 $offsets['stdbias'], '', 0, $offsets['dstmonth'], $offsets['dstday'], $offsets['dstweek'], $offsets['dsthour'], $offsets['dstminute'], $offsets['dstsecond'], $offsets['dstmillis'], 114 $offsets['dstbias']); 115 116 return base64_encode($packed); 117 } 118 119 /** 120 * Create a offset hash suitable for use in ActiveSync transactions 121 * 122 * @param Horde_Date $date A date object representing the date to base the 123 * the tz data on. 124 * 125 * @return array An offset hash. 126 */ 127 public static function getOffsetsFromDate(Horde_Date $date) 128 { 129 $offsets = array( 130 'bias' => 0, 131 'stdname' => '', 132 'stdyear' => 0, 133 'stdmonth' => 0, 134 'stdday' => 0, 135 'stdweek' => 0, 136 'stdhour' => 0, 137 'stdminute' => 0, 138 'stdsecond' => 0, 139 'stdmillis' => 0, 140 'stdbias' => 0, 141 'dstname' => '', 142 'dstyear' => 0, 143 'dstmonth' => 0, 144 'dstday' => 0, 145 'dstweek' => 0, 146 'dsthour' => 0, 147 'dstminute' => 0, 148 'dstsecond' => 0, 149 'dstmillis' => 0, 150 'dstbias' => 0 151 ); 152 153 $timezone = $date->toDateTime()->getTimezone(); 154 // If transition parsing failed, we won't have a multi-element array. 155 $transitions = self::_getTransitions($timezone, $date); 156 if (!empty($transitions)) { 157 list($std, $dst) = self::_getTransitions($timezone, $date); 158 } 159 if (!empty($std)) { 160 $offsets['bias'] = $std['offset'] / 60 * -1; 161 if ($dst) { 162 $offsets = self::_generateOffsetsForTransition($offsets, $std, 'std'); 163 $offsets = self::_generateOffsetsForTransition($offsets, $dst, 'dst'); 164 $offsets['stdhour'] += $dst['offset'] / 3600; 165 $offsets['dsthour'] += $std['offset'] / 3600; 166 $offsets['dstbias'] = ($dst['offset'] - $std['offset']) / 60 * -1; 167 } 168 } 169 170 return $offsets; 171 } 172 173 /** 174 * Get the transition data for moving from DST to STD time. 175 * 176 * @param DateTimeZone $timezone The timezone to get the transition for 177 * @param Horde_Date $date The date to start from. Really only the 178 * year we are interested in is needed. 179 * 180 * @return array An array containing the the STD and DST transitions 181 */ 182 protected static function _getTransitions(DateTimeZone $timezone, Horde_Date $date) 183 { 184 185 $std = $dst = array(); 186 $transitions = $timezone->getTransitions( 187 mktime(0, 0, 0, 12, 1, $date->year - 1), 188 mktime(24, 0, 0, 12, 31, $date->year) 189 ); 190 191 if ($transitions === false) { 192 return array(); 193 } 194 195 foreach ($transitions as $i => $transition) { 196 try { 197 $d = new Horde_Date($transition['time']); 198 $d->setTimezone('UTC'); 199 } catch (Exception $e) { 200 continue; 201 } 202 if (($d->format('Y') == $date->format('Y')) && isset($transitions[$i + 1])) { 203 $next = new Horde_Date($transitions[$i + 1]['ts']); 204 if ($d->format('Y') == $next->format('Y')) { 205 $dst = $transition['isdst'] ? $transition : $transitions[$i + 1]; 206 $std = $transition['isdst'] ? $transitions[$i + 1] : $transition; 207 } else { 208 $dst = $transition['isdst'] ? $transition: null; 209 $std = $transition['isdst'] ? null : $transition; 210 } 211 break; 212 } elseif ($i == count($transitions) - 1) { 213 $std = $transition; 214 } 215 } 216 217 return array($std, $dst); 218 } 219 220 /** 221 * Calculate the offsets for the specified transition 222 * 223 * @param array $offsets A TZ offset hash 224 * @param array $transition A transition hash 225 * @param string $type Transition type - dst or std 226 * 227 * @return array A populated offset hash 228 */ 229 protected static function _generateOffsetsForTransition(array $offsets, array $transition, $type) 230 { 231 // We can't use Horde_Date directly here, since it is unable to 232 // properly convert to UTC from local ON the exact hour of a std -> dst 233 // transition. This is due to a conversion to DateTime in the localtime 234 // zone internally before the timezone change is applied 235 $transitionDate = new DateTime($transition['time']); 236 $transitionDate->setTimezone(new DateTimeZone('UTC')); 237 $transitionDate = new Horde_Date($transitionDate); 238 $offsets[$type . 'month'] = $transitionDate->format('n'); 239 $offsets[$type . 'day'] = $transitionDate->format('w'); 240 $offsets[$type . 'minute'] = (int)$transitionDate->format('i'); 241 $offsets[$type . 'hour'] = (int)$transitionDate->format('H'); 242 for ($i = 5; $i > 0; $i--) { 243 if (self::_isNthOcurrenceOfWeekdayInMonth($transition['ts'], $i)) { 244 $offsets[$type . 'week'] = $i; 245 break; 246 } 247 } 248 249 return $offsets; 250 } 251 252 /** 253 * Attempt to guess the timezone identifier from the $offsets array. 254 * 255 * Since it's impossible to know exactly which olson timezone name a 256 * specific set of offsets represent (multiple timezone names may be 257 * described by the same offsets for any given year) we allow passing an 258 * expected timezone. If this matches one of the timezones that matches the 259 * offsets, we return that. Otherwise, we attempt to get the full timezone 260 * name from Horde_Date and if that fails, return the abbreviated timezone 261 * name of the first timezone that matches the provided offsets. 262 * 263 * @param array|string $offsets The timezone to check. Either an array 264 * of offsets or an activesynz tz blob. 265 * @param string $expectedTimezone The expected timezone. If not empty, and 266 * present in the results, will return. 267 * 268 * @return string The timezone identifier. 269 */ 270 public function getTimezone($offsets, $expectedTimezone = null) 271 { 272 $timezones = $this->getListOfTimezones($offsets, $expectedTimezone); 273 if (isset($timezones[$expectedTimezone])) { 274 return $expectedTimezone; 275 } else { 276 return Horde_Date::getTimezoneAlias(current($timezones)); 277 } 278 } 279 280 /** 281 * Get the list of timezone identifiers that match the given offsets, having 282 * a preference for $expectedTimezone if it's present in the results. 283 * 284 * @param array|string $offsets Either an offset array, or a AS timezone 285 * structure. 286 * @param string $expectedTimezone The expected timezone. 287 * 288 * @return array An array of timezone identifiers 289 */ 290 public function getListOfTimezones($offsets, $expectedTimezone = null) 291 { 292 if (is_string($offsets)) { 293 $offsets = self::getOffsetsFromSyncTZ($offsets); 294 } 295 $this->_setDefaultStartDate($offsets); 296 $timezones = array(); 297 foreach (DateTimeZone::listIdentifiers() as $timezoneIdentifier) { 298 $timezone = new DateTimeZone($timezoneIdentifier); 299 if (false !== ($matchingTransition = $this->_checkTimezone($timezone, $offsets))) { 300 if ($timezoneIdentifier == $expectedTimezone) { 301 $timezones = array($timezoneIdentifier => $matchingTransition['abbr']); 302 break; 303 } else { 304 $timezones[$timezoneIdentifier] = $matchingTransition['abbr']; 305 } 306 } 307 } 308 309 if (empty($timezones)) { 310 throw new Horde_Mapi_Exception('No timezone found for the given offsets'); 311 } 312 313 return $timezones; 314 } 315 316 /** 317 * Set default value for $_startDate. 318 * 319 * Tries to guess the correct startDate depending on object property. Falls 320 * back to current date. 321 * 322 * @param array $offsets Offsets may be avaluated for a given start year 323 */ 324 protected function _setDefaultStartDate(array $offsets = null) 325 { 326 if (!empty($this->_startDate)) { 327 return; 328 } 329 330 if (!empty($offsets['stdyear'])) { 331 $this->_startDate = new Horde_Date($offsets['stdyear'] . '-01-01'); 332 } else { 333 $start = new Horde_Date(time()); 334 $start->year--; 335 $this->_startDate = $start; 336 } 337 } 338 339 /** 340 * Check if the given timezone matches the offsets and also evaluate the 341 * daylight saving time transitions for this timezone if necessary. 342 * 343 * @param DateTimeZone $timezone The timezone to check. 344 * @param array $offsets The offsets to check. 345 * 346 * @return array|boolean An array of transition data or false if timezone 347 * does not match offset. 348 */ 349 protected function _checkTimezone(DateTimeZone $timezone, array $offsets) 350 { 351 list($std, $dst) = $this->_getTransitions($timezone, $this->_startDate); 352 if ($this->_checkTransition($std, $dst, $offsets)) { 353 return $std; 354 } 355 356 return false; 357 } 358 359 /** 360 * Check if the given standardTransition and daylightTransition match to the 361 * given offsets. 362 * 363 * @param array $std The Standard transition date. 364 * @param array $dst The DST transition date. 365 * @param array $offsets The offsets to check. 366 * 367 * @return boolean 368 */ 369 protected function _checkTransition(array $std, array $dst, array $offsets) 370 { 371 if (empty($std) || empty($offsets)) { 372 return false; 373 } 374 375 $standardOffset = ($offsets['bias'] + $offsets['stdbias']) * 60 * -1; 376 377 // check each condition in a single if statement and break the chain 378 // when one condition is not met - for performance reasons 379 if ($standardOffset == $std['offset']) { 380 if ((empty($offsets['dstmonth']) && (empty($dst) || empty($dst['isdst']))) || 381 (empty($dst) && !empty($offsets['dstmonth']))) { 382 // Offset contains DST, but no dst to compare 383 return true; 384 } 385 $daylightOffset = ($offsets['bias'] + $offsets['dstbias']) * 60 * -1; 386 // the milestone is sending a positive value for daylightBias while it should send a negative value 387 $daylightOffsetMilestone = ($offsets['dstbias'] + ($offsets['dstbias'] * -1) ) * 60 * -1; 388 389 if ($daylightOffset == $dst['offset'] || $daylightOffsetMilestone == $dst['offset']) { 390 $standardParsed = new DateTime($std['time']); 391 $daylightParsed = new DateTime($dst['time']); 392 393 if ($standardParsed->format('n') == $offsets['stdmonth'] && 394 $daylightParsed->format('n') == $offsets['dstmonth'] && 395 $standardParsed->format('w') == $offsets['stdday'] && 396 $daylightParsed->format('w') == $offsets['dstday']) 397 { 398 return self::_isNthOcurrenceOfWeekdayInMonth($dst['ts'], $offsets['dstweek']) && 399 self::_isNthOcurrenceOfWeekdayInMonth($std['ts'], $offsets['stdweek']); 400 } 401 } 402 } 403 404 return false; 405 } 406 407 /** 408 * Test if the weekday of the given timestamp is the nth occurence of this 409 * weekday within its month, where '5' indicates the last occurrence even if 410 * there is less than five occurrences. 411 * 412 * @param integer $timestamp The timestamp to check. 413 * @param integer $occurence 1 to 5, where 5 indicates the final occurrence 414 * during the month if that day of the week does 415 * not occur 5 times 416 * @return boolean 417 */ 418 protected static function _isNthOcurrenceOfWeekdayInMonth($timestamp, $occurence) 419 { 420 $original = new Horde_Date($timestamp); 421 $original->setTimezone('UTC'); 422 if ($occurence == 5) { 423 $modified = $original->add(array('mday' => 7)); 424 return $modified->month > $original->month; 425 } else { 426 $modified = $original->sub(array('mday' => 7 * $occurence)); 427 $modified2 = $original->sub(array('mday' => 7 * ($occurence - 1))); 428 429 return $modified->month < $original->month && 430 $modified2->month == $original->month; 431 } 432 } 433 434} 435