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