From 489be2379ed97499973ed575b7fb804fe21d6522 Mon Sep 17 00:00:00 2001 From: Thomas Bruederli Date: Tue, 23 Jul 2013 17:14:11 +0200 Subject: [PATCH] Replace Horde_iCalendar with the SabreTooth VObject library --- plugins/libcalendaring/README | 10 +- plugins/libcalendaring/lib/Horde_Date.php | 1304 ------- .../libcalendaring/lib/Horde_iCalendar.php | 3300 ----------------- .../lib/Horde_iCalendar_timezone.diff | 23 - .../lib/Sabre/VObject/Component.php | 405 ++ .../lib/Sabre/VObject/Component/VAlarm.php | 108 + .../lib/Sabre/VObject/Component/VCalendar.php | 244 ++ .../lib/Sabre/VObject/Component/VCard.php | 107 + .../lib/Sabre/VObject/Component/VEvent.php | 70 + .../lib/Sabre/VObject/Component/VFreeBusy.php | 68 + .../lib/Sabre/VObject/Component/VJournal.php | 46 + .../lib/Sabre/VObject/Component/VTodo.php | 68 + .../lib/Sabre/VObject/DateTimeParser.php | 181 + .../lib/Sabre/VObject/Document.php | 109 + .../lib/Sabre/VObject/ElementList.php | 172 + .../lib/Sabre/VObject/FreeBusyGenerator.php | 322 ++ .../libcalendaring/lib/Sabre/VObject/Node.php | 187 + .../lib/Sabre/VObject/Parameter.php | 100 + .../lib/Sabre/VObject/ParseException.php | 12 + .../lib/Sabre/VObject/Property.php | 442 +++ .../lib/Sabre/VObject/Property/Compound.php | 125 + .../lib/Sabre/VObject/Property/DateTime.php | 245 ++ .../Sabre/VObject/Property/MultiDateTime.php | 180 + .../lib/Sabre/VObject/Reader.php | 223 ++ .../lib/Sabre/VObject/RecurrenceIterator.php | 1112 ++++++ .../lib/Sabre/VObject/Splitter/ICalendar.php | 111 + .../VObject/Splitter/SplitterInterface.php | 39 + .../lib/Sabre/VObject/Splitter/VCard.php | 76 + .../lib/Sabre/VObject/StringUtil.php | 61 + .../lib/Sabre/VObject/TimeZoneUtil.php | 482 +++ .../lib/Sabre/VObject/Version.php | 24 + .../lib/Sabre/VObject/includes.php | 41 + .../libcalendaring/lib/get_horde_icalendar.sh | 31 - .../libcalendaring/lib/get_sabre_vobject.sh | 10 + .../lib/sabre-vobject-2.1.0.tar.gz | Bin 0 -> 86355 bytes plugins/libcalendaring/libvcalendar.php | 1153 +++--- plugins/libcalendaring/tests/libvcalendar.php | 19 +- 37 files changed, 6036 insertions(+), 5174 deletions(-) delete mode 100644 plugins/libcalendaring/lib/Horde_Date.php delete mode 100644 plugins/libcalendaring/lib/Horde_iCalendar.php delete mode 100644 plugins/libcalendaring/lib/Horde_iCalendar_timezone.diff create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Component.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Component/VAlarm.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Component/VCalendar.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Component/VCard.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Component/VEvent.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Component/VFreeBusy.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Component/VJournal.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Component/VTodo.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/DateTimeParser.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Document.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/ElementList.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/FreeBusyGenerator.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Node.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Parameter.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/ParseException.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Property.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Property/Compound.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Property/DateTime.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Property/MultiDateTime.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Reader.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/RecurrenceIterator.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Splitter/ICalendar.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Splitter/SplitterInterface.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Splitter/VCard.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/StringUtil.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/TimeZoneUtil.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/Version.php create mode 100644 plugins/libcalendaring/lib/Sabre/VObject/includes.php delete mode 100755 plugins/libcalendaring/lib/get_horde_icalendar.sh create mode 100755 plugins/libcalendaring/lib/get_sabre_vobject.sh create mode 100644 plugins/libcalendaring/lib/sabre-vobject-2.1.0.tar.gz diff --git a/plugins/libcalendaring/README b/plugins/libcalendaring/README index c49a82f5..86e784d2 100644 --- a/plugins/libcalendaring/README +++ b/plugins/libcalendaring/README @@ -7,9 +7,9 @@ Provides utility functions for calendar-related modules such as * attachment handling * iCal parsing and exporting -iCal parsing is done with the help of the Horde_iCalendar class. A copy -of that class with all its dependencies is part of this package. In order -to update it, execute lib/get_horde_icalendar.sh > lib/Horde_iCalendar.php -Finally apply the patch lib/Horde_iCalendar_timezone.diff to fix timezone -handling when parsing iCal files. +iCal parsing and exporting is done with the help of the Sabretooth VObject +library [1]. A copy of that library with all its dependencies is part of this +package. In order to update it, execute ./get_sabre_vobject.sh within the +lib/ directory. +[1]: https://github.com/fruux/sabre-vobject diff --git a/plugins/libcalendaring/lib/Horde_Date.php b/plugins/libcalendaring/lib/Horde_Date.php deleted file mode 100644 index 9197f846..00000000 --- a/plugins/libcalendaring/lib/Horde_Date.php +++ /dev/null @@ -1,1304 +0,0 @@ - self::MASK_YEAR, - 'month' => self::MASK_MONTH, - 'mday' => self::MASK_DAY, - 'hour' => self::MASK_HOUR, - 'min' => self::MASK_MINUTE, - 'sec' => self::MASK_SECOND, - ); - - protected $_formatCache = array(); - - /** - * Builds a new date object. If $date contains date parts, use them to - * initialize the object. - * - * Recognized formats: - * - arrays with keys 'year', 'month', 'mday', 'day' - * 'hour', 'min', 'minute', 'sec' - * - objects with properties 'year', 'month', 'mday', 'hour', 'min', 'sec' - * - yyyy-mm-dd hh:mm:ss - * - yyyymmddhhmmss - * - yyyymmddThhmmssZ - * - yyyymmdd (might conflict with unix timestamps between 31 Oct 1966 and - * 03 Mar 1973) - * - unix timestamps - * - anything parsed by strtotime()/DateTime. - * - * @throws Horde_Date_Exception - */ - public function __construct($date = null, $timezone = null) - { - if (!self::$_supportedSpecs) { - self::$_supportedSpecs = self::$_defaultSpecs; - if (function_exists('nl_langinfo')) { - self::$_supportedSpecs .= 'bBpxX'; - } - } - - if (func_num_args() > 2) { - // Handle args in order: year month day hour min sec tz - $this->_initializeFromArgs(func_get_args()); - return; - } - - $this->_initializeTimezone($timezone); - - if (is_null($date)) { - return; - } - - if (is_string($date)) { - $date = trim($date, '"'); - } - - if (is_object($date)) { - $this->_initializeFromObject($date); - } elseif (is_array($date)) { - $this->_initializeFromArray($date); - } elseif (preg_match('/^(\d{4})-?(\d{2})-?(\d{2})T? ?(\d{2}):?(\d{2}):?(\d{2})(?:\.\d+)?(Z?)$/', $date, $parts)) { - $this->_year = (int)$parts[1]; - $this->_month = (int)$parts[2]; - $this->_mday = (int)$parts[3]; - $this->_hour = (int)$parts[4]; - $this->_min = (int)$parts[5]; - $this->_sec = (int)$parts[6]; - if ($parts[7]) { - $this->_initializeTimezone('UTC'); - } - } elseif (preg_match('/^(\d{4})-?(\d{2})-?(\d{2})$/', $date, $parts) && - $parts[2] > 0 && $parts[2] <= 12 && - $parts[3] > 0 && $parts[3] <= 31) { - $this->_year = (int)$parts[1]; - $this->_month = (int)$parts[2]; - $this->_mday = (int)$parts[3]; - $this->_hour = $this->_min = $this->_sec = 0; - } elseif ((string)(int)$date == $date) { - // Try as a timestamp. - $parts = @getdate($date); - if ($parts) { - $this->_year = $parts['year']; - $this->_month = $parts['mon']; - $this->_mday = $parts['mday']; - $this->_hour = $parts['hours']; - $this->_min = $parts['minutes']; - $this->_sec = $parts['seconds']; - } - } else { - // Use date_create() so we can catch errors with PHP 5.2. Use - // "new DateTime() once we require 5.3. - $parsed = date_create($date); - if (!$parsed) { - throw new Horde_Date_Exception(sprintf(Horde_Date_Translation::t("Failed to parse time string (%s)"), $date)); - } - $parsed->setTimezone(new DateTimeZone(date_default_timezone_get())); - $this->_year = (int)$parsed->format('Y'); - $this->_month = (int)$parsed->format('m'); - $this->_mday = (int)$parsed->format('d'); - $this->_hour = (int)$parsed->format('H'); - $this->_min = (int)$parsed->format('i'); - $this->_sec = (int)$parsed->format('s'); - $this->_initializeTimezone(date_default_timezone_get()); - } - } - - /** - * Returns a simple string representation of the date object - * - * @return string This object converted to a string. - */ - public function __toString() - { - try { - return $this->format($this->_defaultFormat); - } catch (Exception $e) { - return ''; - } - } - - /** - * Returns a DateTime object representing this object. - * - * @return DateTime - */ - public function toDateTime() - { - $date = new DateTime(null, new DateTimeZone($this->_timezone)); - $date->setDate($this->_year, $this->_month, $this->_mday); - $date->setTime($this->_hour, $this->_min, $this->_sec); - return $date; - } - - /** - * Converts a date in the proleptic Gregorian calendar to the no of days - * since 24th November, 4714 B.C. - * - * Returns the no of days since Monday, 24th November, 4714 B.C. in the - * proleptic Gregorian calendar (which is 24th November, -4713 using - * 'Astronomical' year numbering, and 1st January, 4713 B.C. in the - * proleptic Julian calendar). This is also the first day of the 'Julian - * Period' proposed by Joseph Scaliger in 1583, and the number of days - * since this date is known as the 'Julian Day'. (It is not directly - * to do with the Julian calendar, although this is where the name - * is derived from.) - * - * The algorithm is valid for all years (positive and negative), and - * also for years preceding 4714 B.C. - * - * Algorithm is from PEAR::Date_Calc - * - * @author Monte Ohrt - * @author Pierre-Alain Joye - * @author Daniel Convissor - * @author C.A. Woodcock - * - * @return integer The number of days since 24th November, 4714 B.C. - */ - public function toDays() - { - if (function_exists('GregorianToJD')) { - return gregoriantojd($this->_month, $this->_mday, $this->_year); - } - - $day = $this->_mday; - $month = $this->_month; - $year = $this->_year; - - if ($month > 2) { - // March = 0, April = 1, ..., December = 9, - // January = 10, February = 11 - $month -= 3; - } else { - $month += 9; - --$year; - } - - $hb_negativeyear = $year < 0; - $century = intval($year / 100); - $year = $year % 100; - - if ($hb_negativeyear) { - // Subtract 1 because year 0 is a leap year; - // And N.B. that we must treat the leap years as occurring - // one year earlier than they do, because for the purposes - // of calculation, the year starts on 1st March: - // - return intval((14609700 * $century + ($year == 0 ? 1 : 0)) / 400) + - intval((1461 * $year + 1) / 4) + - intval((153 * $month + 2) / 5) + - $day + 1721118; - } else { - return intval(146097 * $century / 4) + - intval(1461 * $year / 4) + - intval((153 * $month + 2) / 5) + - $day + 1721119; - } - } - - /** - * Converts number of days since 24th November, 4714 B.C. (in the proleptic - * Gregorian calendar, which is year -4713 using 'Astronomical' year - * numbering) to Gregorian calendar date. - * - * Returned date belongs to the proleptic Gregorian calendar, using - * 'Astronomical' year numbering. - * - * The algorithm is valid for all years (positive and negative), and - * also for years preceding 4714 B.C. (i.e. for negative 'Julian Days'), - * and so the only limitation is platform-dependent (for 32-bit systems - * the maximum year would be something like about 1,465,190 A.D.). - * - * N.B. Monday, 24th November, 4714 B.C. is Julian Day '0'. - * - * Algorithm is from PEAR::Date_Calc - * - * @author Monte Ohrt - * @author Pierre-Alain Joye - * @author Daniel Convissor - * @author C.A. Woodcock - * - * @param int $days the number of days since 24th November, 4714 B.C. - * @param string $format the string indicating how to format the output - * - * @return Horde_Date A Horde_Date object representing the date. - */ - public static function fromDays($days) - { - if (function_exists('JDToGregorian')) { - list($month, $day, $year) = explode('/', JDToGregorian($days)); - } else { - $days = intval($days); - - $days -= 1721119; - $century = floor((4 * $days - 1) / 146097); - $days = floor(4 * $days - 1 - 146097 * $century); - $day = floor($days / 4); - - $year = floor((4 * $day + 3) / 1461); - $day = floor(4 * $day + 3 - 1461 * $year); - $day = floor(($day + 4) / 4); - - $month = floor((5 * $day - 3) / 153); - $day = floor(5 * $day - 3 - 153 * $month); - $day = floor(($day + 5) / 5); - - $year = $century * 100 + $year; - if ($month < 10) { - $month +=3; - } else { - $month -=9; - ++$year; - } - } - - return new Horde_Date($year, $month, $day); - } - - /** - * Getter for the date and time properties. - * - * @param string $name One of 'year', 'month', 'mday', 'hour', 'min' or - * 'sec'. - * - * @return integer The property value, or null if not set. - */ - public function __get($name) - { - if ($name == 'day') { - $name = 'mday'; - } - - return $this->{'_' . $name}; - } - - /** - * Setter for the date and time properties. - * - * @param string $name One of 'year', 'month', 'mday', 'hour', 'min' or - * 'sec'. - * @param integer $value The property value. - */ - public function __set($name, $value) - { - if ($name == 'timezone') { - $this->_initializeTimezone($value); - return; - } - if ($name == 'day') { - $name = 'mday'; - } - - if ($name != 'year' && $name != 'month' && $name != 'mday' && - $name != 'hour' && $name != 'min' && $name != 'sec') { - throw new InvalidArgumentException('Undefined property ' . $name); - } - - $down = $value < $this->{'_' . $name}; - $this->{'_' . $name} = $value; - $this->_correct(self::$_corrections[$name], $down); - $this->_formatCache = array(); - } - - /** - * Returns whether a date or time property exists. - * - * @param string $name One of 'year', 'month', 'mday', 'hour', 'min' or - * 'sec'. - * - * @return boolen True if the property exists and is set. - */ - public function __isset($name) - { - if ($name == 'day') { - $name = 'mday'; - } - return ($name == 'year' || $name == 'month' || $name == 'mday' || - $name == 'hour' || $name == 'min' || $name == 'sec') && - isset($this->{'_' . $name}); - } - - /** - * Adds a number of seconds or units to this date, returning a new Date - * object. - */ - public function add($factor) - { - $d = clone($this); - if (is_array($factor) || is_object($factor)) { - foreach ($factor as $property => $value) { - $d->$property += $value; - } - } else { - $d->sec += $factor; - } - - return $d; - } - - /** - * Subtracts a number of seconds or units from this date, returning a new - * Horde_Date object. - */ - public function sub($factor) - { - if (is_array($factor)) { - foreach ($factor as &$value) { - $value *= -1; - } - } else { - $factor *= -1; - } - - return $this->add($factor); - } - - /** - * Converts this object to a different timezone. - * - * @param string $timezone The new timezone. - * - * @return Horde_Date This object. - */ - public function setTimezone($timezone) - { - $date = $this->toDateTime(); - $date->setTimezone(new DateTimeZone($timezone)); - $this->_timezone = $timezone; - $this->_year = (int)$date->format('Y'); - $this->_month = (int)$date->format('m'); - $this->_mday = (int)$date->format('d'); - $this->_hour = (int)$date->format('H'); - $this->_min = (int)$date->format('i'); - $this->_sec = (int)$date->format('s'); - $this->_formatCache = array(); - return $this; - } - - /** - * Sets the default date format used in __toString() - * - * @param string $format - */ - public function setDefaultFormat($format) - { - $this->_defaultFormat = $format; - } - - /** - * Returns the day of the week (0 = Sunday, 6 = Saturday) of this date. - * - * @return integer The day of the week. - */ - public function dayOfWeek() - { - if ($this->_month > 2) { - $month = $this->_month - 2; - $year = $this->_year; - } else { - $month = $this->_month + 10; - $year = $this->_year - 1; - } - - $day = (floor((13 * $month - 1) / 5) + - $this->_mday + ($year % 100) + - floor(($year % 100) / 4) + - floor(($year / 100) / 4) - 2 * - floor($year / 100) + 77); - - return (int)($day - 7 * floor($day / 7)); - } - - /** - * Returns the day number of the year (1 to 365/366). - * - * @return integer The day of the year. - */ - public function dayOfYear() - { - return $this->format('z') + 1; - } - - /** - * Returns the week of the month. - * - * @return integer The week number. - */ - public function weekOfMonth() - { - return ceil($this->_mday / 7); - } - - /** - * Returns the week of the year, first Monday is first day of first week. - * - * @return integer The week number. - */ - public function weekOfYear() - { - return $this->format('W'); - } - - /** - * Returns the number of weeks in the given year (52 or 53). - * - * @param integer $year The year to count the number of weeks in. - * - * @return integer $numWeeks The number of weeks in $year. - */ - public static function weeksInYear($year) - { - // Find the last Thursday of the year. - $date = new Horde_Date($year . '-12-31'); - while ($date->dayOfWeek() != self::DATE_THURSDAY) { - --$date->mday; - } - return $date->weekOfYear(); - } - - /** - * Sets the date of this object to the $nth weekday of $weekday. - * - * @param integer $weekday The day of the week (0 = Sunday, etc). - * @param integer $nth The $nth $weekday to set to (defaults to 1). - */ - public function setNthWeekday($weekday, $nth = 1) - { - if ($weekday < self::DATE_SUNDAY || $weekday > self::DATE_SATURDAY) { - return; - } - - if ($nth < 0) { // last $weekday of month - $this->_mday = $lastday = Horde_Date_Utils::daysInMonth($this->_month, $this->_year); - $last = $this->dayOfWeek(); - $this->_mday += ($weekday - $last); - if ($this->_mday > $lastday) - $this->_mday -= 7; - } - else { - $this->_mday = 1; - $first = $this->dayOfWeek(); - if ($weekday < $first) { - $this->_mday = 8 + $weekday - $first; - } else { - $this->_mday = $weekday - $first + 1; - } - $diff = 7 * $nth - 7; - $this->_mday += $diff; - $this->_correct(self::MASK_DAY, $diff < 0); - } - } - - /** - * Is the date currently represented by this object a valid date? - * - * @return boolean Validity, counting leap years, etc. - */ - public function isValid() - { - return ($this->_year >= 0 && $this->_year <= 9999); - } - - /** - * Compares this date to another date object to see which one is - * greater (later). Assumes that the dates are in the same - * timezone. - * - * @param mixed $other The date to compare to. - * - * @return integer == 0 if they are on the same date - * >= 1 if $this is greater (later) - * <= -1 if $other is greater (later) - */ - public function compareDate($other) - { - if (!($other instanceof Horde_Date)) { - $other = new Horde_Date($other); - } - - if ($this->_year != $other->year) { - return $this->_year - $other->year; - } - if ($this->_month != $other->month) { - return $this->_month - $other->month; - } - - return $this->_mday - $other->mday; - } - - /** - * Returns whether this date is after the other. - * - * @param mixed $other The date to compare to. - * - * @return boolean True if this date is after the other. - */ - public function after($other) - { - return $this->compareDate($other) > 0; - } - - /** - * Returns whether this date is before the other. - * - * @param mixed $other The date to compare to. - * - * @return boolean True if this date is before the other. - */ - public function before($other) - { - return $this->compareDate($other) < 0; - } - - /** - * Returns whether this date is the same like the other. - * - * @param mixed $other The date to compare to. - * - * @return boolean True if this date is the same like the other. - */ - public function equals($other) - { - return $this->compareDate($other) == 0; - } - - /** - * Compares this to another date object by time, to see which one - * is greater (later). Assumes that the dates are in the same - * timezone. - * - * @param mixed $other The date to compare to. - * - * @return integer == 0 if they are at the same time - * >= 1 if $this is greater (later) - * <= -1 if $other is greater (later) - */ - public function compareTime($other) - { - if (!($other instanceof Horde_Date)) { - $other = new Horde_Date($other); - } - - if ($this->_hour != $other->hour) { - return $this->_hour - $other->hour; - } - if ($this->_min != $other->min) { - return $this->_min - $other->min; - } - - return $this->_sec - $other->sec; - } - - /** - * Compares this to another date object, including times, to see - * which one is greater (later). Assumes that the dates are in the - * same timezone. - * - * @param mixed $other The date to compare to. - * - * @return integer == 0 if they are equal - * >= 1 if $this is greater (later) - * <= -1 if $other is greater (later) - */ - public function compareDateTime($other) - { - if (!($other instanceof Horde_Date)) { - $other = new Horde_Date($other); - } - - if ($diff = $this->compareDate($other)) { - return $diff; - } - - return $this->compareTime($other); - } - - /** - * Returns number of days between this date and another. - * - * @param Horde_Date $other The other day to diff with. - * - * @return integer The absolute number of days between the two dates. - */ - public function diff($other) - { - return abs($this->toDays() - $other->toDays()); - } - - /** - * Returns the time offset for local time zone. - * - * @param boolean $colon Place a colon between hours and minutes? - * - * @return string Timezone offset as a string in the format +HH:MM. - */ - public function tzOffset($colon = true) - { - return $colon ? $this->format('P') : $this->format('O'); - } - - /** - * Returns the unix timestamp representation of this date. - * - * @return integer A unix timestamp. - */ - public function timestamp() - { - if ($this->_year >= 1970 && $this->_year < 2038) { - return mktime($this->_hour, $this->_min, $this->_sec, - $this->_month, $this->_mday, $this->_year); - } - return $this->format('U'); - } - - /** - * Returns the unix timestamp representation of this date, 12:00am. - * - * @return integer A unix timestamp. - */ - public function datestamp() - { - if ($this->_year >= 1970 && $this->_year < 2038) { - return mktime(0, 0, 0, $this->_month, $this->_mday, $this->_year); - } - $date = new DateTime($this->format('Y-m-d')); - return $date->format('U'); - } - - /** - * Formats date and time to be passed around as a short url parameter. - * - * @return string Date and time. - */ - public function dateString() - { - return sprintf('%04d%02d%02d', $this->_year, $this->_month, $this->_mday); - } - - /** - * Formats date and time to the ISO format used by JSON. - * - * @return string Date and time. - */ - public function toJson() - { - return $this->format(self::DATE_JSON); - } - - /** - * Formats date and time to the RFC 2445 iCalendar DATE-TIME format. - * - * @param boolean $floating Whether to return a floating date-time - * (without time zone information). - * - * @return string Date and time. - */ - public function toiCalendar($floating = false) - { - if ($floating) { - return $this->format('Ymd\THis'); - } - $dateTime = $this->toDateTime(); - $dateTime->setTimezone(new DateTimeZone('UTC')); - return $dateTime->format('Ymd\THis\Z'); - } - - /** - * Formats time using the specifiers available in date() or in the DateTime - * class' format() method. - * - * To format in languages other than English, use strftime() instead. - * - * @param string $format - * - * @return string Formatted time. - */ - public function format($format) - { - if (!isset($this->_formatCache[$format])) { - $this->_formatCache[$format] = $this->toDateTime()->format($format); - } - return $this->_formatCache[$format]; - } - - /** - * Formats date and time using strftime() format. - * - * @return string strftime() formatted date and time. - */ - public function strftime($format) - { - if (preg_match('/%[^' . self::$_supportedSpecs . ']/', $format)) { - return strftime($format, $this->timestamp()); - } else { - return $this->_strftime($format); - } - } - - /** - * Formats date and time using a limited set of the strftime() format. - * - * @return string strftime() formatted date and time. - */ - protected function _strftime($format) - { - return preg_replace( - array('/%b/e', - '/%B/e', - '/%C/e', - '/%d/e', - '/%D/e', - '/%e/e', - '/%H/e', - '/%I/e', - '/%m/e', - '/%M/e', - '/%n/', - '/%p/e', - '/%R/e', - '/%S/e', - '/%t/', - '/%T/e', - '/%x/e', - '/%X/e', - '/%y/e', - '/%Y/', - '/%%/'), - array('$this->_strftime(Horde_Nls::getLangInfo(constant(\'ABMON_\' . (int)$this->_month)))', - '$this->_strftime(Horde_Nls::getLangInfo(constant(\'MON_\' . (int)$this->_month)))', - '(int)($this->_year / 100)', - 'sprintf(\'%02d\', $this->_mday)', - '$this->_strftime(\'%m/%d/%y\')', - 'sprintf(\'%2d\', $this->_mday)', - 'sprintf(\'%02d\', $this->_hour)', - 'sprintf(\'%02d\', $this->_hour == 0 ? 12 : ($this->_hour > 12 ? $this->_hour - 12 : $this->_hour))', - 'sprintf(\'%02d\', $this->_month)', - 'sprintf(\'%02d\', $this->_min)', - "\n", - '$this->_strftime(Horde_Nls::getLangInfo($this->_hour < 12 ? AM_STR : PM_STR))', - '$this->_strftime(\'%H:%M\')', - 'sprintf(\'%02d\', $this->_sec)', - "\t", - '$this->_strftime(\'%H:%M:%S\')', - '$this->_strftime(Horde_Nls::getLangInfo(D_FMT))', - '$this->_strftime(Horde_Nls::getLangInfo(T_FMT))', - 'substr(sprintf(\'%04d\', $this->_year), -2)', - (int)$this->_year, - '%'), - $format); - } - - /** - * Corrects any over- or underflows in any of the date's members. - * - * @param integer $mask We may not want to correct some overflows. - * @param integer $down Whether to correct the date up or down. - */ - protected function _correct($mask = self::MASK_ALLPARTS, $down = false) - { - if ($mask & self::MASK_SECOND) { - if ($this->_sec < 0 || $this->_sec > 59) { - $mask |= self::MASK_MINUTE; - - $this->_min += (int)($this->_sec / 60); - $this->_sec %= 60; - if ($this->_sec < 0) { - $this->_min--; - $this->_sec += 60; - } - } - } - - if ($mask & self::MASK_MINUTE) { - if ($this->_min < 0 || $this->_min > 59) { - $mask |= self::MASK_HOUR; - - $this->_hour += (int)($this->_min / 60); - $this->_min %= 60; - if ($this->_min < 0) { - $this->_hour--; - $this->_min += 60; - } - } - } - - if ($mask & self::MASK_HOUR) { - if ($this->_hour < 0 || $this->_hour > 23) { - $mask |= self::MASK_DAY; - - $this->_mday += (int)($this->_hour / 24); - $this->_hour %= 24; - if ($this->_hour < 0) { - $this->_mday--; - $this->_hour += 24; - } - } - } - - if ($mask & self::MASK_MONTH) { - $this->_correctMonth($down); - /* When correcting the month, always correct the day too. Months - * have different numbers of days. */ - $mask |= self::MASK_DAY; - } - - if ($mask & self::MASK_DAY) { - while ($this->_mday > 28 && - $this->_mday > Horde_Date_Utils::daysInMonth($this->_month, $this->_year)) { - if ($down) { - $this->_mday -= Horde_Date_Utils::daysInMonth($this->_month + 1, $this->_year) - Horde_Date_Utils::daysInMonth($this->_month, $this->_year); - } else { - $this->_mday -= Horde_Date_Utils::daysInMonth($this->_month, $this->_year); - $this->_month++; - } - $this->_correctMonth($down); - } - while ($this->_mday < 1) { - --$this->_month; - $this->_correctMonth($down); - $this->_mday += Horde_Date_Utils::daysInMonth($this->_month, $this->_year); - } - } - } - - /** - * Corrects the current month. - * - * This cannot be done in _correct() because that would also trigger a - * correction of the day, which would result in an infinite loop. - * - * @param integer $down Whether to correct the date up or down. - */ - protected function _correctMonth($down = false) - { - $this->_year += (int)($this->_month / 12); - $this->_month %= 12; - if ($this->_month < 1) { - $this->_year--; - $this->_month += 12; - } - } - - /** - * Handles args in order: year month day hour min sec tz - */ - protected function _initializeFromArgs($args) - { - $tz = (isset($args[6])) ? array_pop($args) : null; - $this->_initializeTimezone($tz); - - $args = array_slice($args, 0, 6); - $keys = array('year' => 1, 'month' => 1, 'mday' => 1, 'hour' => 0, 'min' => 0, 'sec' => 0); - $date = array_combine(array_slice(array_keys($keys), 0, count($args)), $args); - $date = array_merge($keys, $date); - - $this->_initializeFromArray($date); - } - - protected function _initializeFromArray($date) - { - if (isset($date['year']) && is_string($date['year']) && strlen($date['year']) == 2) { - if ($date['year'] > 70) { - $date['year'] += 1900; - } else { - $date['year'] += 2000; - } - } - - foreach ($date as $key => $val) { - if (in_array($key, array('year', 'month', 'mday', 'hour', 'min', 'sec'))) { - $this->{'_'. $key} = (int)$val; - } - } - - // If $date['day'] is present and numeric we may have been passed - // a Horde_Form_datetime array. - if (isset($date['day']) && - (string)(int)$date['day'] == $date['day']) { - $this->_mday = (int)$date['day']; - } - // 'minute' key also from Horde_Form_datetime - if (isset($date['minute']) && - (string)(int)$date['minute'] == $date['minute']) { - $this->_min = (int)$date['minute']; - } - - $this->_correct(); - } - - protected function _initializeFromObject($date) - { - if ($date instanceof DateTime) { - $this->_year = (int)$date->format('Y'); - $this->_month = (int)$date->format('m'); - $this->_mday = (int)$date->format('d'); - $this->_hour = (int)$date->format('H'); - $this->_min = (int)$date->format('i'); - $this->_sec = (int)$date->format('s'); - $this->_initializeTimezone($date->getTimezone()->getName()); - } else { - $is_horde_date = $date instanceof Horde_Date; - foreach (array('year', 'month', 'mday', 'hour', 'min', 'sec') as $key) { - if ($is_horde_date || isset($date->$key)) { - $this->{'_' . $key} = (int)$date->$key; - } - } - if (!$is_horde_date) { - $this->_correct(); - } else { - $this->_initializeTimezone($date->timezone); - } - } - } - - protected function _initializeTimezone($timezone) - { - if (empty($timezone)) { - $timezone = date_default_timezone_get(); - } - $this->_timezone = $timezone; - } - -} - -/** - * @category Horde - * @package Date - */ - -/** - * Horde Date wrapper/logic class, including some calculation - * functions. - * - * @category Horde - * @package Date - */ -class Horde_Date_Utils -{ - /** - * Returns whether a year is a leap year. - * - * @param integer $year The year. - * - * @return boolean True if the year is a leap year. - */ - public static function isLeapYear($year) - { - if (strlen($year) != 4 || preg_match('/\D/', $year)) { - return false; - } - - return (($year % 4 == 0 && $year % 100 != 0) || $year % 400 == 0); - } - - /** - * Returns the date of the year that corresponds to the first day of the - * given week. - * - * @param integer $week The week of the year to find the first day of. - * @param integer $year The year to calculate for. - * - * @return Horde_Date The date of the first day of the given week. - */ - public static function firstDayOfWeek($week, $year) - { - return new Horde_Date(sprintf('%04dW%02d', $year, $week)); - } - - /** - * Returns the number of days in the specified month. - * - * @param integer $month The month - * @param integer $year The year. - * - * @return integer The number of days in the month. - */ - public static function daysInMonth($month, $year) - { - static $cache = array(); - if (!isset($cache[$year][$month])) { - $date = new DateTime(sprintf('%04d-%02d-01', $year, $month)); - $cache[$year][$month] = $date->format('t'); - } - return $cache[$year][$month]; - } - - /** - * Returns a relative, natural language representation of a timestamp - * - * @todo Wider range of values ... maybe future time as well? - * @todo Support minimum resolution parameter. - * - * @param mixed $time The time. Any format accepted by Horde_Date. - * @param string $date_format Format to display date if timestamp is - * more then 1 day old. - * @param string $time_format Format to display time if timestamp is 1 - * day old. - * - * @return string The relative time (i.e. 2 minutes ago) - */ - public static function relativeDateTime($time, $date_format = '%x', - $time_format = '%X') - { - $date = new Horde_Date($time); - - $delta = time() - $date->timestamp(); - if ($delta < 60) { - return sprintf(Horde_Date_Translation::ngettext("%d second ago", "%d seconds ago", $delta), $delta); - } - - $delta = round($delta / 60); - if ($delta < 60) { - return sprintf(Horde_Date_Translation::ngettext("%d minute ago", "%d minutes ago", $delta), $delta); - } - - $delta = round($delta / 60); - if ($delta < 24) { - return sprintf(Horde_Date_Translation::ngettext("%d hour ago", "%d hours ago", $delta), $delta); - } - - if ($delta > 24 && $delta < 48) { - $date = new Horde_Date($time); - return sprintf(Horde_Date_Translation::t("yesterday at %s"), $date->strftime($time_format)); - } - - $delta = round($delta / 24); - if ($delta < 7) { - return sprintf(Horde_Date_Translation::t("%d days ago"), $delta); - } - - if (round($delta / 7) < 5) { - $delta = round($delta / 7); - return sprintf(Horde_Date_Translation::ngettext("%d week ago", "%d weeks ago", $delta), $delta); - } - - // Default to the user specified date format. - return $date->strftime($date_format); - } - - /** - * Tries to convert strftime() formatters to date() formatters. - * - * Unsupported formatters will be removed. - * - * @param string $format A strftime() formatting string. - * - * @return string A date() formatting string. - */ - public static function strftime2date($format) - { - $replace = array( - '/%a/' => 'D', - '/%A/' => 'l', - '/%d/' => 'd', - '/%e/' => 'j', - '/%j/' => 'z', - '/%u/' => 'N', - '/%w/' => 'w', - '/%U/' => '', - '/%V/' => 'W', - '/%W/' => '', - '/%b/' => 'M', - '/%B/' => 'F', - '/%h/' => 'M', - '/%m/' => 'm', - '/%C/' => '', - '/%g/' => '', - '/%G/' => 'o', - '/%y/' => 'y', - '/%Y/' => 'Y', - '/%H/' => 'H', - '/%I/' => 'h', - '/%i/' => 'g', - '/%M/' => 'i', - '/%p/' => 'A', - '/%P/' => 'a', - '/%r/' => 'h:i:s A', - '/%R/' => 'H:i', - '/%S/' => 's', - '/%T/' => 'H:i:s', - '/%X/e' => 'Horde_Date_Utils::strftime2date(Horde_Nls::getLangInfo(T_FMT))', - '/%z/' => 'O', - '/%Z/' => '', - '/%c/' => '', - '/%D/' => 'm/d/y', - '/%F/' => 'Y-m-d', - '/%s/' => 'U', - '/%x/e' => 'Horde_Date_Utils::strftime2date(Horde_Nls::getLangInfo(D_FMT))', - '/%n/' => "\n", - '/%t/' => "\t", - '/%%/' => '%' - ); - - return preg_replace(array_keys($replace), array_values($replace), $format); - } - -} diff --git a/plugins/libcalendaring/lib/Horde_iCalendar.php b/plugins/libcalendaring/lib/Horde_iCalendar.php deleted file mode 100644 index 6d75d273..00000000 --- a/plugins/libcalendaring/lib/Horde_iCalendar.php +++ /dev/null @@ -1,3300 +0,0 @@ - - * @since Horde 3.0 - * @package Horde_Util - */ -class String { - - /** - * Caches the result of extension_loaded() calls. - * - * @param string $ext The extension name. - * - * @return boolean Is the extension loaded? - * - * @see Util::extensionExists() - */ - function extensionExists($ext) - { - static $cache = array(); - - if (!isset($cache[$ext])) { - $cache[$ext] = extension_loaded($ext); - } - - return $cache[$ext]; - } - - /** - * Sets a default charset that the String:: methods will use if none is - * explicitly specified. - * - * @param string $charset The charset to use as the default one. - */ - function setDefaultCharset($charset) - { - $GLOBALS['_HORDE_STRING_CHARSET'] = $charset; - if (String::extensionExists('mbstring') && - function_exists('mb_regex_encoding')) { - $old_error = error_reporting(0); - mb_regex_encoding(String::_mbstringCharset($charset)); - error_reporting($old_error); - } - } - - /** - * Converts a string from one charset to another. - * - * Works only if either the iconv or the mbstring extension - * are present and best if both are available. - * The original string is returned if conversion failed or none - * of the extensions were available. - * - * @param mixed $input The data to be converted. If $input is an an array, - * the array's values get converted recursively. - * @param string $from The string's current charset. - * @param string $to The charset to convert the string to. If not - * specified, the global variable - * $_HORDE_STRING_CHARSET will be used. - * - * @return mixed The converted input data. - */ - function convertCharset($input, $from, $to = null) - { - /* Don't bother converting numbers. */ - if (is_numeric($input)) { - return $input; - } - - /* Get the user's default character set if none passed in. */ - if (is_null($to)) { - $to = $GLOBALS['_HORDE_STRING_CHARSET']; - } - - /* If the from and to character sets are identical, return now. */ - if ($from == $to) { - return $input; - } - $from = String::lower($from); - $to = String::lower($to); - if ($from == $to) { - return $input; - } - - if (is_array($input)) { - $tmp = array(); - reset($input); - while (list($key, $val) = each($input)) { - $tmp[String::_convertCharset($key, $from, $to)] = String::convertCharset($val, $from, $to); - } - return $tmp; - } - if (is_object($input)) { - // PEAR_Error objects are almost guaranteed to contain recursion, - // which will cause a segfault in PHP. We should never reach - // this line, but add a check and a log message to help the devs - // track down and fix this issue. - if (is_a($input, 'PEAR_Error')) { - Horde::logMessage('Called convertCharset() on a PEAR_Error object. ' . print_r($input, true), __FILE__, __LINE__, PEAR_LOG_DEBUG); - return ''; - } - $vars = get_object_vars($input); - while (list($key, $val) = each($vars)) { - $input->$key = String::convertCharset($val, $from, $to); - } - return $input; - } - - if (!is_string($input)) { - return $input; - } - - return String::_convertCharset($input, $from, $to); - } - - /** - * Internal function used to do charset conversion. - * - * @access private - * - * @param string $input See String::convertCharset(). - * @param string $from See String::convertCharset(). - * @param string $to See String::convertCharset(). - * - * @return string The converted string. - */ - function _convertCharset($input, $from, $to) - { - $output = ''; - $from_check = (($from == 'iso-8859-1') || ($from == 'us-ascii')); - $to_check = (($to == 'iso-8859-1') || ($to == 'us-ascii')); - - /* Use utf8_[en|de]code() if possible and if the string isn't too - * large (less than 16 MB = 16 * 1024 * 1024 = 16777216 bytes) - these - * functions use more memory. */ - if (strlen($input) < 16777216 || !(String::extensionExists('iconv') || String::extensionExists('mbstring'))) { - if ($from_check && ($to == 'utf-8')) { - return utf8_encode($input); - } - - if (($from == 'utf-8') && $to_check) { - return utf8_decode($input); - } - } - - /* First try iconv with transliteration. */ - if (($from != 'utf7-imap') && - ($to != 'utf7-imap') && - String::extensionExists('iconv')) { - /* We need to tack an extra character temporarily because of a bug - * in iconv() if the last character is not a 7 bit ASCII - * character. */ - $oldTrackErrors = ini_set('track_errors', 1); - unset($php_errormsg); - $output = @iconv($from, $to . '//TRANSLIT', $input . 'x'); - $output = (isset($php_errormsg)) ? false : String::substr($output, 0, -1, $to); - ini_set('track_errors', $oldTrackErrors); - } - - /* Next try mbstring. */ - if (!$output && String::extensionExists('mbstring')) { - $old_error = error_reporting(0); - $output = mb_convert_encoding($input, $to, String::_mbstringCharset($from)); - error_reporting($old_error); - } - - /* At last try imap_utf7_[en|de]code if appropriate. */ - if (!$output && String::extensionExists('imap')) { - if ($from_check && ($to == 'utf7-imap')) { - return @imap_utf7_encode($input); - } - if (($from == 'utf7-imap') && $to_check) { - return @imap_utf7_decode($input); - } - } - - return (!$output) ? $input : $output; - } - - /** - * Makes a string lowercase. - * - * @param string $string The string to be converted. - * @param boolean $locale If true the string will be converted based on a - * given charset, locale independent else. - * @param string $charset If $locale is true, the charset to use when - * converting. If not provided the current charset. - * - * @return string The string with lowercase characters - */ - function lower($string, $locale = false, $charset = null) - { - static $lowers; - - if ($locale) { - /* The existence of mb_strtolower() depends on the platform. */ - if (String::extensionExists('mbstring') && - function_exists('mb_strtolower')) { - if (is_null($charset)) { - $charset = $GLOBALS['_HORDE_STRING_CHARSET']; - } - $old_error = error_reporting(0); - $ret = mb_strtolower($string, String::_mbstringCharset($charset)); - error_reporting($old_error); - if (!empty($ret)) { - return $ret; - } - } - return strtolower($string); - } - - if (!isset($lowers)) { - $lowers = array(); - } - if (!isset($lowers[$string])) { - $language = setlocale(LC_CTYPE, 0); - setlocale(LC_CTYPE, 'C'); - $lowers[$string] = strtolower($string); - setlocale(LC_CTYPE, $language); - } - - return $lowers[$string]; - } - - /** - * Makes a string uppercase. - * - * @param string $string The string to be converted. - * @param boolean $locale If true the string will be converted based on a - * given charset, locale independent else. - * @param string $charset If $locale is true, the charset to use when - * converting. If not provided the current charset. - * - * @return string The string with uppercase characters - */ - function upper($string, $locale = false, $charset = null) - { - static $uppers; - - if ($locale) { - /* The existence of mb_strtoupper() depends on the - * platform. */ - if (function_exists('mb_strtoupper')) { - if (is_null($charset)) { - $charset = $GLOBALS['_HORDE_STRING_CHARSET']; - } - $old_error = error_reporting(0); - $ret = mb_strtoupper($string, String::_mbstringCharset($charset)); - error_reporting($old_error); - if (!empty($ret)) { - return $ret; - } - } - return strtoupper($string); - } - - if (!isset($uppers)) { - $uppers = array(); - } - if (!isset($uppers[$string])) { - $language = setlocale(LC_CTYPE, 0); - setlocale(LC_CTYPE, 'C'); - $uppers[$string] = strtoupper($string); - setlocale(LC_CTYPE, $language); - } - - return $uppers[$string]; - } - - /** - * Returns a string with the first letter capitalized if it is - * alphabetic. - * - * @param string $string The string to be capitalized. - * @param boolean $locale If true the string will be converted based on a - * given charset, locale independent else. - * @param string $charset The charset to use, defaults to current charset. - * - * @return string The capitalized string. - */ - function ucfirst($string, $locale = false, $charset = null) - { - if ($locale) { - $first = String::substr($string, 0, 1, $charset); - if (String::isAlpha($first, $charset)) { - $string = String::upper($first, true, $charset) . String::substr($string, 1, null, $charset); - } - } else { - $string = String::upper(substr($string, 0, 1), false) . substr($string, 1); - } - return $string; - } - - /** - * Returns part of a string. - * - * @param string $string The string to be converted. - * @param integer $start The part's start position, zero based. - * @param integer $length The part's length. - * @param string $charset The charset to use when calculating the part's - * position and length, defaults to current - * charset. - * - * @return string The string's part. - */ - function substr($string, $start, $length = null, $charset = null) - { - if (is_null($length)) { - $length = String::length($string, $charset) - $start; - } - - if ($length == 0) { - return ''; - } - - /* Try iconv. */ - if (function_exists('iconv_substr')) { - if (is_null($charset)) { - $charset = $GLOBALS['_HORDE_STRING_CHARSET']; - } - - $old_error = error_reporting(0); - $ret = iconv_substr($string, $start, $length, $charset); - error_reporting($old_error); - /* iconv_substr() returns false on failure. */ - if ($ret !== false) { - return $ret; - } - } - - /* Try mbstring. */ - if (String::extensionExists('mbstring')) { - if (is_null($charset)) { - $charset = $GLOBALS['_HORDE_STRING_CHARSET']; - } - $old_error = error_reporting(0); - $ret = mb_substr($string, $start, $length, String::_mbstringCharset($charset)); - error_reporting($old_error); - /* mb_substr() returns empty string on failure. */ - if (strlen($ret)) { - return $ret; - } - } - - return substr($string, $start, $length); - } - - /** - * Returns the character (not byte) length of a string. - * - * @param string $string The string to return the length of. - * @param string $charset The charset to use when calculating the string's - * length. - * - * @return string The string's part. - */ - function length($string, $charset = null) - { - if (is_null($charset)) { - $charset = $GLOBALS['_HORDE_STRING_CHARSET']; - } - $charset = String::lower($charset); - if ($charset == 'utf-8' || $charset == 'utf8') { - return strlen(utf8_decode($string)); - } - if (String::extensionExists('mbstring')) { - $old_error = error_reporting(0); - $ret = mb_strlen($string, String::_mbstringCharset($charset)); - error_reporting($old_error); - if (!empty($ret)) { - return $ret; - } - } - return strlen($string); - } - - /** - * Returns the numeric position of the first occurrence of $needle - * in the $haystack string. - * - * @param string $haystack The string to search through. - * @param string $needle The string to search for. - * @param integer $offset Allows to specify which character in haystack - * to start searching. - * @param string $charset The charset to use when searching for the - * $needle string. - * - * @return integer The position of first occurrence. - */ - function pos($haystack, $needle, $offset = 0, $charset = null) - { - if (String::extensionExists('mbstring')) { - if (is_null($charset)) { - $charset = $GLOBALS['_HORDE_STRING_CHARSET']; - } - $track_errors = ini_set('track_errors', 1); - $old_error = error_reporting(0); - $ret = mb_strpos($haystack, $needle, $offset, String::_mbstringCharset($charset)); - error_reporting($old_error); - ini_set('track_errors', $track_errors); - if (!isset($php_errormsg)) { - return $ret; - } - } - return strpos($haystack, $needle, $offset); - } - - /** - * Returns a string padded to a certain length with another string. - * - * This method behaves exactly like str_pad but is multibyte safe. - * - * @param string $input The string to be padded. - * @param integer $length The length of the resulting string. - * @param string $pad The string to pad the input string with. Must - * be in the same charset like the input string. - * @param const $type The padding type. One of STR_PAD_LEFT, - * STR_PAD_RIGHT, or STR_PAD_BOTH. - * @param string $charset The charset of the input and the padding - * strings. - * - * @return string The padded string. - */ - function pad($input, $length, $pad = ' ', $type = STR_PAD_RIGHT, - $charset = null) - { - $mb_length = String::length($input, $charset); - $sb_length = strlen($input); - $pad_length = String::length($pad, $charset); - - /* Return if we already have the length. */ - if ($mb_length >= $length) { - return $input; - } - - /* Shortcut for single byte strings. */ - if ($mb_length == $sb_length && $pad_length == strlen($pad)) { - return str_pad($input, $length, $pad, $type); - } - - switch ($type) { - case STR_PAD_LEFT: - $left = $length - $mb_length; - $output = String::substr(str_repeat($pad, ceil($left / $pad_length)), 0, $left, $charset) . $input; - break; - case STR_PAD_BOTH: - $left = floor(($length - $mb_length) / 2); - $right = ceil(($length - $mb_length) / 2); - $output = String::substr(str_repeat($pad, ceil($left / $pad_length)), 0, $left, $charset) . - $input . - String::substr(str_repeat($pad, ceil($right / $pad_length)), 0, $right, $charset); - break; - case STR_PAD_RIGHT: - $right = $length - $mb_length; - $output = $input . String::substr(str_repeat($pad, ceil($right / $pad_length)), 0, $right, $charset); - break; - } - - return $output; - } - - /** - * Wraps the text of a message. - * - * @since Horde 3.2 - * - * @param string $string String containing the text to wrap. - * @param integer $width Wrap the string at this number of - * characters. - * @param string $break Character(s) to use when breaking lines. - * @param boolean $cut Whether to cut inside words if a line - * can't be wrapped. - * @param string $charset Character set to use when breaking lines. - * @param boolean $line_folding Whether to apply line folding rules per - * RFC 822 or similar. The correct break - * characters including leading whitespace - * have to be specified too. - * - * @return string String containing the wrapped text. - */ - function wordwrap($string, $width = 75, $break = "\n", $cut = false, - $charset = null, $line_folding = false) - { - /* Get the user's default character set if none passed in. */ - if (is_null($charset)) { - $charset = $GLOBALS['_HORDE_STRING_CHARSET']; - } - $charset = String::_mbstringCharset($charset); - $string = String::convertCharset($string, $charset, 'utf-8'); - $wrapped = ''; - - while (String::length($string, 'utf-8') > $width) { - $line = String::substr($string, 0, $width, 'utf-8'); - $string = String::substr($string, String::length($line, 'utf-8'), null, 'utf-8'); - // Make sure didn't cut a word, unless we want hard breaks anyway. - if (!$cut && preg_match('/^(.+?)((\s|\r?\n).*)/us', $string, $match)) { - $line .= $match[1]; - $string = $match[2]; - } - // Wrap at existing line breaks. - if (preg_match('/^(.*?)(\r?\n)(.*)$/u', $line, $match)) { - $wrapped .= $match[1] . $match[2]; - $string = $match[3] . $string; - continue; - } - // Wrap at the last colon or semicolon followed by a whitespace if - // doing line folding. - if ($line_folding && - preg_match('/^(.*?)(;|:)(\s+.*)$/u', $line, $match)) { - $wrapped .= $match[1] . $match[2] . $break; - $string = $match[3] . $string; - continue; - } - // Wrap at the last whitespace of $line. - if ($line_folding) { - $sub = '(.+[^\s])'; - } else { - $sub = '(.*)'; - } - if (preg_match('/^' . $sub . '(\s+)(.*)$/u', $line, $match)) { - $wrapped .= $match[1] . $break; - $string = ($line_folding ? $match[2] : '') . $match[3] . $string; - continue; - } - // Hard wrap if necessary. - if ($cut) { - $wrapped .= $line . $break; - continue; - } - $wrapped .= $line; - } - - return String::convertCharset($wrapped . $string, 'utf-8', $charset); - } - - /** - * Wraps the text of a message. - * - * @param string $text String containing the text to wrap. - * @param integer $length Wrap $text at this number of characters. - * @param string $break_char Character(s) to use when breaking lines. - * @param string $charset Character set to use when breaking lines. - * @param boolean $quote Ignore lines that are wrapped with the '>' - * character (RFC 2646)? If true, we don't - * remove any padding whitespace at the end of - * the string. - * - * @return string String containing the wrapped text. - */ - function wrap($text, $length = 80, $break_char = "\n", $charset = null, - $quote = false) - { - $paragraphs = array(); - - foreach (preg_split('/\r?\n/', $text) as $input) { - if ($quote && (strpos($input, '>') === 0)) { - $line = $input; - } else { - /* We need to handle the Usenet-style signature line - * separately; since the space after the two dashes is - * REQUIRED, we don't want to trim the line. */ - if ($input != '-- ') { - $input = rtrim($input); - } - $line = String::wordwrap($input, $length, $break_char, false, $charset); - } - - $paragraphs[] = $line; - } - - return implode($break_char, $paragraphs); - } - - /** - * Returns true if the every character in the parameter is an alphabetic - * character. - * - * @param $string The string to test. - * @param $charset The charset to use when testing the string. - * - * @return boolean True if the parameter was alphabetic only. - */ - function isAlpha($string, $charset = null) - { - if (!String::extensionExists('mbstring')) { - return ctype_alpha($string); - } - - $charset = String::_mbstringCharset($charset); - $old_charset = mb_regex_encoding(); - $old_error = error_reporting(0); - - if ($charset != $old_charset) { - mb_regex_encoding($charset); - } - $alpha = !mb_ereg_match('[^[:alpha:]]', $string); - if ($charset != $old_charset) { - mb_regex_encoding($old_charset); - } - - error_reporting($old_error); - - return $alpha; - } - - /** - * Returns true if ever character in the parameter is a lowercase letter in - * the current locale. - * - * @param $string The string to test. - * @param $charset The charset to use when testing the string. - * - * @return boolean True if the parameter was lowercase. - */ - function isLower($string, $charset = null) - { - return ((String::lower($string, true, $charset) === $string) && - String::isAlpha($string, $charset)); - } - - /** - * Returns true if every character in the parameter is an uppercase letter - * in the current locale. - * - * @param string $string The string to test. - * @param string $charset The charset to use when testing the string. - * - * @return boolean True if the parameter was uppercase. - */ - function isUpper($string, $charset = null) - { - return ((String::upper($string, true, $charset) === $string) && - String::isAlpha($string, $charset)); - } - - /** - * Performs a multibyte safe regex match search on the text provided. - * - * @since Horde 3.1 - * - * @param string $text The text to search. - * @param array $regex The regular expressions to use, without perl - * regex delimiters (e.g. '/' or '|'). - * @param string $charset The character set of the text. - * - * @return array The matches array from the first regex that matches. - */ - function regexMatch($text, $regex, $charset = null) - { - if (!empty($charset)) { - $regex = String::convertCharset($regex, $charset, 'utf-8'); - $text = String::convertCharset($text, $charset, 'utf-8'); - } - - $matches = array(); - foreach ($regex as $val) { - if (preg_match('/' . $val . '/u', $text, $matches)) { - break; - } - } - - if (!empty($charset)) { - $matches = String::convertCharset($matches, 'utf-8', $charset); - } - - return $matches; - } - - /** - * Workaround charsets that don't work with mbstring functions. - * - * @access private - * - * @param string $charset The original charset. - * - * @return string The charset to use with mbstring functions. - */ - function _mbstringCharset($charset) - { - /* mbstring functions do not handle the 'ks_c_5601-1987' & - * 'ks_c_5601-1989' charsets. However, these charsets are used, for - * example, by various versions of Outlook to send Korean characters. - * Use UHC (CP949) encoding instead. See, e.g., - * http://lists.w3.org/Archives/Public/ietf-charsets/2001AprJun/0030.html */ - if (in_array(String::lower($charset), array('ks_c_5601-1987', 'ks_c_5601-1989'))) { - $charset = 'UHC'; - } - - return $charset; - } - -} - - - -/** - * @package Horde_iCalendar - */ - -/** - * String package - */ - - - -/** - * Class representing iCalendar files. - * - * $Horde: framework/iCalendar/iCalendar.php,v 1.57.4.81 2010-11-10 14:34:25 jan Exp $ - * - * Copyright 2003-2009 The Horde Project (http://www.horde.org/) - * - * See the enclosed file COPYING for license information (LGPL). If you - * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html. - * - * @author Mike Cochrane - * @since Horde 3.0 - * @package Horde_iCalendar - */ -class Horde_iCalendar { - - /** - * The parent (containing) iCalendar object. - * - * @var Horde_iCalendar - */ - var $_container = false; - - /** - * The name/value pairs of attributes for this object (UID, - * DTSTART, etc.). Which are present depends on the object and on - * what kind of component it is. - * - * @var array - */ - var $_attributes = array(); - - /** - * Any children (contained) iCalendar components of this object. - * - * @var array - */ - var $_components = array(); - - /** - * According to RFC 2425, we should always use CRLF-terminated lines. - * - * @var string - */ - var $_newline = "\r\n"; - - /** - * iCalendar format version (different behavior for 1.0 and 2.0 - * especially with recurring events). - * - * @var string - */ - var $_version; - - function Horde_iCalendar($version = '2.0') - { - $this->_version = $version; - $this->setAttribute('VERSION', $version); - } - - /** - * Return a reference to a new component. - * - * @param string $type The type of component to return - * @param Horde_iCalendar $container A container that this component - * will be associated with. - * - * @return object Reference to a Horde_iCalendar_* object as specified. - * - * @static - */ - function &newComponent($type, &$container) - { - $type = String::lower($type); - $class = 'Horde_iCalendar_' . $type; - if (!class_exists($class)) { - include 'Horde/iCalendar/' . $type . '.php'; - } - if (class_exists($class)) { - $component = new $class(); - if ($container !== false) { - $component->_container = &$container; - // Use version of container, not default set by component - // constructor. - $component->_version = $container->_version; - } - } else { - // Should return an dummy x-unknown type class here. - $component = false; - } - - return $component; - } - - /** - * Sets the value of an attribute. - * - * @param string $name The name of the attribute. - * @param string $value The value of the attribute. - * @param array $params Array containing any addition parameters for - * this attribute. - * @param boolean $append True to append the attribute, False to replace - * the first matching attribute found. - * @param array $values Array representation of $value. For - * comma/semicolon seperated lists of values. If - * not set use $value as single array element. - */ - function setAttribute($name, $value, $params = array(), $append = true, - $values = false) - { - // Make sure we update the internal format version if - // setAttribute('VERSION', ...) is called. - if ($name == 'VERSION') { - $this->_version = $value; - if ($this->_container !== false) { - $this->_container->_version = $value; - } - } - - if (!$values) { - $values = array($value); - } - $found = false; - if (!$append) { - foreach (array_keys($this->_attributes) as $key) { - if ($this->_attributes[$key]['name'] == String::upper($name)) { - $this->_attributes[$key]['params'] = $params; - $this->_attributes[$key]['value'] = $value; - $this->_attributes[$key]['values'] = $values; - $found = true; - break; - } - } - } - - if ($append || !$found) { - $this->_attributes[] = array( - 'name' => String::upper($name), - 'params' => $params, - 'value' => $value, - 'values' => $values - ); - } - } - - /** - * Sets parameter(s) for an (already existing) attribute. The - * parameter set is merged into the existing set. - * - * @param string $name The name of the attribute. - * @param array $params Array containing any additional parameters for - * this attribute. - * @return boolean True on success, false if no attribute $name exists. - */ - function setParameter($name, $params = array()) - { - $keys = array_keys($this->_attributes); - foreach ($keys as $key) { - if ($this->_attributes[$key]['name'] == $name) { - $this->_attributes[$key]['params'] = - array_merge($this->_attributes[$key]['params'], $params); - return true; - } - } - - return false; - } - - /** - * Get the value of an attribute. - * - * @param string $name The name of the attribute. - * @param boolean $params Return the parameters for this attribute instead - * of its value. - * - * @return mixed (object) PEAR_Error if the attribute does not exist. - * (string) The value of the attribute. - * (array) The parameters for the attribute or - * multiple values for an attribute. - */ - function getAttribute($name, $params = false) - { - $result = array(); - foreach ($this->_attributes as $attribute) { - if ($attribute['name'] == $name) { - if ($params) { - $result[] = $attribute['params']; - } else { - $result[] = $attribute['value']; - } - } - } - if (!count($result)) { - require_once 'PEAR.php'; - return PEAR::raiseError('Attribute "' . $name . '" Not Found'); - } if (count($result) == 1 && !$params) { - return $result[0]; - } else { - return $result; - } - } - - /** - * Gets the values of an attribute as an array. Multiple values - * are possible due to: - * - * a) multiplce occurences of 'name' - * b) (unsecapd) comma seperated lists. - * - * So for a vcard like "KEY:a,b\nKEY:c" getAttributesValues('KEY') - * will return array('a', 'b', 'c'). - * - * @param string $name The name of the attribute. - * @return mixed (object) PEAR_Error if the attribute does not exist. - * (array) Multiple values for an attribute. - */ - function getAttributeValues($name) - { - $result = array(); - foreach ($this->_attributes as $attribute) { - if ($attribute['name'] == $name) { - $result = array_merge($attribute['values'], $result); - } - } - if (!count($result)) { - return PEAR::raiseError('Attribute "' . $name . '" Not Found'); - } - return $result; - } - - /** - * Returns the value of an attribute, or a specified default value - * if the attribute does not exist. - * - * @param string $name The name of the attribute. - * @param mixed $default What to return if the attribute specified by - * $name does not exist. - * - * @return mixed (string) The value of $name. - * (mixed) $default if $name does not exist. - */ - function getAttributeDefault($name, $default = '') - { - $value = $this->getAttribute($name); - return is_a($value, 'PEAR_Error') ? $default : $value; - } - - /** - * Remove all occurences of an attribute. - * - * @param string $name The name of the attribute. - */ - function removeAttribute($name) - { - $keys = array_keys($this->_attributes); - foreach ($keys as $key) { - if ($this->_attributes[$key]['name'] == $name) { - unset($this->_attributes[$key]); - } - } - } - - /** - * Get attributes for all tags or for a given tag. - * - * @param string $tag Return attributes for this tag, or all attributes if - * not given. - * - * @return array An array containing all the attributes and their types. - */ - function getAllAttributes($tag = false) - { - if ($tag === false) { - return $this->_attributes; - } - $result = array(); - foreach ($this->_attributes as $attribute) { - if ($attribute['name'] == $tag) { - $result[] = $attribute; - } - } - return $result; - } - - /** - * Add a vCalendar component (eg vEvent, vTimezone, etc.). - * - * @param Horde_iCalendar $component Component (subclass) to add. - */ - function addComponent($component) - { - if (is_a($component, 'Horde_iCalendar')) { - $component->_container = &$this; - $this->_components[] = &$component; - } - } - - /** - * Retrieve all the components. - * - * @return array Array of Horde_iCalendar objects. - */ - function getComponents() - { - return $this->_components; - } - - function getType() - { - return 'vcalendar'; - } - - /** - * Return the classes (entry types) we have. - * - * @return array Hash with class names Horde_iCalendar_xxx as keys - * and number of components of this class as value. - */ - function getComponentClasses() - { - $r = array(); - foreach ($this->_components as $c) { - $cn = strtolower(get_class($c)); - if (empty($r[$cn])) { - $r[$cn] = 1; - } else { - $r[$cn]++; - } - } - - return $r; - } - - /** - * Number of components in this container. - * - * @return integer Number of components in this container. - */ - function getComponentCount() - { - return count($this->_components); - } - - /** - * Retrieve a specific component. - * - * @param integer $idx The index of the object to retrieve. - * - * @return mixed (boolean) False if the index does not exist. - * (Horde_iCalendar_*) The requested component. - */ - function getComponent($idx) - { - if (isset($this->_components[$idx])) { - return $this->_components[$idx]; - } else { - return false; - } - } - - /** - * Locates the first child component of the specified class, and returns a - * reference to it. - * - * @param string $type The type of component to find. - * - * @return boolean|Horde_iCalendar_* False if no subcomponent of the - * specified class exists or a reference - * to the requested component. - */ - function &findComponent($childclass) - { - $childclass = 'Horde_iCalendar_' . String::lower($childclass); - $keys = array_keys($this->_components); - foreach ($keys as $key) { - if (is_a($this->_components[$key], $childclass)) { - return $this->_components[$key]; - } - } - - $component = false; - return $component; - } - - /** - * Locates the first matching child component of the specified class, and - * returns a reference to it. - * - * @param string $childclass The type of component to find. - * @param string $attribute This attribute must be set in the component - * for it to match. - * @param string $value Optional value that $attribute must match. - * - * @return boolean|Horde_iCalendar_* False if no matching subcomponent of - * the specified class exists, or a - * reference to the requested component. - */ - function &findComponentByAttribute($childclass, $attribute, $value = null) - { - $childclass = 'Horde_iCalendar_' . String::lower($childclass); - $keys = array_keys($this->_components); - foreach ($keys as $key) { - if (is_a($this->_components[$key], $childclass)) { - $attr = $this->_components[$key]->getAttribute($attribute); - if (is_a($attr, 'PEAR_Error')) { - continue; - } - if ($value !== null && $value != $attr) { - continue; - } - return $this->_components[$key]; - } - } - - $component = false; - return $component; - } - - /** - * Clears the iCalendar object (resets the components and attributes - * arrays). - */ - function clear() - { - $this->_components = array(); - $this->_attributes = array(); - } - - /** - * Checks if entry is vcalendar 1.0, vcard 2.1 or vnote 1.1. - * - * These 'old' formats are defined by www.imc.org. The 'new' (non-old) - * formats icalendar 2.0 and vcard 3.0 are defined in rfc2426 and rfc2445 - * respectively. - * - * @since Horde 3.1.2 - */ - function isOldFormat() - { - if ($this->getType() == 'vcard') { - return ($this->_version < 3); - } - if ($this->getType() == 'vNote') { - return ($this->_version < 2); - } - if ($this->_version >= 2) { - return false; - } - return true; - } - - /** - * Export as vCalendar format. - */ - function exportvCalendar() - { - // Default values. - $requiredAttributes['PRODID'] = '-//The Horde Project//Horde_iCalendar Library' . (defined('HORDE_VERSION') ? ', Horde ' . constant('HORDE_VERSION') : '') . '//EN'; - $requiredAttributes['METHOD'] = 'PUBLISH'; - - foreach ($requiredAttributes as $name => $default_value) { - if (is_a($this->getattribute($name), 'PEAR_Error')) { - $this->setAttribute($name, $default_value); - } - } - - return $this->_exportvData('VCALENDAR'); - } - - /** - * Export this entry as a hash array with tag names as keys. - * - * @param boolean $paramsInKeys - * If false, the operation can be quite lossy as the - * parameters are ignored when building the array keys. - * So if you export a vcard with - * LABEL;TYPE=WORK:foo - * LABEL;TYPE=HOME:bar - * the resulting hash contains only one label field! - * If set to true, array keys look like 'LABEL;TYPE=WORK' - * @return array A hash array with tag names as keys. - */ - function toHash($paramsInKeys = false) - { - $hash = array(); - foreach ($this->_attributes as $a) { - $k = $a['name']; - if ($paramsInKeys && is_array($a['params'])) { - foreach ($a['params'] as $p => $v) { - $k .= ";$p=$v"; - } - } - $hash[$k] = $a['value']; - } - - return $hash; - } - - /** - * Parses a string containing vCalendar data. - * - * @todo This method doesn't work well at all, if $base is VCARD. - * - * @param string $text The data to parse. - * @param string $base The type of the base object. - * @param string $charset The encoding charset for $text. Defaults to - * utf-8 for new format, iso-8859-1 for old format. - * @param boolean $clear If true clears the iCal object before parsing. - * - * @return boolean True on successful import, false otherwise. - */ - function parsevCalendar($text, $base = 'VCALENDAR', $charset = null, - $clear = true) - { - if ($clear) { - $this->clear(); - } - if (preg_match('/^BEGIN:' . $base . '(.*)^END:' . $base . '/ism', $text, $matches)) { - $container = true; - $vCal = $matches[1]; - } else { - // Text isn't enclosed in BEGIN:VCALENDAR - // .. END:VCALENDAR. We'll try to parse it anyway. - $container = false; - $vCal = $text; - } - $vCal = trim($vCal); - - // Extract all subcomponents. - $matches = $components = null; - if (preg_match_all('/^BEGIN:(.*)(\r\n|\r|\n)(.*)^END:\1/Uims', $vCal, $components)) { - foreach ($components[0] as $key => $data) { - // Remove from the vCalendar data. - $vCal = str_replace($data, '', $vCal); - } - } elseif (!$container) { - return false; - } - - // Unfold "quoted printable" folded lines like: - // BODY;ENCODING=QUOTED-PRINTABLE:= - // another=20line= - // last=20line - while (preg_match_all('/^([^:]+;\s*(ENCODING=)?QUOTED-PRINTABLE(.*=\r?\n)+(.*[^=])?\r?\n)/mU', $vCal, $matches)) { - foreach ($matches[1] as $s) { - $r = preg_replace('/=\r?\n/', '', $s); - $vCal = str_replace($s, $r, $vCal); - } - } - - // Unfold any folded lines. - if ($this->isOldFormat()) { - $vCal = preg_replace('/[\r\n]+([ \t])/', '$1', $vCal); - } else { - $vCal = preg_replace('/[\r\n]+[ \t]/', '', $vCal); - } - - // Parse the remaining attributes. - if (preg_match_all('/^((?:[^":]+|(?:"[^"]*")+)*):([^\r\n]*)\r?$/m', $vCal, $matches)) { - foreach ($matches[0] as $attribute) { - preg_match('/([^;^:]*)((;(?:[^":]+|(?:"[^"]*")+)*)?):([^\r\n]*)[\r\n]*/', $attribute, $parts); - $tag = trim(String::upper($parts[1])); - $value = $parts[4]; - $params = array(); - - // Parse parameters. - if (!empty($parts[2])) { - preg_match_all('/;(([^;=]*)(=("[^"]*"|[^;]*))?)/', $parts[2], $param_parts); - foreach ($param_parts[2] as $key => $paramName) { - $paramName = String::upper($paramName); - $paramValue = $param_parts[4][$key]; - if ($paramName == 'TYPE') { - $paramValue = preg_split('/(? $tmp) { - if (preg_match('/"([^"]*)"/', $tmp, $parts)) { - $paramValue[$k] = $parts[1]; - } - } - } - $params[$paramName] = $paramValue; - } - } - - // Charset and encoding handling. - if ((isset($params['ENCODING']) && - String::upper($params['ENCODING']) == 'QUOTED-PRINTABLE') || - isset($params['QUOTED-PRINTABLE'])) { - - $value = quoted_printable_decode($value); - if (isset($params['CHARSET'])) { - $value = String::convertCharset($value, $params['CHARSET']); - } else { - $value = String::convertCharset($value, empty($charset) ? ($this->isOldFormat() ? 'iso-8859-1' : 'utf-8') : $charset); - } - } elseif (isset($params['CHARSET'])) { - $value = String::convertCharset($value, $params['CHARSET']); - } else { - // As per RFC 2279, assume UTF8 if we don't have an - // explicit charset parameter. - $value = String::convertCharset($value, empty($charset) ? ($this->isOldFormat() ? 'iso-8859-1' : 'utf-8') : $charset); - } - - // Get timezone info for date fields from $params. - $tzid = isset($params['TZID']) ? trim($params['TZID'], '\"') : false; - - switch ($tag) { - // Date fields. - case 'COMPLETED': - case 'CREATED': - case 'LAST-MODIFIED': - case 'X-MOZ-LASTACK': - case 'X-MOZ-SNOOZE-TIME': - $this->setAttribute($tag, $this->_parseDateTime($value, $tzid), $params); - break; - - case 'BDAY': - case 'X-SYNCJE-ANNIVERSARY': - case 'X-ANNIVERSARY': - $this->setAttribute($tag, $this->_parseDate($value), $params); - break; - - case 'DTEND': - case 'DTSTART': - case 'DTSTAMP': - case 'DUE': - case 'AALARM': - case 'RECURRENCE-ID': - // types like AALARM may contain additional data after a ; - // ignore these. - $ts = explode(';', $value); - if (isset($params['VALUE']) && $params['VALUE'] == 'DATE') { - $this->setAttribute($tag, $this->_parseDate($ts[0]), $params); - } else { - $this->setAttribute($tag, $this->_parseDateTime($ts[0], $tzid), $params); - } - break; - - case 'TRIGGER': - if (isset($params['VALUE']) && - $params['VALUE'] == 'DATE-TIME') { - $this->setAttribute($tag, $this->_parseDateTime($value, $tzid), $params); - } else { - $this->setAttribute($tag, $this->_parseDuration($value), $params); - } - break; - - // Comma seperated dates. - case 'EXDATE': - case 'RDATE': - if (!strlen($value)) { - break; - } - $dates = array(); - $separator = $this->isOldFormat() ? ';' : ','; - preg_match_all('/' . $separator . '([^' . $separator . ']*)/', $separator . $value, $values); - - foreach ($values[1] as $value) { - $dates[] = $this->_parseDate($value); - } - $this->setAttribute($tag, isset($dates[0]) ? $dates[0] : null, $params, true, $dates); - break; - - // Duration fields. - case 'DURATION': - $this->setAttribute($tag, $this->_parseDuration($value), $params); - break; - - // Period of time fields. - case 'FREEBUSY': - $periods = array(); - preg_match_all('/,([^,]*)/', ',' . $value, $values); - foreach ($values[1] as $value) { - $periods[] = $this->_parsePeriod($value); - } - - $this->setAttribute($tag, isset($periods[0]) ? $periods[0] : null, $params, true, $periods); - break; - - // UTC offset fields. - case 'TZOFFSETFROM': - case 'TZOFFSETTO': - $this->setAttribute($tag, $this->_parseUtcOffset($value), $params); - break; - - // Integer fields. - case 'PERCENT-COMPLETE': - case 'PRIORITY': - case 'REPEAT': - case 'SEQUENCE': - $this->setAttribute($tag, intval($value), $params); - break; - - // Geo fields. - case 'GEO': - if ($this->isOldFormat()) { - $floats = explode(',', $value); - $value = array('latitude' => floatval($floats[1]), - 'longitude' => floatval($floats[0])); - } else { - $floats = explode(';', $value); - $value = array('latitude' => floatval($floats[0]), - 'longitude' => floatval($floats[1])); - } - $this->setAttribute($tag, $value, $params); - break; - - // Recursion fields. - case 'EXRULE': - case 'RRULE': - $this->setAttribute($tag, trim($value), $params); - break; - - // ADR, ORG and N are lists seperated by unescaped semicolons - // with a specific number of slots. - case 'ADR': - case 'N': - case 'ORG': - $value = trim($value); - // As of rfc 2426 2.4.2 semicolon, comma, and colon must - // be escaped (comma is unescaped after splitting below). - $value = str_replace(array('\\n', '\\N', '\\;', '\\:'), - array($this->_newline, $this->_newline, ';', ':'), - $value); - - // Split by unescaped semicolons: - $values = preg_split('/(?setAttribute($tag, trim($value), $params, true, $values); - break; - - // String fields. - default: - if ($this->isOldFormat()) { - // vCalendar 1.0 and vCard 2.1 only escape semicolons - // and use unescaped semicolons to create lists. - $value = trim($value); - // Split by unescaped semicolons: - $values = preg_split('/(?setAttribute($tag, trim($value), $params, true, $values); - } else { - $value = trim($value); - // As of rfc 2426 2.4.2 semicolon, comma, and colon - // must be escaped (comma is unescaped after splitting - // below). - $value = str_replace(array('\\n', '\\N', '\\;', '\\:', '\\\\'), - array($this->_newline, $this->_newline, ';', ':', '\\'), - $value); - - // Split by unescaped commas. - $values = preg_split('/(?setAttribute($tag, trim($value), $params, true, $values); - } - break; - } - } - } - - // Process all components. - if ($components) { - // vTimezone components are processed first. They are - // needed to process vEvents that may use a TZID. - foreach ($components[0] as $key => $data) { - $type = trim($components[1][$key]); - if ($type != 'VTIMEZONE') { - continue; - } - $component = &Horde_iCalendar::newComponent($type, $this); - if ($component === false) { - return PEAR::raiseError("Unable to create object for type $type"); - } - $component->parsevCalendar($data, $type, $charset); - - $this->addComponent($component); - } - - // Now process the non-vTimezone components. - foreach ($components[0] as $key => $data) { - $type = trim($components[1][$key]); - if ($type == 'VTIMEZONE') { - continue; - } - $component = &Horde_iCalendar::newComponent($type, $this); - if ($component === false) { - return PEAR::raiseError("Unable to create object for type $type"); - } - $component->parsevCalendar($data, $type, $charset); - - $this->addComponent($component); - } - } - - return true; - } - - /** - * Export this component in vCal format. - * - * @param string $base The type of the base object. - * - * @return string vCal format data. - */ - function _exportvData($base = 'VCALENDAR') - { - $result = 'BEGIN:' . String::upper($base) . $this->_newline; - - // VERSION is not allowed for entries enclosed in VCALENDAR/ICALENDAR, - // as it is part of the enclosing VCALENDAR/ICALENDAR. See rfc2445 - if ($base !== 'VEVENT' && $base !== 'VTODO' && $base !== 'VALARM' && - $base !== 'VJOURNAL' && $base !== 'VFREEBUSY') { - // Ensure that version is the first attribute. - $result .= 'VERSION:' . $this->_version . $this->_newline; - } - foreach ($this->_attributes as $attribute) { - $name = $attribute['name']; - if ($name == 'VERSION') { - // Already done. - continue; - } - - $params_str = ''; - $params = $attribute['params']; - if ($params) { - foreach ($params as $param_name => $param_value) { - /* Skip CHARSET for iCalendar 2.0 data, not allowed. */ - if ($param_name == 'CHARSET' && !$this->isOldFormat()) { - continue; - } - /* Skip VALUE=DATE for vCalendar 1.0 data, not allowed. */ - if ($this->isOldFormat() && - $param_name == 'VALUE' && $param_value == 'DATE') { - continue; - } - - if ($param_value === null) { - $params_str .= ";$param_name"; - } else { - $len = strlen($param_value); - $safe_value = ''; - $quote = false; - for ($i = 0; $i < $len; ++$i) { - $ord = ord($param_value[$i]); - // Accept only valid characters. - if ($ord == 9 || $ord == 32 || $ord == 33 || - ($ord >= 35 && $ord <= 126) || - $ord >= 128) { - $safe_value .= $param_value[$i]; - // Characters above 128 do not need to be - // quoted as per RFC2445 but Outlook requires - // this. - if ($ord == 44 || $ord == 58 || $ord == 59 || - $ord >= 128) { - $quote = true; - } - } - } - if ($quote) { - $safe_value = '"' . $safe_value . '"'; - } - $params_str .= ";$param_name=$safe_value"; - } - } - } - - $value = $attribute['value']; - switch ($name) { - // Date fields. - case 'COMPLETED': - case 'CREATED': - case 'DCREATED': - case 'LAST-MODIFIED': - case 'X-MOZ-LASTACK': - case 'X-MOZ-SNOOZE-TIME': - $value = $this->_exportDateTime($value); - break; - - case 'DTEND': - case 'DTSTART': - case 'DTSTAMP': - case 'DUE': - case 'AALARM': - case 'RECURRENCE-ID': - if (isset($params['VALUE'])) { - if ($params['VALUE'] == 'DATE') { - // VCALENDAR 1.0 uses T000000 - T235959 for all day events: - if ($this->isOldFormat() && $name == 'DTEND') { - $d = new Horde_Date($value); - $value = new Horde_Date(array( - 'year' => $d->year, - 'month' => $d->month, - 'mday' => $d->mday - 1)); - $value->correct(); - $value = $this->_exportDate($value, '235959'); - } else { - $value = $this->_exportDate($value, '000000'); - } - } else { - $value = $this->_exportDateTime($value); - } - } else { - $value = $this->_exportDateTime($value); - } - break; - - // Comma seperated dates. - case 'EXDATE': - case 'RDATE': - $dates = array(); - foreach ($value as $date) { - if (isset($params['VALUE'])) { - if ($params['VALUE'] == 'DATE') { - $dates[] = $this->_exportDate($date, '000000'); - } elseif ($params['VALUE'] == 'PERIOD') { - $dates[] = $this->_exportPeriod($date); - } else { - $dates[] = $this->_exportDateTime($date); - } - } else { - $dates[] = $this->_exportDateTime($date); - } - } - $value = implode($this->isOldFormat() ? ';' : ',', $dates); - break; - - case 'TRIGGER': - if (isset($params['VALUE'])) { - if ($params['VALUE'] == 'DATE-TIME') { - $value = $this->_exportDateTime($value); - } elseif ($params['VALUE'] == 'DURATION') { - $value = $this->_exportDuration($value); - } - } else { - $value = $this->_exportDuration($value); - } - break; - - // Duration fields. - case 'DURATION': - $value = $this->_exportDuration($value); - break; - - // Period of time fields. - case 'FREEBUSY': - $value_str = ''; - foreach ($value as $period) { - $value_str .= empty($value_str) ? '' : ','; - $value_str .= $this->_exportPeriod($period); - } - $value = $value_str; - break; - - // UTC offset fields. - case 'TZOFFSETFROM': - case 'TZOFFSETTO': - $value = $this->_exportUtcOffset($value); - break; - - // Integer fields. - case 'PERCENT-COMPLETE': - case 'PRIORITY': - case 'REPEAT': - case 'SEQUENCE': - $value = "$value"; - break; - - // Geo fields. - case 'GEO': - if ($this->isOldFormat()) { - $value = $value['longitude'] . ',' . $value['latitude']; - } else { - $value = $value['latitude'] . ';' . $value['longitude']; - } - break; - - // Recurrence fields. - case 'EXRULE': - case 'RRULE': - break; - - default: - if ($this->isOldFormat()) { - if (is_array($attribute['values']) && - count($attribute['values']) > 1) { - $values = $attribute['values']; - if ($name == 'N' || $name == 'ADR' || $name == 'ORG') { - $glue = ';'; - } else { - $glue = ','; - } - $values = str_replace(';', '\\;', $values); - $value = implode($glue, $values); - } else { - /* vcard 2.1 and vcalendar 1.0 escape only - * semicolons */ - $value = str_replace(';', '\\;', $value); - } - // Text containing newlines or ASCII >= 127 must be BASE64 - // or QUOTED-PRINTABLE encoded. Currently we use - // QUOTED-PRINTABLE as default. - if (preg_match("/[^\x20-\x7F]/", $value) && - empty($params['ENCODING'])) { - $params['ENCODING'] = 'QUOTED-PRINTABLE'; - $params_str .= ';ENCODING=QUOTED-PRINTABLE'; - // Add CHARSET as well. At least the synthesis client - // gets confused otherwise - if (empty($params['CHARSET'])) { - $params['CHARSET'] = 'UTF-8'; - $params_str .= ';CHARSET=' . $params['CHARSET']; - } - } - } else { - if (is_array($attribute['values']) && - count($attribute['values'])) { - $values = $attribute['values']; - if ($name == 'N' || $name == 'ADR' || $name == 'ORG') { - $glue = ';'; - } else { - $glue = ','; - } - // As of rfc 2426 2.5 semicolon and comma must be - // escaped. - $values = str_replace(array('\\', ';', ','), - array('\\\\', '\\;', '\\,'), - $values); - $value = implode($glue, $values); - } else { - // As of rfc 2426 2.5 semicolon and comma must be - // escaped. - $value = str_replace(array('\\', ';', ','), - array('\\\\', '\\;', '\\,'), - $value); - } - $value = preg_replace('/\r?\n/', '\n', $value); - } - break; - } - - $value = str_replace("\r", '', $value); - if (!empty($params['ENCODING']) && - $params['ENCODING'] == 'QUOTED-PRINTABLE' && - strlen(trim($value))) { - $result .= $name . $params_str . ':' - . str_replace('=0A', '=0D=0A', - $this->_quotedPrintableEncode($value)) - . $this->_newline; - } else { - $attr_string = $name . $params_str . ':' . $value; - if (!$this->isOldFormat()) { - $attr_string = String::wordwrap($attr_string, 75, $this->_newline . ' ', - true, 'utf-8', true); - } - $result .= $attr_string . $this->_newline; - } - } - - foreach ($this->_components as $component) { - $result .= $component->exportvCalendar(); - } - - return $result . 'END:' . $base . $this->_newline; - } - - /** - * Parse a UTC Offset field. - */ - function _parseUtcOffset($text) - { - $offset = array(); - if (preg_match('/(\+|-)([0-9]{2})([0-9]{2})([0-9]{2})?/', $text, $timeParts)) { - $offset['ahead'] = (bool)($timeParts[1] == '+'); - $offset['hour'] = intval($timeParts[2]); - $offset['minute'] = intval($timeParts[3]); - if (isset($timeParts[4])) { - $offset['second'] = intval($timeParts[4]); - } - return $offset; - } else { - return false; - } - } - - /** - * Export a UTC Offset field. - */ - function _exportUtcOffset($value) - { - $offset = $value['ahead'] ? '+' : '-'; - $offset .= sprintf('%02d%02d', - $value['hour'], $value['minute']); - if (isset($value['second'])) { - $offset .= sprintf('%02d', $value['second']); - } - - return $offset; - } - - /** - * Parse a Time Period field. - */ - function _parsePeriod($text) - { - $periodParts = explode('/', $text); - - $start = $this->_parseDateTime($periodParts[0]); - - if ($duration = $this->_parseDuration($periodParts[1])) { - return array('start' => $start, 'duration' => $duration); - } elseif ($end = $this->_parseDateTime($periodParts[1])) { - return array('start' => $start, 'end' => $end); - } - } - - /** - * Export a Time Period field. - */ - function _exportPeriod($value) - { - $period = $this->_exportDateTime($value['start']); - $period .= '/'; - if (isset($value['duration'])) { - $period .= $this->_exportDuration($value['duration']); - } else { - $period .= $this->_exportDateTime($value['end']); - } - return $period; - } - - /** - * Grok the TZID and return an offset in seconds from UTC for this - * date and time. - */ - function _parseTZID($date, $time, $tzid) - { - $vtimezone = $this->_container->findComponentByAttribute('vtimezone', 'TZID', $tzid); - if (!$vtimezone) { - // use PHP's standard timezone db to determine tzoffset - try { - $tz = new DateTimeZone($tzid); - $dt = new DateTime('now', $tz); - $dt->setDate($date['year'], $date['month'], $date['mday']); - $dt->setTime($time['hour'], $time['minute'], $date['recond']); - return $tz->getOffset($dt); - } - catch (Exception $e) { - return false; - } - } - - $change_times = array(); - foreach ($vtimezone->getComponents() as $o) { - $t = $vtimezone->parseChild($o, $date['year']); - if ($t !== false) { - $change_times[] = $t; - } - } - - if (!$change_times) { - return false; - } - - sort($change_times); - - // Time is arbitrarily based on UTC for comparison. - $t = @gmmktime($time['hour'], $time['minute'], $time['second'], - $date['month'], $date['mday'], $date['year']); - - if ($t < $change_times[0]['time']) { - return $change_times[0]['from']; - } - - for ($i = 0, $n = count($change_times); $i < $n - 1; $i++) { - if (($t >= $change_times[$i]['time']) && - ($t < $change_times[$i + 1]['time'])) { - return $change_times[$i]['to']; - } - } - - if ($t >= $change_times[$n - 1]['time']) { - return $change_times[$n - 1]['to']; - } - - return false; - } - - /** - * Parses a DateTime field and returns a unix timestamp. If the - * field cannot be parsed then the original text is returned - * unmodified. - * - * @todo This function should be moved to Horde_Date and made public. - */ - function _parseDateTime($text, $tzid = false) - { - $dateParts = explode('T', $text); - if (count($dateParts) != 2 && !empty($text)) { - // Not a datetime field but may be just a date field. - if (!preg_match('/^(\d{4})-?(\d{2})-?(\d{2})$/', $text, $match)) { - // Or not - return $text; - } - $newtext = $text.'T000000'; - $dateParts = explode('T', $newtext); - } - - if (!$date = Horde_iCalendar::_parseDate($dateParts[0])) { - return $text; - } - if (!$time = Horde_iCalendar::_parseTime($dateParts[1])) { - return $text; - } - - // Get timezone info for date fields from $tzid and container. - $tzoffset = ($time['zone'] == 'Local' && $tzid && is_a($this->_container, 'Horde_iCalendar')) - ? $this->_parseTZID($date, $time, $tzid) : false; - if ($time['zone'] == 'UTC' || $tzoffset !== false) { - $result = @gmmktime($time['hour'], $time['minute'], $time['second'], - $date['month'], $date['mday'], $date['year']); - if ($tzoffset) { - $result -= $tzoffset; - } - } else { - // We don't know the timezone so assume local timezone. - // FIXME: shouldn't this be based on the user's timezone - // preference rather than the server's timezone? - $result = @mktime($time['hour'], $time['minute'], $time['second'], - $date['month'], $date['mday'], $date['year']); - } - - return ($result !== false) ? $result : $text; - } - - /** - * Export a DateTime field. - */ - function _exportDateTime($value) - { - $temp = array(); - if (!is_object($value) && !is_array($value)) { - $tz = date('O', $value); - $TZOffset = (3600 * substr($tz, 0, 3)) + (60 * substr($tz, 3, 2)); - $value -= $TZOffset; - - $temp['zone'] = 'UTC'; - list($temp['year'], $temp['month'], $temp['mday'], $temp['hour'], $temp['minute'], $temp['second']) = explode('-', date('Y-n-j-G-i-s', $value)); - } else { - $dateOb = new Horde_Date($value); - return Horde_iCalendar::_exportDateTime($dateOb->timestamp()); - } - - return Horde_iCalendar::_exportDate($temp) . 'T' . Horde_iCalendar::_exportTime($temp); - } - - /** - * Parses a Time field. - * - * @static - */ - function _parseTime($text) - { - if (preg_match('/([0-9]{2})([0-9]{2})([0-9]{2})(Z)?/', $text, $timeParts)) { - $time['hour'] = intval($timeParts[1]); - $time['minute'] = intval($timeParts[2]); - $time['second'] = intval($timeParts[3]); - if (isset($timeParts[4])) { - $time['zone'] = 'UTC'; - } else { - $time['zone'] = 'Local'; - } - return $time; - } else { - return false; - } - } - - /** - * Exports a Time field. - */ - function _exportTime($value) - { - $time = sprintf('%02d%02d%02d', - $value['hour'], $value['minute'], $value['second']); - if ($value['zone'] == 'UTC') { - $time .= 'Z'; - } - return $time; - } - - /** - * Parses a Date field. - * - * @static - */ - function _parseDate($text) - { - $parts = explode('T', $text); - if (count($parts) == 2) { - $text = $parts[0]; - } - - if (!preg_match('/^(\d{4})-?(\d{2})-?(\d{2})$/', $text, $match)) { - return false; - } - - return array('year' => $match[1], - 'month' => $match[2], - 'mday' => $match[3]); - } - - /** - * Exports a date field. - * - * @param object|array $value Date object or hash. - * @param string $autoconvert If set, use this as time part to export the - * date as datetime when exporting to Vcalendar - * 1.0. Examples: '000000' or '235959' - */ - function _exportDate($value, $autoconvert = false) - { - if (is_object($value)) { - $value = array('year' => $value->year, 'month' => $value->month, 'mday' => $value->mday); - } - if ($autoconvert !== false && $this->isOldFormat()) { - return sprintf('%04d%02d%02dT%s', $value['year'], $value['month'], $value['mday'], $autoconvert); - } else { - return sprintf('%04d%02d%02d', $value['year'], $value['month'], $value['mday']); - } - } - - /** - * Parse a Duration Value field. - */ - function _parseDuration($text) - { - if (preg_match('/([+]?|[-])P(([0-9]+W)|([0-9]+D)|)(T(([0-9]+H)|([0-9]+M)|([0-9]+S))+)?/', trim($text), $durvalue)) { - // Weeks. - $duration = 7 * 86400 * intval($durvalue[3]); - - if (count($durvalue) > 4) { - // Days. - $duration += 86400 * intval($durvalue[4]); - } - if (count($durvalue) > 5) { - // Hours. - $duration += 3600 * intval($durvalue[7]); - - // Mins. - if (isset($durvalue[8])) { - $duration += 60 * intval($durvalue[8]); - } - - // Secs. - if (isset($durvalue[9])) { - $duration += intval($durvalue[9]); - } - } - - // Sign. - if ($durvalue[1] == "-") { - $duration *= -1; - } - - return $duration; - } else { - return false; - } - } - - /** - * Export a duration value. - */ - function _exportDuration($value) - { - $duration = ''; - if ($value < 0) { - $value *= -1; - $duration .= '-'; - } - $duration .= 'P'; - - $weeks = floor($value / (7 * 86400)); - $value = $value % (7 * 86400); - if ($weeks) { - $duration .= $weeks . 'W'; - } - - $days = floor($value / (86400)); - $value = $value % (86400); - if ($days) { - $duration .= $days . 'D'; - } - - if ($value) { - $duration .= 'T'; - - $hours = floor($value / 3600); - $value = $value % 3600; - if ($hours) { - $duration .= $hours . 'H'; - } - - $mins = floor($value / 60); - $value = $value % 60; - if ($mins) { - $duration .= $mins . 'M'; - } - - if ($value) { - $duration .= $value . 'S'; - } - } - - return $duration; - } - - /** - * Converts an 8bit string to a quoted-printable string according to RFC - * 2045, section 6.7. - * - * imap_8bit() does not apply all necessary rules. - * - * @param string $input The string to be encoded. - * - * @return string The quoted-printable encoded string. - */ - function _quotedPrintableEncode($input = '') - { - $output = $line = ''; - $len = strlen($input); - - for ($i = 0; $i < $len; ++$i) { - $ord = ord($input[$i]); - // Encode non-printable characters (rule 2). - if ($ord == 9 || - ($ord >= 32 && $ord <= 60) || - ($ord >= 62 && $ord <= 126)) { - $chunk = $input[$i]; - } else { - // Quoted printable encoding (rule 1). - $chunk = '=' . String::upper(sprintf('%02X', $ord)); - } - $line .= $chunk; - // Wrap long lines (rule 5) - if (strlen($line) + 1 > 76) { - $line = String::wordwrap($line, 75, "=\r\n", true, 'us-ascii', true); - $newline = strrchr($line, "\r\n"); - if ($newline !== false) { - $output .= substr($line, 0, -strlen($newline) + 2); - $line = substr($newline, 2); - } else { - $output .= $line; - } - continue; - } - // Wrap at line breaks for better readability (rule 4). - if (substr($line, -3) == '=0A') { - $output .= $line . "=\r\n"; - $line = ''; - } - } - $output .= $line; - - // Trailing whitespace must be encoded (rule 3). - $lastpos = strlen($output) - 1; - if ($output[$lastpos] == chr(9) || - $output[$lastpos] == chr(32)) { - $output[$lastpos] = '='; - $output .= String::upper(sprintf('%02X', ord($output[$lastpos]))); - } - - return $output; - } - -} - - - -/** - * Class representing vAlarms. - * - * $Horde: framework/iCalendar/iCalendar/valarm.php,v 1.8.10.9 2009-01-06 15:23:53 jan Exp $ - * - * Copyright 2003-2009 The Horde Project (http://www.horde.org/) - * - * See the enclosed file COPYING for license information (LGPL). If you - * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html. - * - * @author Mike Cochrane - * @since Horde 3.0 - * @package Horde_iCalendar - */ -class Horde_iCalendar_valarm extends Horde_iCalendar { - - function getType() - { - return 'vAlarm'; - } - - function exportvCalendar() - { - return parent::_exportvData('VALARM'); - } - -} - -/** - * Class representing vEvents. - * - * $Horde: framework/iCalendar/iCalendar/vevent.php,v 1.31.10.16 2009-01-06 15:23:53 jan Exp $ - * - * Copyright 2003-2009 The Horde Project (http://www.horde.org/) - * - * See the enclosed file COPYING for license information (LGPL). If you - * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html. - * - * @author Mike Cochrane - * @since Horde 3.0 - * @package Horde_iCalendar - */ -class Horde_iCalendar_vevent extends Horde_iCalendar { - - function getType() - { - return 'vEvent'; - } - - function exportvCalendar() - { - // Default values. - $requiredAttributes = array(); - $requiredAttributes['DTSTAMP'] = time(); - $requiredAttributes['UID'] = $this->_exportDateTime(time()) - . substr(str_pad(base_convert(microtime(), 10, 36), 16, uniqid(mt_rand()), STR_PAD_LEFT), -16) - . '@' . (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'localhost'); - - $method = !empty($this->_container) ? - $this->_container->getAttribute('METHOD') : 'PUBLISH'; - - switch ($method) { - case 'PUBLISH': - $requiredAttributes['DTSTART'] = time(); - $requiredAttributes['SUMMARY'] = ''; - break; - - case 'REQUEST': - $requiredAttributes['ATTENDEE'] = ''; - $requiredAttributes['DTSTART'] = time(); - $requiredAttributes['SUMMARY'] = ''; - break; - - case 'REPLY': - $requiredAttributes['ATTENDEE'] = ''; - break; - - case 'ADD': - $requiredAttributes['DTSTART'] = time(); - $requiredAttributes['SEQUENCE'] = 1; - $requiredAttributes['SUMMARY'] = ''; - break; - - case 'CANCEL': - $requiredAttributes['ATTENDEE'] = ''; - $requiredAttributes['SEQUENCE'] = 1; - break; - - case 'REFRESH': - $requiredAttributes['ATTENDEE'] = ''; - break; - } - - foreach ($requiredAttributes as $name => $default_value) { - if (is_a($this->getAttribute($name), 'PEAR_Error')) { - $this->setAttribute($name, $default_value); - } - } - - return parent::_exportvData('VEVENT'); - } - - /** - * Update the status of an attendee of an event. - * - * @param $email The email address of the attendee. - * @param $status The participant status to set. - * @param $fullname The full name of the participant to set. - */ - function updateAttendee($email, $status, $fullname = '') - { - foreach ($this->_attributes as $key => $attribute) { - if ($attribute['name'] == 'ATTENDEE' && - $attribute['value'] == 'mailto:' . $email) { - $this->_attributes[$key]['params']['PARTSTAT'] = $status; - if (!empty($fullname)) { - $this->_attributes[$key]['params']['CN'] = $fullname; - } - unset($this->_attributes[$key]['params']['RSVP']); - return; - } - } - $params = array('PARTSTAT' => $status); - if (!empty($fullname)) { - $params['CN'] = $fullname; - } - $this->setAttribute('ATTENDEE', 'mailto:' . $email, $params); - } - - /** - * Return the organizer display name or email. - * - * @return string The organizer name to display for this event. - */ - function organizerName() - { - $organizer = $this->getAttribute('ORGANIZER', true); - if (is_a($organizer, 'PEAR_Error')) { - return _("An unknown person"); - } - - if (isset($organizer[0]['CN'])) { - return $organizer[0]['CN']; - } - - $organizer = parse_url($this->getAttribute('ORGANIZER')); - - return $organizer['path']; - } - - /** - * Update this event with details from another event. - * - * @param Horde_iCalendar_vEvent $vevent The vEvent with latest details. - */ - function updateFromvEvent($vevent) - { - $newAttributes = $vevent->getAllAttributes(); - foreach ($newAttributes as $newAttribute) { - $currentValue = $this->getAttribute($newAttribute['name']); - if (is_a($currentValue, 'PEAR_error')) { - // Already exists so just add it. - $this->setAttribute($newAttribute['name'], - $newAttribute['value'], - $newAttribute['params']); - } else { - // Already exists so locate and modify. - $found = false; - - // Try matching the attribte name and value incase - // only the params changed (eg attendee updating - // status). - foreach ($this->_attributes as $id => $attr) { - if ($attr['name'] == $newAttribute['name'] && - $attr['value'] == $newAttribute['value']) { - // merge the params - foreach ($newAttribute['params'] as $param_id => $param_name) { - $this->_attributes[$id]['params'][$param_id] = $param_name; - } - $found = true; - break; - } - } - if (!$found) { - // Else match the first attribute with the same - // name (eg changing start time). - foreach ($this->_attributes as $id => $attr) { - if ($attr['name'] == $newAttribute['name']) { - $this->_attributes[$id]['value'] = $newAttribute['value']; - // Merge the params. - foreach ($newAttribute['params'] as $param_id => $param_name) { - $this->_attributes[$id]['params'][$param_id] = $param_name; - } - break; - } - } - } - } - } - } - - /** - * Update just the attendess of event with details from another - * event. - * - * @param Horde_iCalendar_vEvent $vevent The vEvent with latest details - */ - function updateAttendeesFromvEvent($vevent) - { - $newAttributes = $vevent->getAllAttributes(); - foreach ($newAttributes as $newAttribute) { - if ($newAttribute['name'] != 'ATTENDEE') { - continue; - } - $currentValue = $this->getAttribute($newAttribute['name']); - if (is_a($currentValue, 'PEAR_error')) { - // Already exists so just add it. - $this->setAttribute($newAttribute['name'], - $newAttribute['value'], - $newAttribute['params']); - } else { - // Already exists so locate and modify. - $found = false; - // Try matching the attribte name and value incase - // only the params changed (eg attendee updating - // status). - foreach ($this->_attributes as $id => $attr) { - if ($attr['name'] == $newAttribute['name'] && - $attr['value'] == $newAttribute['value']) { - // Merge the params. - foreach ($newAttribute['params'] as $param_id => $param_name) { - $this->_attributes[$id]['params'][$param_id] = $param_name; - } - $found = true; - break; - } - } - - if (!$found) { - // Else match the first attribute with the same - // name (eg changing start time). - foreach ($this->_attributes as $id => $attr) { - if ($attr['name'] == $newAttribute['name']) { - $this->_attributes[$id]['value'] = $newAttribute['value']; - // Merge the params. - foreach ($newAttribute['params'] as $param_id => $param_name) { - $this->_attributes[$id]['params'][$param_id] = $param_name; - } - break; - } - } - } - } - } - } - -} - -/** - * Class representing vFreebusy components. - * - * $Horde: framework/iCalendar/iCalendar/vfreebusy.php,v 1.16.10.18 2009-01-06 15:23:53 jan Exp $ - * - * Copyright 2003-2009 The Horde Project (http://www.horde.org/) - * - * See the enclosed file COPYING for license information (LGPL). If you - * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html. - * - * @todo Don't use timestamps - * - * @author Mike Cochrane - * @since Horde 3.0 - * @package Horde_iCalendar - */ -class Horde_iCalendar_vfreebusy extends Horde_iCalendar { - - var $_busyPeriods = array(); - var $_extraParams = array(); - - /** - * Returns the type of this calendar component. - * - * @return string The type of this component. - */ - function getType() - { - return 'vFreebusy'; - } - - /** - * Parses a string containing vFreebusy data. - * - * @param string $data The data to parse. - */ - function parsevCalendar($data, $type = null, $charset = null) - { - parent::parsevCalendar($data, 'VFREEBUSY', $charset); - - // Do something with all the busy periods. - foreach ($this->_attributes as $key => $attribute) { - if ($attribute['name'] != 'FREEBUSY') { - continue; - } - foreach ($attribute['values'] as $value) { - $params = isset($attribute['params']) - ? $attribute['params'] - : array(); - if (isset($value['duration'])) { - $this->addBusyPeriod('BUSY', $value['start'], null, - $value['duration'], $params); - } else { - $this->addBusyPeriod('BUSY', $value['start'], - $value['end'], null, $params); - } - } - unset($this->_attributes[$key]); - } - } - - /** - * Returns the component exported as string. - * - * @return string The exported vFreeBusy information according to the - * iCalender format specification. - */ - function exportvCalendar() - { - foreach ($this->_busyPeriods as $start => $end) { - $periods = array(array('start' => $start, 'end' => $end)); - $this->setAttribute('FREEBUSY', $periods, - isset($this->_extraParams[$start]) - ? $this->_extraParams[$start] : array()); - } - - $res = parent::_exportvData('VFREEBUSY'); - - foreach ($this->_attributes as $key => $attribute) { - if ($attribute['name'] == 'FREEBUSY') { - unset($this->_attributes[$key]); - } - } - - return $res; - } - - /** - * Returns a display name for this object. - * - * @return string A clear text name for displaying this object. - */ - function getName() - { - $name = ''; - $method = !empty($this->_container) ? - $this->_container->getAttribute('METHOD') : 'PUBLISH'; - - if (is_a($method, 'PEAR_Error') || $method == 'PUBLISH') { - $attr = 'ORGANIZER'; - } elseif ($method == 'REPLY') { - $attr = 'ATTENDEE'; - } - - $name = $this->getAttribute($attr, true); - if (!is_a($name, 'PEAR_Error') && isset($name[0]['CN'])) { - return $name[0]['CN']; - } - - $name = $this->getAttribute($attr); - if (is_a($name, 'PEAR_Error')) { - return ''; - } else { - $name = parse_url($name); - return $name['path']; - } - } - - /** - * Returns the email address for this object. - * - * @return string The email address of this object's owner. - */ - function getEmail() - { - $name = ''; - $method = !empty($this->_container) - ? $this->_container->getAttribute('METHOD') : 'PUBLISH'; - - if (is_a($method, 'PEAR_Error') || $method == 'PUBLISH') { - $attr = 'ORGANIZER'; - } elseif ($method == 'REPLY') { - $attr = 'ATTENDEE'; - } - - $name = $this->getAttribute($attr); - if (is_a($name, 'PEAR_Error')) { - return ''; - } else { - $name = parse_url($name); - return $name['path']; - } - } - - /** - * Returns the busy periods. - * - * @return array All busy periods. - */ - function getBusyPeriods() - { - return $this->_busyPeriods; - } - - /** - * Returns any additional freebusy parameters. - * - * @return array Additional parameters of the freebusy periods. - */ - function getExtraParams() - { - return $this->_extraParams; - } - - /** - * Returns all the free periods of time in a given period. - * - * @param integer $startStamp The start timestamp. - * @param integer $endStamp The end timestamp. - * - * @return array A hash with free time periods, the start times as the - * keys and the end times as the values. - */ - function getFreePeriods($startStamp, $endStamp) - { - $this->simplify(); - $periods = array(); - - // Check that we have data for some part of this period. - if ($this->getEnd() < $startStamp || $this->getStart() > $endStamp) { - return $periods; - } - - // Locate the first time in the requested period we have data for. - $nextstart = max($startStamp, $this->getStart()); - - // Check each busy period and add free periods in between. - foreach ($this->_busyPeriods as $start => $end) { - if ($start <= $endStamp && $end >= $nextstart) { - if ($nextstart <= $start) { - $periods[$nextstart] = min($start, $endStamp); - } - $nextstart = min($end, $endStamp); - } - } - - // If we didn't read the end of the requested period but still have - // data then mark as free to the end of the period or available data. - if ($nextstart < $endStamp && $nextstart < $this->getEnd()) { - $periods[$nextstart] = min($this->getEnd(), $endStamp); - } - - return $periods; - } - - /** - * Adds a busy period to the info. - * - * This function may throw away data in case you add a period with a start - * date that already exists. The longer of the two periods will be chosen - * (and all information associated with the shorter one will be removed). - * - * @param string $type The type of the period. Either 'FREE' or - * 'BUSY'; only 'BUSY' supported at the moment. - * @param integer $start The start timestamp of the period. - * @param integer $end The end timestamp of the period. - * @param integer $duration The duration of the period. If specified, the - * $end parameter will be ignored. - * @param array $extra Additional parameters for this busy period. - */ - function addBusyPeriod($type, $start, $end = null, $duration = null, - $extra = array()) - { - if ($type == 'FREE') { - // Make sure this period is not marked as busy. - return false; - } - - // Calculate the end time if duration was specified. - $tempEnd = is_null($duration) ? $end : $start + $duration; - - // Make sure the period length is always positive. - $end = max($start, $tempEnd); - $start = min($start, $tempEnd); - - if (isset($this->_busyPeriods[$start])) { - // Already a period starting at this time. Change the current - // period only if the new one is longer. This might be a problem - // if the callee assumes that there is no simplification going - // on. But since the periods are stored using the start time of - // the busy periods we have to throw away data here. - if ($end > $this->_busyPeriods[$start]) { - $this->_busyPeriods[$start] = $end; - $this->_extraParams[$start] = $extra; - } - } else { - // Add a new busy period. - $this->_busyPeriods[$start] = $end; - $this->_extraParams[$start] = $extra; - } - - return true; - } - - /** - * Returns the timestamp of the start of the time period this free busy - * information covers. - * - * @return integer A timestamp. - */ - function getStart() - { - if (!is_a($this->getAttribute('DTSTART'), 'PEAR_Error')) { - return $this->getAttribute('DTSTART'); - } elseif (count($this->_busyPeriods)) { - return min(array_keys($this->_busyPeriods)); - } else { - return false; - } - } - - /** - * Returns the timestamp of the end of the time period this free busy - * information covers. - * - * @return integer A timestamp. - */ - function getEnd() - { - if (!is_a($this->getAttribute('DTEND'), 'PEAR_Error')) { - return $this->getAttribute('DTEND'); - } elseif (count($this->_busyPeriods)) { - return max(array_values($this->_busyPeriods)); - } else { - return false; - } - } - - /** - * Merges the busy periods of another Horde_iCalendar_vfreebusy object - * into this one. - * - * This might lead to simplification no matter what you specify for the - * "simplify" flag since periods with the same start date will lead to the - * shorter period being removed (see addBusyPeriod). - * - * @param Horde_iCalendar_vfreebusy $freebusy A freebusy object. - * @param boolean $simplify If true, simplify() will - * called after the merge. - */ - function merge($freebusy, $simplify = true) - { - if (!is_a($freebusy, 'Horde_iCalendar_vfreebusy')) { - return false; - } - - $extra = $freebusy->getExtraParams(); - foreach ($freebusy->getBusyPeriods() as $start => $end) { - // This might simplify the busy periods without taking the - // "simplify" flag into account. - $this->addBusyPeriod('BUSY', $start, $end, null, - isset($extra[$start]) - ? $extra[$start] : array()); - } - - $thisattr = $this->getAttribute('DTSTART'); - $thatattr = $freebusy->getAttribute('DTSTART'); - if (is_a($thisattr, 'PEAR_Error') && !is_a($thatattr, 'PEAR_Error')) { - $this->setAttribute('DTSTART', $thatattr, array(), false); - } elseif (!is_a($thatattr, 'PEAR_Error')) { - if ($thatattr < $thisattr) { - $this->setAttribute('DTSTART', $thatattr, array(), false); - } - } - - $thisattr = $this->getAttribute('DTEND'); - $thatattr = $freebusy->getAttribute('DTEND'); - if (is_a($thisattr, 'PEAR_Error') && !is_a($thatattr, 'PEAR_Error')) { - $this->setAttribute('DTEND', $thatattr, array(), false); - } elseif (!is_a($thatattr, 'PEAR_Error')) { - if ($thatattr > $thisattr) { - $this->setAttribute('DTEND', $thatattr, array(), false); - } - } - - if ($simplify) { - $this->simplify(); - } - - return true; - } - - /** - * Removes all overlaps and simplifies the busy periods array as much as - * possible. - */ - function simplify() - { - $clean = false; - $busy = array($this->_busyPeriods, $this->_extraParams); - while (!$clean) { - $result = $this->_simplify($busy[0], $busy[1]); - $clean = $result === $busy; - $busy = $result; - } - - ksort($result[1], SORT_NUMERIC); - $this->_extraParams = $result[1]; - - ksort($result[0], SORT_NUMERIC); - $this->_busyPeriods = $result[0]; - } - - function _simplify($busyPeriods, $extraParams = array()) - { - $checked = array(); - $checkedExtra = array(); - $checkedEmpty = true; - - foreach ($busyPeriods as $start => $end) { - if ($checkedEmpty) { - $checked[$start] = $end; - $checkedExtra[$start] = isset($extraParams[$start]) - ? $extraParams[$start] : array(); - $checkedEmpty = false; - } else { - $added = false; - foreach ($checked as $testStart => $testEnd) { - // Replace old period if the new period lies around the - // old period. - if ($start <= $testStart && $end >= $testEnd) { - // Remove old period entry. - unset($checked[$testStart]); - unset($checkedExtra[$testStart]); - // Add replacing entry. - $checked[$start] = $end; - $checkedExtra[$start] = isset($extraParams[$start]) - ? $extraParams[$start] : array(); - $added = true; - } elseif ($start >= $testStart && $end <= $testEnd) { - // The new period lies fully within the old - // period. Just forget about it. - $added = true; - } elseif (($end <= $testEnd && $end >= $testStart) || - ($start >= $testStart && $start <= $testEnd)) { - // Now we are in trouble: Overlapping time periods. If - // we allow for additional parameters we cannot simply - // choose one of the two parameter sets. It's better - // to leave two separated time periods. - $extra = isset($extraParams[$start]) - ? $extraParams[$start] : array(); - $testExtra = isset($checkedExtra[$testStart]) - ? $checkedExtra[$testStart] : array(); - // Remove old period entry. - unset($checked[$testStart]); - unset($checkedExtra[$testStart]); - // We have two periods overlapping. Are their - // additional parameters the same or different? - $newStart = min($start, $testStart); - $newEnd = max($end, $testEnd); - if ($extra === $testExtra) { - // Both periods have the same information. So we - // can just merge. - $checked[$newStart] = $newEnd; - $checkedExtra[$newStart] = $extra; - } else { - // Extra parameters are different. Create one - // period at the beginning with the params of the - // first period and create a trailing period with - // the params of the second period. The break - // point will be the end of the first period. - $break = min($end, $testEnd); - $checked[$newStart] = $break; - $checkedExtra[$newStart] = - isset($extraParams[$newStart]) - ? $extraParams[$newStart] : array(); - $checked[$break] = $newEnd; - $highStart = max($start, $testStart); - $checkedExtra[$break] = - isset($extraParams[$highStart]) - ? $extraParams[$highStart] : array(); - - // Ensure we also have the extra data in the - // extraParams. - $extraParams[$break] = - isset($extraParams[$highStart]) - ? $extraParams[$highStart] : array(); - } - $added = true; - } - - if ($added) { - break; - } - } - - if (!$added) { - $checked[$start] = $end; - $checkedExtra[$start] = isset($extraParams[$start]) - ? $extraParams[$start] : array(); - } - } - } - - return array($checked, $checkedExtra); - } - -} - -/** - * Class representing vJournals. - * - * $Horde: framework/iCalendar/iCalendar/vjournal.php,v 1.8.10.9 2009-01-06 15:23:53 jan Exp $ - * - * Copyright 2003-2009 The Horde Project (http://www.horde.org/) - * - * See the enclosed file COPYING for license information (LGPL). If you - * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html. - * - * @author Mike Cochrane - * @since Horde 3.0 - * @package Horde_iCalendar - */ -class Horde_iCalendar_vjournal extends Horde_iCalendar { - - function getType() - { - return 'vJournal'; - } - - function exportvCalendar() - { - return parent::_exportvData('VJOURNAL'); - } - -} - - - - -/** - * Class representing vNotes. - * - * $Horde: framework/iCalendar/iCalendar/vnote.php,v 1.3.10.10 2009-01-06 15:23:53 jan Exp $ - * - * Copyright 2003-2009 The Horde Project (http://www.horde.org/) - * - * See the enclosed file COPYING for license information (LGPL). If you - * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html. - * - * @author Mike Cochrane - * @author Karsten Fourmont - * @package Horde_iCalendar - */ -class Horde_iCalendar_vnote extends Horde_iCalendar { - - function Horde_iCalendar_vnote($version = '1.1') - { - return parent::Horde_iCalendar($version); - } - - function getType() - { - return 'vNote'; - } - - /** - * Unlike vevent and vtodo, a vnote is normally not enclosed in an - * iCalendar container. (BEGIN..END) - */ - function exportvCalendar() - { - $requiredAttributes['BODY'] = ''; - $requiredAttributes['VERSION'] = '1.1'; - - foreach ($requiredAttributes as $name => $default_value) { - if (is_a($this->getattribute($name), 'PEAR_Error')) { - $this->setAttribute($name, $default_value); - } - } - - return $this->_exportvData('VNOTE'); - } - -} - -/** - * Class representing vTimezones. - * - * $Horde: framework/iCalendar/iCalendar/vtimezone.php,v 1.8.10.10 2009-01-06 15:23:53 jan Exp $ - * - * Copyright 2003-2009 The Horde Project (http://www.horde.org/) - * - * See the enclosed file COPYING for license information (LGPL). If you - * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html. - * - * @author Mike Cochrane - * @since Horde 3.0 - * @package Horde_iCalendar - */ -class Horde_iCalendar_vtimezone extends Horde_iCalendar { - - function getType() - { - return 'vTimeZone'; - } - - function exportvCalendar() - { - return parent::_exportvData('VTIMEZONE'); - } - - /** - * Parse child components of the vTimezone component. Returns an - * array with the exact time of the time change as well as the - * 'from' and 'to' offsets around the change. Time is arbitrarily - * based on UTC for comparison. - */ - function parseChild(&$child, $year) - { - // Make sure 'time' key is first for sort(). - $result['time'] = 0; - - $t = $child->getAttribute('TZOFFSETFROM'); - if (is_a($t, 'PEAR_Error')) { - return false; - } - $result['from'] = ($t['hour'] * 60 * 60 + $t['minute'] * 60) * ($t['ahead'] ? 1 : -1); - - $t = $child->getAttribute('TZOFFSETTO'); - if (is_a($t, 'PEAR_Error')) { - return false; - } - $result['to'] = ($t['hour'] * 60 * 60 + $t['minute'] * 60) * ($t['ahead'] ? 1 : -1); - - $switch_time = $child->getAttribute('DTSTART'); - if (is_a($switch_time, 'PEAR_Error')) { - return false; - } - - $rrules = $child->getAttribute('RRULE'); - if (is_a($rrules, 'PEAR_Error')) { - if (!is_int($switch_time)) { - return false; - } - // Convert this timestamp from local time to UTC for - // comparison (All dates are compared as if they are UTC). - $t = getdate($switch_time); - $result['time'] = @gmmktime($t['hours'], $t['minutes'], $t['seconds'], - $t['mon'], $t['mday'], $t['year']); - return $result; - } - - $rrules = explode(';', $rrules); - foreach ($rrules as $rrule) { - $t = explode('=', $rrule); - switch ($t[0]) { - case 'FREQ': - if ($t[1] != 'YEARLY') { - return false; - } - break; - - case 'INTERVAL': - if ($t[1] != '1') { - return false; - } - break; - - case 'BYMONTH': - $month = intval($t[1]); - break; - - case 'BYDAY': - $len = strspn($t[1], '1234567890-+'); - if ($len == 0) { - return false; - } - $weekday = substr($t[1], $len); - $weekdays = array( - 'SU' => 0, - 'MO' => 1, - 'TU' => 2, - 'WE' => 3, - 'TH' => 4, - 'FR' => 5, - 'SA' => 6 - ); - $weekday = $weekdays[$weekday]; - $which = intval(substr($t[1], 0, $len)); - break; - - case 'UNTIL': - if (intval($year) > intval(substr($t[1], 0, 4))) { - return false; - } - break; - } - } - - if (empty($month) || !isset($weekday)) { - return false; - } - - if (is_int($switch_time)) { - // Was stored as localtime. - $switch_time = strftime('%H:%M:%S', $switch_time); - $switch_time = explode(':', $switch_time); - } else { - $switch_time = explode('T', $switch_time); - if (count($switch_time) != 2) { - return false; - } - $switch_time[0] = substr($switch_time[1], 0, 2); - $switch_time[2] = substr($switch_time[1], 4, 2); - $switch_time[1] = substr($switch_time[1], 2, 2); - } - - // Get the timestamp for the first day of $month. - $when = gmmktime($switch_time[0], $switch_time[1], $switch_time[2], - $month, 1, $year); - // Get the day of the week for the first day of $month. - $first_of_month_weekday = intval(gmstrftime('%w', $when)); - - // Go to the first $weekday before first day of $month. - if ($weekday >= $first_of_month_weekday) { - $weekday -= 7; - } - $when -= ($first_of_month_weekday - $weekday) * 60 * 60 * 24; - - // If going backwards go to the first $weekday after last day - // of $month. - if ($which < 0) { - do { - $when += 60*60*24*7; - } while (intval(gmstrftime('%m', $when)) == $month); - } - - // Calculate $weekday number $which. - $when += $which * 60 * 60 * 24 * 7; - - $result['time'] = $when; - - return $result; - } - -} - -/** - * @package Horde_iCalendar - */ -class Horde_iCalendar_standard extends Horde_iCalendar { - - function getType() - { - return 'standard'; - } - - function parsevCalendar($data) - { - parent::parsevCalendar($data, 'STANDARD'); - } - - function exportvCalendar() - { - return parent::_exportvData('STANDARD'); - } - -} - -/** - * @package Horde_iCalendar - */ -class Horde_iCalendar_daylight extends Horde_iCalendar { - - function getType() - { - return 'daylight'; - } - - function parsevCalendar($data) - { - parent::parsevCalendar($data, 'DAYLIGHT'); - } - - function exportvCalendar() - { - return parent::_exportvData('DAYLIGHT'); - } - -} - -/** - * Class representing vTodos. - * - * $Horde: framework/iCalendar/iCalendar/vtodo.php,v 1.13.10.9 2009-01-06 15:23:53 jan Exp $ - * - * Copyright 2003-2009 The Horde Project (http://www.horde.org/) - * - * See the enclosed file COPYING for license information (LGPL). If you - * did not receive this file, see http://www.fsf.org/copyleft/lgpl.html. - * - * @author Mike Cochrane - * @since Horde 3.0 - * @package Horde_iCalendar - */ -class Horde_iCalendar_vtodo extends Horde_iCalendar { - - function getType() - { - return 'vTodo'; - } - - function exportvCalendar() - { - return parent::_exportvData('VTODO'); - } - - /** - * Convert this todo to an array of attributes. - * - * @return array Array containing the details of the todo in a hash - * as used by Horde applications. - */ - function toArray() - { - $todo = array(); - - $name = $this->getAttribute('SUMMARY'); - if (!is_array($name) && !is_a($name, 'PEAR_Error')) { - $todo['name'] = $name; - } - $desc = $this->getAttribute('DESCRIPTION'); - if (!is_array($desc) && !is_a($desc, 'PEAR_Error')) { - $todo['desc'] = $desc; - } - - $priority = $this->getAttribute('PRIORITY'); - if (!is_array($priority) && !is_a($priority, 'PEAR_Error')) { - $todo['priority'] = $priority; - } - - $due = $this->getAttribute('DTSTAMP'); - if (!is_array($due) && !is_a($due, 'PEAR_Error')) { - $todo['due'] = $due; - } - - return $todo; - } - - /** - * Set the attributes for this todo item from an array. - * - * @param array $todo Array containing the details of the todo in - * the same format that toArray() exports. - */ - function fromArray($todo) - { - if (isset($todo['name'])) { - $this->setAttribute('SUMMARY', $todo['name']); - } - if (isset($todo['desc'])) { - $this->setAttribute('DESCRIPTION', $todo['desc']); - } - - if (isset($todo['priority'])) { - $this->setAttribute('PRIORITY', $todo['priority']); - } - - if (isset($todo['due'])) { - $this->setAttribute('DTSTAMP', $todo['due']); - } - } - -} diff --git a/plugins/libcalendaring/lib/Horde_iCalendar_timezone.diff b/plugins/libcalendaring/lib/Horde_iCalendar_timezone.diff deleted file mode 100644 index edd55876..00000000 --- a/plugins/libcalendaring/lib/Horde_iCalendar_timezone.diff +++ /dev/null @@ -1,23 +0,0 @@ -diff --git a/plugins/calendar/lib/Horde_iCalendar.php b/plugins/calendar/lib/Horde_iCalendar.php -index a3ff79d..6d75d27 100644 ---- a/lib/Horde_iCalendar.php -+++ b/lib/Horde_iCalendar.php -@@ -1874,7 +1874,17 @@ class Horde_iCalendar { - { - $vtimezone = $this->_container->findComponentByAttribute('vtimezone', 'TZID', $tzid); - if (!$vtimezone) { -- return false; -+ // use PHP's standard timezone db to determine tzoffset -+ try { -+ $tz = new DateTimeZone($tzid); -+ $dt = new DateTime('now', $tz); -+ $dt->setDate($date['year'], $date['month'], $date['mday']); -+ $dt->setTime($time['hour'], $time['minute'], $date['recond']); -+ return $tz->getOffset($dt); -+ } -+ catch (Exception $e) { -+ return false; -+ } - } - - $change_times = array(); diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Component.php b/plugins/libcalendaring/lib/Sabre/VObject/Component.php new file mode 100644 index 00000000..1c1d9244 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Component.php @@ -0,0 +1,405 @@ + 'Sabre\\VObject\\Component\\VAlarm', + 'VCALENDAR' => 'Sabre\\VObject\\Component\\VCalendar', + 'VCARD' => 'Sabre\\VObject\\Component\\VCard', + 'VEVENT' => 'Sabre\\VObject\\Component\\VEvent', + 'VJOURNAL' => 'Sabre\\VObject\\Component\\VJournal', + 'VTODO' => 'Sabre\\VObject\\Component\\VTodo', + 'VFREEBUSY' => 'Sabre\\VObject\\Component\\VFreeBusy', + ); + + /** + * Creates the new component by name, but in addition will also see if + * there's a class mapped to the property name. + * + * @param string $name + * @param string $value + * @return Component + */ + static public function create($name, $value = null) { + + $name = strtoupper($name); + + if (isset(self::$classMap[$name])) { + return new self::$classMap[$name]($name, $value); + } else { + return new self($name, $value); + } + + } + + /** + * Creates a new component. + * + * By default this object will iterate over its own children, but this can + * be overridden with the iterator argument + * + * @param string $name + * @param ElementList $iterator + */ + public function __construct($name, ElementList $iterator = null) { + + $this->name = strtoupper($name); + if (!is_null($iterator)) $this->iterator = $iterator; + + } + + /** + * Turns the object back into a serialized blob. + * + * @return string + */ + public function serialize() { + + $str = "BEGIN:" . $this->name . "\r\n"; + + /** + * Gives a component a 'score' for sorting purposes. + * + * This is solely used by the childrenSort method. + * + * A higher score means the item will be lower in the list. + * To avoid score collisions, each "score category" has a reasonable + * space to accomodate elements. The $key is added to the $score to + * preserve the original relative order of elements. + * + * @param int $key + * @param array $array + * @return int + */ + $sortScore = function($key, $array) { + + if ($array[$key] instanceof Component) { + + // We want to encode VTIMEZONE first, this is a personal + // preference. + if ($array[$key]->name === 'VTIMEZONE') { + $score=300000000; + return $score+$key; + } else { + $score=400000000; + return $score+$key; + } + } else { + // Properties get encoded first + // VCARD version 4.0 wants the VERSION property to appear first + if ($array[$key] instanceof Property) { + if ($array[$key]->name === 'VERSION') { + $score=100000000; + return $score+$key; + } else { + // All other properties + $score=200000000; + return $score+$key; + } + } + } + + }; + + $tmp = $this->children; + uksort($this->children, function($a, $b) use ($sortScore, $tmp) { + + $sA = $sortScore($a, $tmp); + $sB = $sortScore($b, $tmp); + + if ($sA === $sB) return 0; + + return ($sA < $sB) ? -1 : 1; + + }); + + foreach($this->children as $child) $str.=$child->serialize(); + $str.= "END:" . $this->name . "\r\n"; + + return $str; + + } + + /** + * Adds a new component or element + * + * You can call this method with the following syntaxes: + * + * add(Node $node) + * add(string $name, $value, array $parameters = array()) + * + * The first version adds an Element + * The second adds a property as a string. + * + * @param mixed $item + * @param mixed $itemValue + * @return void + */ + public function add($item, $itemValue = null, array $parameters = array()) { + + if ($item instanceof Node) { + if (!is_null($itemValue)) { + throw new \InvalidArgumentException('The second argument must not be specified, when passing a VObject Node'); + } + $item->parent = $this; + $this->children[] = $item; + } elseif(is_string($item)) { + + $item = Property::create($item,$itemValue, $parameters); + $item->parent = $this; + $this->children[] = $item; + + } else { + + throw new \InvalidArgumentException('The first argument must either be a \\Sabre\\VObject\\Node or a string'); + + } + + } + + /** + * Returns an iterable list of children + * + * @return ElementList + */ + public function children() { + + return new ElementList($this->children); + + } + + /** + * Returns an array with elements that match the specified name. + * + * This function is also aware of MIME-Directory groups (as they appear in + * vcards). This means that if a property is grouped as "HOME.EMAIL", it + * will also be returned when searching for just "EMAIL". If you want to + * search for a property in a specific group, you can select on the entire + * string ("HOME.EMAIL"). If you want to search on a specific property that + * has not been assigned a group, specify ".EMAIL". + * + * Keys are retained from the 'children' array, which may be confusing in + * certain cases. + * + * @param string $name + * @return array + */ + public function select($name) { + + $group = null; + $name = strtoupper($name); + if (strpos($name,'.')!==false) { + list($group,$name) = explode('.', $name, 2); + } + + $result = array(); + foreach($this->children as $key=>$child) { + + if ( + strtoupper($child->name) === $name && + (is_null($group) || ( $child instanceof Property && strtoupper($child->group) === $group)) + ) { + + $result[$key] = $child; + + } + } + + reset($result); + return $result; + + } + + /** + * This method only returns a list of sub-components. Properties are + * ignored. + * + * @return array + */ + public function getComponents() { + + $result = array(); + foreach($this->children as $child) { + if ($child instanceof Component) { + $result[] = $child; + } + } + + return $result; + + } + + /** + * Validates the node for correctness. + * + * The following options are supported: + * - Node::REPAIR - If something is broken, and automatic repair may + * be attempted. + * + * An array is returned with warnings. + * + * Every item in the array has the following properties: + * * level - (number between 1 and 3 with severity information) + * * message - (human readable message) + * * node - (reference to the offending node) + * + * @param int $options + * @return array + */ + public function validate($options = 0) { + + $result = array(); + foreach($this->children as $child) { + $result = array_merge($result, $child->validate($options)); + } + return $result; + + } + + /* Magic property accessors {{{ */ + + /** + * Using 'get' you will either get a property or component, + * + * If there were no child-elements found with the specified name, + * null is returned. + * + * @param string $name + * @return Property + */ + public function __get($name) { + + $matches = $this->select($name); + if (count($matches)===0) { + return null; + } else { + $firstMatch = current($matches); + /** @var $firstMatch Property */ + $firstMatch->setIterator(new ElementList(array_values($matches))); + return $firstMatch; + } + + } + + /** + * This method checks if a sub-element with the specified name exists. + * + * @param string $name + * @return bool + */ + public function __isset($name) { + + $matches = $this->select($name); + return count($matches)>0; + + } + + /** + * Using the setter method you can add properties or subcomponents + * + * You can either pass a Component, Property + * object, or a string to automatically create a Property. + * + * If the item already exists, it will be removed. If you want to add + * a new item with the same name, always use the add() method. + * + * @param string $name + * @param mixed $value + * @return void + */ + public function __set($name, $value) { + + $matches = $this->select($name); + $overWrite = count($matches)?key($matches):null; + + if ($value instanceof Component || $value instanceof Property) { + $value->parent = $this; + if (!is_null($overWrite)) { + $this->children[$overWrite] = $value; + } else { + $this->children[] = $value; + } + } elseif (is_scalar($value)) { + $property = Property::create($name,$value); + $property->parent = $this; + if (!is_null($overWrite)) { + $this->children[$overWrite] = $property; + } else { + $this->children[] = $property; + } + } else { + throw new \InvalidArgumentException('You must pass a \\Sabre\\VObject\\Component, \\Sabre\\VObject\\Property or scalar type'); + } + + } + + /** + * Removes all properties and components within this component. + * + * @param string $name + * @return void + */ + public function __unset($name) { + + $matches = $this->select($name); + foreach($matches as $k=>$child) { + + unset($this->children[$k]); + $child->parent = null; + + } + + } + + /* }}} */ + + /** + * This method is automatically called when the object is cloned. + * Specifically, this will ensure all child elements are also cloned. + * + * @return void + */ + public function __clone() { + + foreach($this->children as $key=>$child) { + $this->children[$key] = clone $child; + $this->children[$key]->parent = $this; + } + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Component/VAlarm.php b/plugins/libcalendaring/lib/Sabre/VObject/Component/VAlarm.php new file mode 100644 index 00000000..2f86c44f --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Component/VAlarm.php @@ -0,0 +1,108 @@ +TRIGGER; + if(!isset($trigger['VALUE']) || strtoupper($trigger['VALUE']) === 'DURATION') { + $triggerDuration = VObject\DateTimeParser::parseDuration($this->TRIGGER); + $related = (isset($trigger['RELATED']) && strtoupper($trigger['RELATED']) == 'END') ? 'END' : 'START'; + + $parentComponent = $this->parent; + if ($related === 'START') { + + if ($parentComponent->name === 'VTODO') { + $propName = 'DUE'; + } else { + $propName = 'DTSTART'; + } + + $effectiveTrigger = clone $parentComponent->$propName->getDateTime(); + $effectiveTrigger->add($triggerDuration); + } else { + if ($parentComponent->name === 'VTODO') { + $endProp = 'DUE'; + } elseif ($parentComponent->name === 'VEVENT') { + $endProp = 'DTEND'; + } else { + throw new \LogicException('time-range filters on VALARM components are only supported when they are a child of VTODO or VEVENT'); + } + + if (isset($parentComponent->$endProp)) { + $effectiveTrigger = clone $parentComponent->$endProp->getDateTime(); + $effectiveTrigger->add($triggerDuration); + } elseif (isset($parentComponent->DURATION)) { + $effectiveTrigger = clone $parentComponent->DTSTART->getDateTime(); + $duration = VObject\DateTimeParser::parseDuration($parentComponent->DURATION); + $effectiveTrigger->add($duration); + $effectiveTrigger->add($triggerDuration); + } else { + $effectiveTrigger = clone $parentComponent->DTSTART->getDateTime(); + $effectiveTrigger->add($triggerDuration); + } + } + } else { + $effectiveTrigger = $trigger->getDateTime(); + } + return $effectiveTrigger; + + } + + /** + * Returns true or false depending on if the event falls in the specified + * time-range. This is used for filtering purposes. + * + * The rules used to determine if an event falls within the specified + * time-range is based on the CalDAV specification. + * + * @param \DateTime $start + * @param \DateTime $end + * @return bool + */ + public function isInTimeRange(\DateTime $start, \DateTime $end) { + + $effectiveTrigger = $this->getEffectiveTriggerTime(); + + if (isset($this->DURATION)) { + $duration = VObject\DateTimeParser::parseDuration($this->DURATION); + $repeat = (string)$this->repeat; + if (!$repeat) { + $repeat = 1; + } + + $period = new \DatePeriod($effectiveTrigger, $duration, (int)$repeat); + + foreach($period as $occurrence) { + + if ($start <= $occurrence && $end > $occurrence) { + return true; + } + } + return false; + } else { + return ($start <= $effectiveTrigger && $end > $effectiveTrigger); + } + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Component/VCalendar.php b/plugins/libcalendaring/lib/Sabre/VObject/Component/VCalendar.php new file mode 100644 index 00000000..9de67982 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Component/VCalendar.php @@ -0,0 +1,244 @@ +children as $component) { + + if (!$component instanceof VObject\Component) + continue; + + if (isset($component->{'RECURRENCE-ID'})) + continue; + + if ($componentName && $component->name !== strtoupper($componentName)) + continue; + + if ($component->name === 'VTIMEZONE') + continue; + + $components[] = $component; + + } + + return $components; + + } + + /** + * If this calendar object, has events with recurrence rules, this method + * can be used to expand the event into multiple sub-events. + * + * Each event will be stripped from it's recurrence information, and only + * the instances of the event in the specified timerange will be left + * alone. + * + * In addition, this method will cause timezone information to be stripped, + * and normalized to UTC. + * + * This method will alter the VCalendar. This cannot be reversed. + * + * This functionality is specifically used by the CalDAV standard. It is + * possible for clients to request expand events, if they are rather simple + * clients and do not have the possibility to calculate recurrences. + * + * @param DateTime $start + * @param DateTime $end + * @return void + */ + public function expand(\DateTime $start, \DateTime $end) { + + $newEvents = array(); + + foreach($this->select('VEVENT') as $key=>$vevent) { + + if (isset($vevent->{'RECURRENCE-ID'})) { + unset($this->children[$key]); + continue; + } + + + if (!$vevent->rrule) { + unset($this->children[$key]); + if ($vevent->isInTimeRange($start, $end)) { + $newEvents[] = $vevent; + } + continue; + } + + $uid = (string)$vevent->uid; + if (!$uid) { + throw new \LogicException('Event did not have a UID!'); + } + + $it = new VObject\RecurrenceIterator($this, $vevent->uid); + $it->fastForward($start); + + while($it->valid() && $it->getDTStart() < $end) { + + if ($it->getDTEnd() > $start) { + + $newEvents[] = $it->getEventObject(); + + } + $it->next(); + + } + unset($this->children[$key]); + + } + + foreach($newEvents as $newEvent) { + + foreach($newEvent->children as $child) { + if ($child instanceof VObject\Property\DateTime && + $child->getDateType() == VObject\Property\DateTime::LOCALTZ) { + $child->setDateTime($child->getDateTime(),VObject\Property\DateTime::UTC); + } + } + + $this->add($newEvent); + + } + + // Removing all VTIMEZONE components + unset($this->VTIMEZONE); + + } + + /** + * Validates the node for correctness. + * An array is returned with warnings. + * + * Every item in the array has the following properties: + * * level - (number between 1 and 3 with severity information) + * * message - (human readable message) + * * node - (reference to the offending node) + * + * @return array + */ + /* + public function validate() { + + $warnings = array(); + + $version = $this->select('VERSION'); + if (count($version)!==1) { + $warnings[] = array( + 'level' => 1, + 'message' => 'The VERSION property must appear in the VCALENDAR component exactly 1 time', + 'node' => $this, + ); + } else { + if ((string)$this->VERSION !== '2.0') { + $warnings[] = array( + 'level' => 1, + 'message' => 'Only iCalendar version 2.0 as defined in rfc5545 is supported.', + 'node' => $this, + ); + } + } + $version = $this->select('PRODID'); + if (count($version)!==1) { + $warnings[] = array( + 'level' => 2, + 'message' => 'The PRODID property must appear in the VCALENDAR component exactly 1 time', + 'node' => $this, + ); + } + if (count($this->CALSCALE) > 1) { + $warnings[] = array( + 'level' => 2, + 'message' => 'The CALSCALE property must not be specified more than once.', + 'node' => $this, + ); + } + if (count($this->METHOD) > 1) { + $warnings[] = array( + 'level' => 2, + 'message' => 'The METHOD property must not be specified more than once.', + 'node' => $this, + ); + } + + $allowedComponents = array( + 'VEVENT', + 'VTODO', + 'VJOURNAL', + 'VFREEBUSY', + 'VTIMEZONE', + ); + $allowedProperties = array( + 'PRODID', + 'VERSION', + 'CALSCALE', + 'METHOD', + ); + $componentsFound = 0; + foreach($this->children as $child) { + if($child instanceof Component) { + $componentsFound++; + if (!in_array($child->name, $allowedComponents)) { + $warnings[] = array( + 'level' => 1, + 'message' => 'The ' . $child->name . " component is not allowed in the VCALENDAR component", + 'node' => $this, + ); + } + } + if ($child instanceof Property) { + if (!in_array($child->name, $allowedProperties)) { + $warnings[] = array( + 'level' => 2, + 'message' => 'The ' . $child->name . " property is not allowed in the VCALENDAR component", + 'node' => $this, + ); + } + } + } + + if ($componentsFound===0) { + $warnings[] = array( + 'level' => 1, + 'message' => 'An iCalendar object must have at least 1 component.', + 'node' => $this, + ); + } + + return array_merge( + $warnings, + parent::validate() + ); + + } + */ + +} + diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Component/VCard.php b/plugins/libcalendaring/lib/Sabre/VObject/Component/VCard.php new file mode 100644 index 00000000..0fc8b702 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Component/VCard.php @@ -0,0 +1,107 @@ +select('VERSION'); + if (count($version)!==1) { + $warnings[] = array( + 'level' => 1, + 'message' => 'The VERSION property must appear in the VCARD component exactly 1 time', + 'node' => $this, + ); + if ($options & self::REPAIR) { + $this->VERSION = self::DEFAULT_VERSION; + } + } else { + $version = (string)$this->VERSION; + if ($version!=='2.1' && $version!=='3.0' && $version!=='4.0') { + $warnings[] = array( + 'level' => 1, + 'message' => 'Only vcard version 4.0 (RFC6350), version 3.0 (RFC2426) or version 2.1 (icm-vcard-2.1) are supported.', + 'node' => $this, + ); + if ($options & self::REPAIR) { + $this->VERSION = '4.0'; + } + } + + } + $fn = $this->select('FN'); + if (count($fn)!==1) { + $warnings[] = array( + 'level' => 1, + 'message' => 'The FN property must appear in the VCARD component exactly 1 time', + 'node' => $this, + ); + if (($options & self::REPAIR) && count($fn) === 0) { + // We're going to try to see if we can use the contents of the + // N property. + if (isset($this->N)) { + $value = explode(';', (string)$this->N); + if (isset($value[1]) && $value[1]) { + $this->FN = $value[1] . ' ' . $value[0]; + } else { + $this->FN = $value[0]; + } + + // Otherwise, the ORG property may work + } elseif (isset($this->ORG)) { + $this->FN = (string)$this->ORG; + } + + } + } + + return array_merge( + parent::validate($options), + $warnings + ); + + } + +} + diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Component/VEvent.php b/plugins/libcalendaring/lib/Sabre/VObject/Component/VEvent.php new file mode 100644 index 00000000..2375c531 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Component/VEvent.php @@ -0,0 +1,70 @@ +RRULE) { + $it = new VObject\RecurrenceIterator($this); + $it->fastForward($start); + + // We fast-forwarded to a spot where the end-time of the + // recurrence instance exceeded the start of the requested + // time-range. + // + // If the starttime of the recurrence did not exceed the + // end of the time range as well, we have a match. + return ($it->getDTStart() < $end && $it->getDTEnd() > $start); + + } + + $effectiveStart = $this->DTSTART->getDateTime(); + if (isset($this->DTEND)) { + + // The DTEND property is considered non inclusive. So for a 3 day + // event in july, dtstart and dtend would have to be July 1st and + // July 4th respectively. + // + // See: + // http://tools.ietf.org/html/rfc5545#page-54 + $effectiveEnd = $this->DTEND->getDateTime(); + + } elseif (isset($this->DURATION)) { + $effectiveEnd = clone $effectiveStart; + $effectiveEnd->add( VObject\DateTimeParser::parseDuration($this->DURATION) ); + } elseif ($this->DTSTART->getDateType() == VObject\Property\DateTime::DATE) { + $effectiveEnd = clone $effectiveStart; + $effectiveEnd->modify('+1 day'); + } else { + $effectiveEnd = clone $effectiveStart; + } + return ( + ($start <= $effectiveEnd) && ($end > $effectiveStart) + ); + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Component/VFreeBusy.php b/plugins/libcalendaring/lib/Sabre/VObject/Component/VFreeBusy.php new file mode 100644 index 00000000..7afe9fdb --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Component/VFreeBusy.php @@ -0,0 +1,68 @@ +select('FREEBUSY') as $freebusy) { + + // We are only interested in FBTYPE=BUSY (the default), + // FBTYPE=BUSY-TENTATIVE or FBTYPE=BUSY-UNAVAILABLE. + if (isset($freebusy['FBTYPE']) && strtoupper(substr((string)$freebusy['FBTYPE'],0,4))!=='BUSY') { + continue; + } + + // The freebusy component can hold more than 1 value, separated by + // commas. + $periods = explode(',', (string)$freebusy); + + foreach($periods as $period) { + // Every period is formatted as [start]/[end]. The start is an + // absolute UTC time, the end may be an absolute UTC time, or + // duration (relative) value. + list($busyStart, $busyEnd) = explode('/', $period); + + $busyStart = VObject\DateTimeParser::parse($busyStart); + $busyEnd = VObject\DateTimeParser::parse($busyEnd); + if ($busyEnd instanceof \DateInterval) { + $tmp = clone $busyStart; + $tmp->add($busyEnd); + $busyEnd = $tmp; + } + + if($start < $busyEnd && $end > $busyStart) { + return false; + } + + } + + } + + return true; + + } + +} + diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Component/VJournal.php b/plugins/libcalendaring/lib/Sabre/VObject/Component/VJournal.php new file mode 100644 index 00000000..23288787 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Component/VJournal.php @@ -0,0 +1,46 @@ +DTSTART)?$this->DTSTART->getDateTime():null; + if ($dtstart) { + $effectiveEnd = clone $dtstart; + if ($this->DTSTART->getDateType() == VObject\Property\DateTime::DATE) { + $effectiveEnd->modify('+1 day'); + } + + return ($start <= $effectiveEnd && $end > $dtstart); + + } + return false; + + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Component/VTodo.php b/plugins/libcalendaring/lib/Sabre/VObject/Component/VTodo.php new file mode 100644 index 00000000..b1579cf7 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Component/VTodo.php @@ -0,0 +1,68 @@ +DTSTART)?$this->DTSTART->getDateTime():null; + $duration = isset($this->DURATION)?VObject\DateTimeParser::parseDuration($this->DURATION):null; + $due = isset($this->DUE)?$this->DUE->getDateTime():null; + $completed = isset($this->COMPLETED)?$this->COMPLETED->getDateTime():null; + $created = isset($this->CREATED)?$this->CREATED->getDateTime():null; + + if ($dtstart) { + if ($duration) { + $effectiveEnd = clone $dtstart; + $effectiveEnd->add($duration); + return $start <= $effectiveEnd && $end > $dtstart; + } elseif ($due) { + return + ($start < $due || $start <= $dtstart) && + ($end > $dtstart || $end >= $due); + } else { + return $start <= $dtstart && $end > $dtstart; + } + } + if ($due) { + return ($start < $due && $end >= $due); + } + if ($completed && $created) { + return + ($start <= $created || $start <= $completed) && + ($end >= $created || $end >= $completed); + } + if ($completed) { + return ($start <= $completed && $end >= $completed); + } + if ($created) { + return ($end > $created); + } + return true; + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/DateTimeParser.php b/plugins/libcalendaring/lib/Sabre/VObject/DateTimeParser.php new file mode 100644 index 00000000..03600506 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/DateTimeParser.php @@ -0,0 +1,181 @@ +setTimeZone(new \DateTimeZone('UTC')); + return $date; + + } + + /** + * Parses an iCalendar (rfc5545) formatted date and returns a DateTime object + * + * @param string $date + * @return DateTime + */ + static public function parseDate($date) { + + // Format is YYYYMMDD + $result = preg_match('/^([1-4][0-9]{3})([0-1][0-9])([0-3][0-9])$/',$date,$matches); + + if (!$result) { + throw new \LogicException('The supplied iCalendar date value is incorrect: ' . $date); + } + + $date = new \DateTime($matches[1] . '-' . $matches[2] . '-' . $matches[3], new \DateTimeZone('UTC')); + return $date; + + } + + /** + * Parses an iCalendar (RFC5545) formatted duration value. + * + * This method will either return a DateTimeInterval object, or a string + * suitable for strtotime or DateTime::modify. + * + * @param string $duration + * @param bool $asString + * @return DateInterval|string + */ + static public function parseDuration($duration, $asString = false) { + + $result = preg_match('/^(?P\+|-)?P((?P\d+)W)?((?P\d+)D)?(T((?P\d+)H)?((?P\d+)M)?((?P\d+)S)?)?$/', $duration, $matches); + if (!$result) { + throw new \LogicException('The supplied iCalendar duration value is incorrect: ' . $duration); + } + + if (!$asString) { + $invert = false; + if ($matches['plusminus']==='-') { + $invert = true; + } + + + $parts = array( + 'week', + 'day', + 'hour', + 'minute', + 'second', + ); + foreach($parts as $part) { + $matches[$part] = isset($matches[$part])&&$matches[$part]?(int)$matches[$part]:0; + } + + + // We need to re-construct the $duration string, because weeks and + // days are not supported by DateInterval in the same string. + $duration = 'P'; + $days = $matches['day']; + if ($matches['week']) { + $days+=$matches['week']*7; + } + if ($days) + $duration.=$days . 'D'; + + if ($matches['minute'] || $matches['second'] || $matches['hour']) { + $duration.='T'; + + if ($matches['hour']) + $duration.=$matches['hour'].'H'; + + if ($matches['minute']) + $duration.=$matches['minute'].'M'; + + if ($matches['second']) + $duration.=$matches['second'].'S'; + + } + + if ($duration==='P') { + $duration = 'PT0S'; + } + $iv = new \DateInterval($duration); + if ($invert) $iv->invert = true; + + return $iv; + + } + + + + $parts = array( + 'week', + 'day', + 'hour', + 'minute', + 'second', + ); + + $newDur = ''; + foreach($parts as $part) { + if (isset($matches[$part]) && $matches[$part]) { + $newDur.=' '.$matches[$part] . ' ' . $part . 's'; + } + } + + $newDur = ($matches['plusminus']==='-'?'-':'+') . trim($newDur); + if ($newDur === '+') { $newDur = '+0 seconds'; }; + return $newDur; + + } + + /** + * Parses either a Date or DateTime, or Duration value. + * + * @param string $date + * @param DateTimeZone|string $referenceTZ + * @return DateTime|DateInterval + */ + static public function parse($date, $referenceTZ = null) { + + if ($date[0]==='P' || ($date[0]==='-' && $date[1]==='P')) { + return self::parseDuration($date); + } elseif (strlen($date)===8) { + return self::parseDate($date); + } else { + return self::parseDateTime($date, $referenceTZ); + } + + } + + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Document.php b/plugins/libcalendaring/lib/Sabre/VObject/Document.php new file mode 100644 index 00000000..50a662ee --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Document.php @@ -0,0 +1,109 @@ +value syntax, in which case + * properties will automatically be created, or you can just pass a list of + * Component and Property object. + * + * @param string $name + * @param array $children + * @return Component + */ + public function createComponent($name, array $children = array()) { + + $component = Component::create($name); + foreach($children as $k=>$v) { + + if ($v instanceof Node) { + $component->add($v); + } else { + $component->add($k, $v); + } + + } + return $component; + + } + + /** + * Factory method for creating new properties + * + * This method automatically searches for the correct property class, based + * on its name. + * + * You can specify the parameters either in key=>value syntax, in which case + * parameters will automatically be created, or you can just pass a list of + * Parameter objects. + * + * @param string $name + * @param mixed $value + * @param array $parameters + * @return Property + */ + public function createProperty($name, $value = null, array $parameters = array()) { + + return Property::create($name, $value, $parameters); + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/ElementList.php b/plugins/libcalendaring/lib/Sabre/VObject/ElementList.php new file mode 100644 index 00000000..1c203708 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/ElementList.php @@ -0,0 +1,172 @@ +vevent where there's multiple VEVENT objects. + * + * @copyright Copyright (C) 2007-2013 fruux GmbH (https://fruux.com/). + * @author Evert Pot (http://evertpot.com/) + * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License + */ +class ElementList implements \Iterator, \Countable, \ArrayAccess { + + /** + * Inner elements + * + * @var array + */ + protected $elements = array(); + + /** + * Creates the element list. + * + * @param array $elements + */ + public function __construct(array $elements) { + + $this->elements = $elements; + + } + + /* {{{ Iterator interface */ + + /** + * Current position + * + * @var int + */ + private $key = 0; + + /** + * Returns current item in iteration + * + * @return Element + */ + public function current() { + + return $this->elements[$this->key]; + + } + + /** + * To the next item in the iterator + * + * @return void + */ + public function next() { + + $this->key++; + + } + + /** + * Returns the current iterator key + * + * @return int + */ + public function key() { + + return $this->key; + + } + + /** + * Returns true if the current position in the iterator is a valid one + * + * @return bool + */ + public function valid() { + + return isset($this->elements[$this->key]); + + } + + /** + * Rewinds the iterator + * + * @return void + */ + public function rewind() { + + $this->key = 0; + + } + + /* }}} */ + + /* {{{ Countable interface */ + + /** + * Returns the number of elements + * + * @return int + */ + public function count() { + + return count($this->elements); + + } + + /* }}} */ + + /* {{{ ArrayAccess Interface */ + + + /** + * Checks if an item exists through ArrayAccess. + * + * @param int $offset + * @return bool + */ + public function offsetExists($offset) { + + return isset($this->elements[$offset]); + + } + + /** + * Gets an item through ArrayAccess. + * + * @param int $offset + * @return mixed + */ + public function offsetGet($offset) { + + return $this->elements[$offset]; + + } + + /** + * Sets an item through ArrayAccess. + * + * @param int $offset + * @param mixed $value + * @return void + */ + public function offsetSet($offset,$value) { + + throw new \LogicException('You can not add new objects to an ElementList'); + + } + + /** + * Sets an item through ArrayAccess. + * + * This method just forwards the request to the inner iterator + * + * @param int $offset + * @return void + */ + public function offsetUnset($offset) { + + throw new \LogicException('You can not remove objects from an ElementList'); + + } + + /* }}} */ + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/FreeBusyGenerator.php b/plugins/libcalendaring/lib/Sabre/VObject/FreeBusyGenerator.php new file mode 100644 index 00000000..96d0be5a --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/FreeBusyGenerator.php @@ -0,0 +1,322 @@ +setTimeRange($start, $end); + } + + if ($objects) { + $this->setObjects($objects); + } + + } + + /** + * Sets the VCALENDAR object. + * + * If this is set, it will not be generated for you. You are responsible + * for setting things like the METHOD, CALSCALE, VERSION, etc.. + * + * The VFREEBUSY object will be automatically added though. + * + * @param Component $vcalendar + * @return void + */ + public function setBaseObject(Component $vcalendar) { + + $this->baseObject = $vcalendar; + + } + + /** + * Sets the input objects + * + * You must either specify a valendar object as a strong, or as the parse + * Component. + * It's also possible to specify multiple objects as an array. + * + * @param mixed $objects + * @return void + */ + public function setObjects($objects) { + + if (!is_array($objects)) { + $objects = array($objects); + } + + $this->objects = array(); + foreach($objects as $object) { + + if (is_string($object)) { + $this->objects[] = Reader::read($object); + } elseif ($object instanceof Component) { + $this->objects[] = $object; + } else { + throw new \InvalidArgumentException('You can only pass strings or \\Sabre\\VObject\\Component arguments to setObjects'); + } + + } + + } + + /** + * Sets the time range + * + * Any freebusy object falling outside of this time range will be ignored. + * + * @param DateTime $start + * @param DateTime $end + * @return void + */ + public function setTimeRange(\DateTime $start = null, \DateTime $end = null) { + + $this->start = $start; + $this->end = $end; + + } + + /** + * Parses the input data and returns a correct VFREEBUSY object, wrapped in + * a VCALENDAR. + * + * @return Component + */ + public function getResult() { + + $busyTimes = array(); + + foreach($this->objects as $object) { + + foreach($object->getBaseComponents() as $component) { + + switch($component->name) { + + case 'VEVENT' : + + $FBTYPE = 'BUSY'; + if (isset($component->TRANSP) && (strtoupper($component->TRANSP) === 'TRANSPARENT')) { + break; + } + if (isset($component->STATUS)) { + $status = strtoupper($component->STATUS); + if ($status==='CANCELLED') { + break; + } + if ($status==='TENTATIVE') { + $FBTYPE = 'BUSY-TENTATIVE'; + } + } + + $times = array(); + + if ($component->RRULE) { + + $iterator = new RecurrenceIterator($object, (string)$component->uid); + if ($this->start) { + $iterator->fastForward($this->start); + } + + $maxRecurrences = 200; + + while($iterator->valid() && --$maxRecurrences) { + + $startTime = $iterator->getDTStart(); + if ($this->end && $startTime > $this->end) { + break; + } + $times[] = array( + $iterator->getDTStart(), + $iterator->getDTEnd(), + ); + + $iterator->next(); + + } + + } else { + + $startTime = $component->DTSTART->getDateTime(); + if ($this->end && $startTime > $this->end) { + break; + } + $endTime = null; + if (isset($component->DTEND)) { + $endTime = $component->DTEND->getDateTime(); + } elseif (isset($component->DURATION)) { + $duration = DateTimeParser::parseDuration((string)$component->DURATION); + $endTime = clone $startTime; + $endTime->add($duration); + } elseif ($component->DTSTART->getDateType() === Property\DateTime::DATE) { + $endTime = clone $startTime; + $endTime->modify('+1 day'); + } else { + // The event had no duration (0 seconds) + break; + } + + $times[] = array($startTime, $endTime); + + } + + foreach($times as $time) { + + if ($this->end && $time[0] > $this->end) break; + if ($this->start && $time[1] < $this->start) break; + + $busyTimes[] = array( + $time[0], + $time[1], + $FBTYPE, + ); + } + break; + + case 'VFREEBUSY' : + foreach($component->FREEBUSY as $freebusy) { + + $fbType = isset($freebusy['FBTYPE'])?strtoupper($freebusy['FBTYPE']):'BUSY'; + + // Skipping intervals marked as 'free' + if ($fbType==='FREE') + continue; + + $values = explode(',', $freebusy); + foreach($values as $value) { + list($startTime, $endTime) = explode('/', $value); + $startTime = DateTimeParser::parseDateTime($startTime); + + if (substr($endTime,0,1)==='P' || substr($endTime,0,2)==='-P') { + $duration = DateTimeParser::parseDuration($endTime); + $endTime = clone $startTime; + $endTime->add($duration); + } else { + $endTime = DateTimeParser::parseDateTime($endTime); + } + + if($this->start && $this->start > $endTime) continue; + if($this->end && $this->end < $startTime) continue; + $busyTimes[] = array( + $startTime, + $endTime, + $fbType + ); + + } + + + } + break; + + + + } + + + } + + } + + if ($this->baseObject) { + $calendar = $this->baseObject; + } else { + $calendar = Component::create('VCALENDAR'); + $calendar->version = '2.0'; + $calendar->prodid = '-//Sabre//Sabre VObject ' . Version::VERSION . '//EN'; + $calendar->calscale = 'GREGORIAN'; + } + + $vfreebusy = Component::create('VFREEBUSY'); + $calendar->add($vfreebusy); + + if ($this->start) { + $dtstart = Property::create('DTSTART'); + $dtstart->setDateTime($this->start,Property\DateTime::UTC); + $vfreebusy->add($dtstart); + } + if ($this->end) { + $dtend = Property::create('DTEND'); + $dtend->setDateTime($this->end,Property\DateTime::UTC); + $vfreebusy->add($dtend); + } + $dtstamp = Property::create('DTSTAMP'); + $dtstamp->setDateTime(new \DateTime('now'), Property\DateTime::UTC); + $vfreebusy->add($dtstamp); + + foreach($busyTimes as $busyTime) { + + $busyTime[0]->setTimeZone(new \DateTimeZone('UTC')); + $busyTime[1]->setTimeZone(new \DateTimeZone('UTC')); + + $prop = Property::create( + 'FREEBUSY', + $busyTime[0]->format('Ymd\\THis\\Z') . '/' . $busyTime[1]->format('Ymd\\THis\\Z') + ); + $prop['FBTYPE'] = $busyTime[2]; + $vfreebusy->add($prop); + + } + + return $calendar; + + } + +} + diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Node.php b/plugins/libcalendaring/lib/Sabre/VObject/Node.php new file mode 100644 index 00000000..bee68ec2 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Node.php @@ -0,0 +1,187 @@ +iterator)) + return $this->iterator; + + return new ElementList(array($this)); + + } + + /** + * Sets the overridden iterator + * + * Note that this is not actually part of the iterator interface + * + * @param ElementList $iterator + * @return void + */ + public function setIterator(ElementList $iterator) { + + $this->iterator = $iterator; + + } + + /* }}} */ + + /* {{{ Countable interface */ + + /** + * Returns the number of elements + * + * @return int + */ + public function count() { + + $it = $this->getIterator(); + return $it->count(); + + } + + /* }}} */ + + /* {{{ ArrayAccess Interface */ + + + /** + * Checks if an item exists through ArrayAccess. + * + * This method just forwards the request to the inner iterator + * + * @param int $offset + * @return bool + */ + public function offsetExists($offset) { + + $iterator = $this->getIterator(); + return $iterator->offsetExists($offset); + + } + + /** + * Gets an item through ArrayAccess. + * + * This method just forwards the request to the inner iterator + * + * @param int $offset + * @return mixed + */ + public function offsetGet($offset) { + + $iterator = $this->getIterator(); + return $iterator->offsetGet($offset); + + } + + /** + * Sets an item through ArrayAccess. + * + * This method just forwards the request to the inner iterator + * + * @param int $offset + * @param mixed $value + * @return void + */ + public function offsetSet($offset,$value) { + + $iterator = $this->getIterator(); + $iterator->offsetSet($offset,$value); + + // @codeCoverageIgnoreStart + // + // This method always throws an exception, so we ignore the closing + // brace + } + // @codeCoverageIgnoreEnd + + /** + * Sets an item through ArrayAccess. + * + * This method just forwards the request to the inner iterator + * + * @param int $offset + * @return void + */ + public function offsetUnset($offset) { + + $iterator = $this->getIterator(); + $iterator->offsetUnset($offset); + + // @codeCoverageIgnoreStart + // + // This method always throws an exception, so we ignore the closing + // brace + } + // @codeCoverageIgnoreEnd + + /* }}} */ + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Parameter.php b/plugins/libcalendaring/lib/Sabre/VObject/Parameter.php new file mode 100644 index 00000000..37c9fd01 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Parameter.php @@ -0,0 +1,100 @@ +name = strtoupper($name); + $this->value = $value; + + } + + /** + * Returns the parameter's internal value. + * + * @return string + */ + public function getValue() { + + return $this->value; + + } + + + /** + * Turns the object back into a serialized blob. + * + * @return string + */ + public function serialize() { + + if (is_null($this->value)) { + return $this->name; + } + $src = array( + '\\', + "\n", + ';', + ',', + ); + $out = array( + '\\\\', + '\n', + '\;', + '\,', + ); + + return $this->name . '=' . str_replace($src, $out, $this->value); + + } + + /** + * Called when this object is being cast to a string + * + * @return string + */ + public function __toString() { + + return $this->value; + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/ParseException.php b/plugins/libcalendaring/lib/Sabre/VObject/ParseException.php new file mode 100644 index 00000000..66b49c60 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/ParseException.php @@ -0,0 +1,12 @@ + 'Sabre\\VObject\\Property\\DateTime', + 'CREATED' => 'Sabre\\VObject\\Property\\DateTime', + 'DTEND' => 'Sabre\\VObject\\Property\\DateTime', + 'DTSTAMP' => 'Sabre\\VObject\\Property\\DateTime', + 'DTSTART' => 'Sabre\\VObject\\Property\\DateTime', + 'DUE' => 'Sabre\\VObject\\Property\\DateTime', + 'EXDATE' => 'Sabre\\VObject\\Property\\MultiDateTime', + 'LAST-MODIFIED' => 'Sabre\\VObject\\Property\\DateTime', + 'RECURRENCE-ID' => 'Sabre\\VObject\\Property\\DateTime', + 'TRIGGER' => 'Sabre\\VObject\\Property\\DateTime', + 'N' => 'Sabre\\VObject\\Property\\Compound', + 'ORG' => 'Sabre\\VObject\\Property\\Compound', + 'ADR' => 'Sabre\\VObject\\Property\\Compound', + 'CATEGORIES' => 'Sabre\\VObject\\Property\\Compound', + ); + + /** + * Creates the new property by name, but in addition will also see if + * there's a class mapped to the property name. + * + * Parameters can be specified with the optional third argument. Parameters + * must be a key->value map of the parameter name, and value. If the value + * is specified as an array, it is assumed that multiple parameters with + * the same name should be added. + * + * @param string $name + * @param string $value + * @param array $parameters + * @return Property + */ + static public function create($name, $value = null, array $parameters = array()) { + + $name = strtoupper($name); + $shortName = $name; + $group = null; + if (strpos($shortName,'.')!==false) { + list($group, $shortName) = explode('.', $shortName); + } + + if (isset(self::$classMap[$shortName])) { + return new self::$classMap[$shortName]($name, $value, $parameters); + } else { + return new self($name, $value, $parameters); + } + + } + + /** + * Creates a new property object + * + * Parameters can be specified with the optional third argument. Parameters + * must be a key->value map of the parameter name, and value. If the value + * is specified as an array, it is assumed that multiple parameters with + * the same name should be added. + * + * @param string $name + * @param string $value + * @param array $parameters + */ + public function __construct($name, $value = null, array $parameters = array()) { + + if (!is_scalar($value) && !is_null($value)) { + throw new \InvalidArgumentException('The value argument must be scalar or null'); + } + + $name = strtoupper($name); + $group = null; + if (strpos($name,'.')!==false) { + list($group, $name) = explode('.', $name); + } + $this->name = $name; + $this->group = $group; + $this->setValue($value); + + foreach($parameters as $paramName => $paramValues) { + + if (!is_array($paramValues)) { + $paramValues = array($paramValues); + } + + foreach($paramValues as $paramValue) { + $this->add($paramName, $paramValue); + } + + } + + } + + /** + * Updates the internal value + * + * @param string $value + * @return void + */ + public function setValue($value) { + + $this->value = $value; + + } + + /** + * Returns the internal value + * + * @param string $value + * @return string + */ + public function getValue() { + + return $this->value; + + } + + /** + * Turns the object back into a serialized blob. + * + * @return string + */ + public function serialize() { + + $str = $this->name; + if ($this->group) $str = $this->group . '.' . $this->name; + + foreach($this->parameters as $param) { + + $str.=';' . $param->serialize(); + + } + + $src = array( + '\\', + "\n", + ); + $out = array( + '\\\\', + '\n', + ); + $str.=':' . str_replace($src, $out, $this->value); + + $out = ''; + while(strlen($str)>0) { + if (strlen($str)>75) { + $out.= mb_strcut($str,0,75,'utf-8') . "\r\n"; + $str = ' ' . mb_strcut($str,75,strlen($str),'utf-8'); + } else { + $out.=$str . "\r\n"; + $str=''; + break; + } + } + + return $out; + + } + + /** + * Adds a new componenten or element + * + * You can call this method with the following syntaxes: + * + * add(Parameter $element) + * add(string $name, $value) + * + * The first version adds an Parameter + * The second adds a property as a string. + * + * @param mixed $item + * @param mixed $itemValue + * @return void + */ + public function add($item, $itemValue = null) { + + if ($item instanceof Parameter) { + if (!is_null($itemValue)) { + throw new \InvalidArgumentException('The second argument must not be specified, when passing a VObject'); + } + $item->parent = $this; + $this->parameters[] = $item; + } elseif(is_string($item)) { + + $parameter = new Parameter($item,$itemValue); + $parameter->parent = $this; + $this->parameters[] = $parameter; + + } else { + + throw new \InvalidArgumentException('The first argument must either be a Node a string'); + + } + + } + + /* ArrayAccess interface {{{ */ + + /** + * Checks if an array element exists + * + * @param mixed $name + * @return bool + */ + public function offsetExists($name) { + + if (is_int($name)) return parent::offsetExists($name); + + $name = strtoupper($name); + + foreach($this->parameters as $parameter) { + if ($parameter->name == $name) return true; + } + return false; + + } + + /** + * Returns a parameter, or parameter list. + * + * @param string $name + * @return Node + */ + public function offsetGet($name) { + + if (is_int($name)) return parent::offsetGet($name); + $name = strtoupper($name); + + $result = array(); + foreach($this->parameters as $parameter) { + if ($parameter->name == $name) + $result[] = $parameter; + } + + if (count($result)===0) { + return null; + } elseif (count($result)===1) { + return $result[0]; + } else { + $result[0]->setIterator(new ElementList($result)); + return $result[0]; + } + + } + + /** + * Creates a new parameter + * + * @param string $name + * @param mixed $value + * @return void + */ + public function offsetSet($name, $value) { + + if (is_int($name)) parent::offsetSet($name, $value); + + if (is_scalar($value)) { + if (!is_string($name)) + throw new \InvalidArgumentException('A parameter name must be specified. This means you cannot use the $array[]="string" to add parameters.'); + + $this->offsetUnset($name); + $parameter = new Parameter($name, $value); + $parameter->parent = $this; + $this->parameters[] = $parameter; + + } elseif ($value instanceof Parameter) { + if (!is_null($name)) + throw new \InvalidArgumentException('Don\'t specify a parameter name if you\'re passing a \\Sabre\\VObject\\Parameter. Add using $array[]=$parameterObject.'); + + $value->parent = $this; + $this->parameters[] = $value; + } else { + throw new \InvalidArgumentException('You can only add parameters to the property object'); + } + + } + + /** + * Removes one or more parameters with the specified name + * + * @param string $name + * @return void + */ + public function offsetUnset($name) { + + if (is_int($name)) parent::offsetUnset($name); + $name = strtoupper($name); + + foreach($this->parameters as $key=>$parameter) { + if ($parameter->name == $name) { + $parameter->parent = null; + unset($this->parameters[$key]); + } + + } + + } + + /* }}} */ + + /** + * Called when this object is being cast to a string + * + * @return string + */ + public function __toString() { + + return (string)$this->value; + + } + + /** + * This method is automatically called when the object is cloned. + * Specifically, this will ensure all child elements are also cloned. + * + * @return void + */ + public function __clone() { + + foreach($this->parameters as $key=>$child) { + $this->parameters[$key] = clone $child; + $this->parameters[$key]->parent = $this; + } + + } + + /** + * Validates the node for correctness. + * + * The following options are supported: + * - Node::REPAIR - If something is broken, and automatic repair may + * be attempted. + * + * An array is returned with warnings. + * + * Every item in the array has the following properties: + * * level - (number between 1 and 3 with severity information) + * * message - (human readable message) + * * node - (reference to the offending node) + * + * @param int $options + * @return array + */ + public function validate($options = 0) { + + $warnings = array(); + + // Checking if our value is UTF-8 + if (!StringUtil::isUTF8($this->value)) { + $warnings[] = array( + 'level' => 1, + 'message' => 'Property is not valid UTF-8!', + 'node' => $this, + ); + if ($options & self::REPAIR) { + $this->value = StringUtil::convertToUTF8($this->value); + } + } + + // Checking if the propertyname does not contain any invalid bytes. + if (!preg_match('/^([A-Z0-9-]+)$/', $this->name)) { + $warnings[] = array( + 'level' => 1, + 'message' => 'The propertyname: ' . $this->name . ' contains invalid characters. Only A-Z, 0-9 and - are allowed', + 'node' => $this, + ); + if ($options & self::REPAIR) { + // Uppercasing and converting underscores to dashes. + $this->name = strtoupper( + str_replace('_', '-', $this->name) + ); + // Removing every other invalid character + $this->name = preg_replace('/([^A-Z0-9-])/u', '', $this->name); + + } + + } + + // Validating inner parameters + foreach($this->parameters as $param) { + $warnings = array_merge($warnings, $param->validate($options)); + } + + return $warnings; + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Property/Compound.php b/plugins/libcalendaring/lib/Sabre/VObject/Property/Compound.php new file mode 100644 index 00000000..26f09006 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Property/Compound.php @@ -0,0 +1,125 @@ + ';', + 'ADR' => ';', + 'ORG' => ';', + 'CATEGORIES' => ',', + ); + + /** + * The currently used delimiter. + * + * @var string + */ + protected $delimiter = null; + + /** + * Get a compound value as an array. + * + * @param $name string + * @return array + */ + public function getParts() { + + if (is_null($this->value)) { + return array(); + } + + $delimiter = $this->getDelimiter(); + + // split by any $delimiter which is NOT prefixed by a slash. + // Note that this is not a a perfect solution. If a value is prefixed + // by two slashes, it should actually be split anyway. + // + // Hopefully we can fix this better in a future version, where we can + // break compatibility a bit. + $compoundValues = preg_split("/(?value); + + // remove slashes from any semicolon and comma left escaped in the single values + $compoundValues = array_map( + function($val) { + return strtr($val, array('\,' => ',', '\;' => ';')); + }, $compoundValues); + + return $compoundValues; + + } + + /** + * Returns the delimiter for this property. + * + * @return string + */ + public function getDelimiter() { + + if (!$this->delimiter) { + if (isset(self::$delimiterMap[$this->name])) { + $this->delimiter = self::$delimiterMap[$this->name]; + } else { + // To be a bit future proof, we are going to default the + // delimiter to ; + $this->delimiter = ';'; + } + } + return $this->delimiter; + + } + + /** + * Set a compound value as an array. + * + * + * @param $name string + * @return array + */ + public function setParts(array $values) { + + // add slashes to all semicolons and commas in the single values + $values = array_map( + function($val) { + return strtr($val, array(',' => '\,', ';' => '\;')); + }, $values); + + $this->setValue( + implode($this->getDelimiter(), $values) + ); + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Property/DateTime.php b/plugins/libcalendaring/lib/Sabre/VObject/Property/DateTime.php new file mode 100644 index 00000000..95e9b020 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Property/DateTime.php @@ -0,0 +1,245 @@ +setValue($dt->format('Ymd\\THis')); + $this->offsetUnset('VALUE'); + $this->offsetUnset('TZID'); + $this->offsetSet('VALUE','DATE-TIME'); + break; + case self::UTC : + $dt->setTimeZone(new \DateTimeZone('UTC')); + $this->setValue($dt->format('Ymd\\THis\\Z')); + $this->offsetUnset('VALUE'); + $this->offsetUnset('TZID'); + $this->offsetSet('VALUE','DATE-TIME'); + break; + case self::LOCALTZ : + $this->setValue($dt->format('Ymd\\THis')); + $this->offsetUnset('VALUE'); + $this->offsetUnset('TZID'); + $this->offsetSet('VALUE','DATE-TIME'); + $this->offsetSet('TZID', $dt->getTimeZone()->getName()); + break; + case self::DATE : + $this->setValue($dt->format('Ymd')); + $this->offsetUnset('VALUE'); + $this->offsetUnset('TZID'); + $this->offsetSet('VALUE','DATE'); + break; + default : + throw new \InvalidArgumentException('You must pass a valid dateType constant'); + + } + $this->dateTime = $dt; + $this->dateType = $dateType; + + } + + /** + * Returns the current DateTime value. + * + * If no value was set, this method returns null. + * + * @return \DateTime|null + */ + public function getDateTime() { + + if ($this->dateTime) + return $this->dateTime; + + list( + $this->dateType, + $this->dateTime + ) = self::parseData($this->value, $this); + return $this->dateTime; + + } + + /** + * Returns the type of Date format. + * + * This method returns one of the format constants. If no date was set, + * this method will return null. + * + * @return int|null + */ + public function getDateType() { + + if ($this->dateType) + return $this->dateType; + + list( + $this->dateType, + $this->dateTime, + ) = self::parseData($this->value, $this); + return $this->dateType; + + } + + /** + * This method will return true, if the property had a date and a time, as + * opposed to only a date. + * + * @return bool + */ + public function hasTime() { + + return $this->getDateType()!==self::DATE; + + } + + /** + * Parses the internal data structure to figure out what the current date + * and time is. + * + * The returned array contains two elements: + * 1. A 'DateType' constant (as defined on this class), or null. + * 2. A DateTime object (or null) + * + * @param string|null $propertyValue The string to parse (yymmdd or + * ymmddThhmmss, etc..) + * @param \Sabre\VObject\Property|null $property The instance of the + * property we're parsing. + * @return array + */ + static public function parseData($propertyValue, VObject\Property $property = null) { + + if (is_null($propertyValue)) { + return array(null, null); + } + + $date = '(?P[1-2][0-9]{3})(?P[0-1][0-9])(?P[0-3][0-9])'; + $time = '(?P[0-2][0-9])(?P[0-5][0-9])(?P[0-5][0-9])'; + $regex = "/^$date(T$time(?PZ)?)?$/"; + + if (!preg_match($regex, $propertyValue, $matches)) { + throw new \InvalidArgumentException($propertyValue . ' is not a valid \DateTime or Date string'); + } + + if (!isset($matches['hour'])) { + // Date-only + return array( + self::DATE, + new \DateTime($matches['year'] . '-' . $matches['month'] . '-' . $matches['date'] . ' 00:00:00', new \DateTimeZone('UTC')), + ); + } + + $dateStr = + $matches['year'] .'-' . + $matches['month'] . '-' . + $matches['date'] . ' ' . + $matches['hour'] . ':' . + $matches['minute'] . ':' . + $matches['second']; + + if (isset($matches['isutc'])) { + $dt = new \DateTime($dateStr,new \DateTimeZone('UTC')); + $dt->setTimeZone(new \DateTimeZone('UTC')); + return array( + self::UTC, + $dt + ); + } + + // Finding the timezone. + $tzid = $property['TZID']; + if (!$tzid) { + // This was a floating time string. This implies we use the + // timezone from date_default_timezone_set / date.timezone ini + // setting. + return array( + self::LOCAL, + new \DateTime($dateStr) + ); + } + + // To look up the timezone, we must first find the VCALENDAR component. + $root = $property; + while($root->parent) { + $root = $root->parent; + } + if ($root->name === 'VCALENDAR') { + $tz = VObject\TimeZoneUtil::getTimeZone((string)$tzid, $root); + } else { + $tz = VObject\TimeZoneUtil::getTimeZone((string)$tzid); + } + + $dt = new \DateTime($dateStr, $tz); + $dt->setTimeZone($tz); + + return array( + self::LOCALTZ, + $dt + ); + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Property/MultiDateTime.php b/plugins/libcalendaring/lib/Sabre/VObject/Property/MultiDateTime.php new file mode 100644 index 00000000..f01491b6 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Property/MultiDateTime.php @@ -0,0 +1,180 @@ +offsetUnset('VALUE'); + $this->offsetUnset('TZID'); + switch($dateType) { + + case DateTime::LOCAL : + $val = array(); + foreach($dt as $i) { + $val[] = $i->format('Ymd\\THis'); + } + $this->setValue(implode(',',$val)); + $this->offsetSet('VALUE','DATE-TIME'); + break; + case DateTime::UTC : + $val = array(); + foreach($dt as $i) { + $i->setTimeZone(new \DateTimeZone('UTC')); + $val[] = $i->format('Ymd\\THis\\Z'); + } + $this->setValue(implode(',',$val)); + $this->offsetSet('VALUE','DATE-TIME'); + break; + case DateTime::LOCALTZ : + $val = array(); + foreach($dt as $i) { + $val[] = $i->format('Ymd\\THis'); + } + $this->setValue(implode(',',$val)); + $this->offsetSet('VALUE','DATE-TIME'); + $this->offsetSet('TZID', $dt[0]->getTimeZone()->getName()); + break; + case DateTime::DATE : + $val = array(); + foreach($dt as $i) { + $val[] = $i->format('Ymd'); + } + $this->setValue(implode(',',$val)); + $this->offsetSet('VALUE','DATE'); + break; + default : + throw new \InvalidArgumentException('You must pass a valid dateType constant'); + + } + $this->dateTimes = $dt; + $this->dateType = $dateType; + + } + + /** + * Returns the current DateTime value. + * + * If no value was set, this method returns null. + * + * @return array|null + */ + public function getDateTimes() { + + if ($this->dateTimes) + return $this->dateTimes; + + $dts = array(); + + if (!$this->value) { + $this->dateTimes = null; + $this->dateType = null; + return null; + } + + foreach(explode(',',$this->value) as $val) { + list( + $type, + $dt + ) = DateTime::parseData($val, $this); + $dts[] = $dt; + $this->dateType = $type; + } + $this->dateTimes = $dts; + return $this->dateTimes; + + } + + /** + * Returns the type of Date format. + * + * This method returns one of the format constants. If no date was set, + * this method will return null. + * + * @return int|null + */ + public function getDateType() { + + if ($this->dateType) + return $this->dateType; + + if (!$this->value) { + $this->dateTimes = null; + $this->dateType = null; + return null; + } + + $dts = array(); + foreach(explode(',',$this->value) as $val) { + list( + $type, + $dt + ) = DateTime::parseData($val, $this); + $dts[] = $dt; + $this->dateType = $type; + } + $this->dateTimes = $dts; + return $this->dateType; + + } + + /** + * This method will return true, if the property had a date and a time, as + * opposed to only a date. + * + * @return bool + */ + public function hasTime() { + + return $this->getDateType()!==DateTime::DATE; + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Reader.php b/plugins/libcalendaring/lib/Sabre/VObject/Reader.php new file mode 100644 index 00000000..a001b2bf --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Reader.php @@ -0,0 +1,223 @@ +add($parsedLine); + + if ($nextLine===false) + throw new ParseException('Invalid VObject. Document ended prematurely.'); + + } + + // Checking component name of the 'END:' line. + if (substr($nextLine,4)!==$obj->name) { + throw new ParseException('Invalid VObject, expected: "END:' . $obj->name . '" got: "' . $nextLine . '"'); + } + next($lines); + + return $obj; + + } + + // Properties + //$result = preg_match('/(?P[A-Z0-9-]+)(?:;(?P^(?([^:^\"]|\"([^\"]*)\")*))?"; + $regex = "/^(?P$token)$parameters:(?P.*)$/i"; + + $result = preg_match($regex,$line,$matches); + + if (!$result) { + if ($options & self::OPTION_IGNORE_INVALID_LINES) { + return null; + } else { + throw new ParseException('Invalid VObject, line ' . ($lineNr+1) . ' did not follow the icalendar/vcard format'); + } + } + + $propertyName = strtoupper($matches['name']); + $propertyValue = preg_replace_callback('#(\\\\(\\\\|N|n))#',function($matches) { + if ($matches[2]==='n' || $matches[2]==='N') { + return "\n"; + } else { + return $matches[2]; + } + }, $matches['value']); + + $obj = Property::create($propertyName, $propertyValue); + + if ($matches['parameters']) { + + foreach(self::readParameters($matches['parameters']) as $param) { + $obj->add($param); + } + + } + + return $obj; + + + } + + /** + * Reads a parameter list from a property + * + * This method returns an array of Parameter + * + * @param string $parameters + * @return array + */ + static private function readParameters($parameters) { + + $token = '[A-Z0-9-]+'; + + $paramValue = '(?P[^\"^;]*|"[^"]*")'; + + $regex = "/(?<=^|;)(?P$token)(=$paramValue(?=$|;))?/i"; + preg_match_all($regex, $parameters, $matches, PREG_SET_ORDER); + + $params = array(); + foreach($matches as $match) { + + if (!isset($match['paramValue'])) { + + $value = null; + + } else { + + $value = $match['paramValue']; + + if (isset($value[0]) && $value[0]==='"') { + // Stripping quotes, if needed + $value = substr($value,1,strlen($value)-2); + } + + $value = preg_replace_callback('#(\\\\(\\\\|N|n|;|,))#',function($matches) { + if ($matches[2]==='n' || $matches[2]==='N') { + return "\n"; + } else { + return $matches[2]; + } + }, $value); + + } + + $params[] = new Parameter($match['paramName'], $value); + + } + + return $params; + + } + + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/RecurrenceIterator.php b/plugins/libcalendaring/lib/Sabre/VObject/RecurrenceIterator.php new file mode 100644 index 00000000..0d5997ec --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/RecurrenceIterator.php @@ -0,0 +1,1112 @@ + 0, + 'MO' => 1, + 'TU' => 2, + 'WE' => 3, + 'TH' => 4, + 'FR' => 5, + 'SA' => 6, + ); + + /** + * Mappings between the day number and english day name. + * + * @var array + */ + private $dayNames = array( + 0 => 'Sunday', + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', + ); + + /** + * If the current iteration of the event is an overriden event, this + * property will hold the VObject + * + * @var Component + */ + private $currentOverriddenEvent; + + /** + * This property may contain the date of the next not-overridden event. + * This date is calculated sometimes a bit early, before overridden events + * are evaluated. + * + * @var DateTime + */ + private $nextDate; + + /** + * Creates the iterator + * + * You should pass a VCALENDAR component, as well as the UID of the event + * we're going to traverse. + * + * @param Component $vcal + * @param string|null $uid + */ + public function __construct(Component $vcal, $uid=null) { + + if (is_null($uid)) { + if ($vcal->name === 'VCALENDAR') { + throw new \InvalidArgumentException('If you pass a VCALENDAR object, you must pass a uid argument as well'); + } + $components = array($vcal); + $uid = (string)$vcal->uid; + } else { + $components = $vcal->select('VEVENT'); + } + foreach($components as $component) { + if ((string)$component->uid == $uid) { + if (isset($component->{'RECURRENCE-ID'})) { + $this->overriddenEvents[$component->DTSTART->getDateTime()->getTimeStamp()] = $component; + $this->overriddenDates[] = $component->{'RECURRENCE-ID'}->getDateTime(); + } else { + $this->baseEvent = $component; + } + } + } + if (!$this->baseEvent) { + throw new \InvalidArgumentException('Could not find a base event with uid: ' . $uid); + } + + $this->startDate = clone $this->baseEvent->DTSTART->getDateTime(); + + $this->endDate = null; + if (isset($this->baseEvent->DTEND)) { + $this->endDate = clone $this->baseEvent->DTEND->getDateTime(); + } else { + $this->endDate = clone $this->startDate; + if (isset($this->baseEvent->DURATION)) { + $this->endDate->add(DateTimeParser::parse($this->baseEvent->DURATION->value)); + } elseif ($this->baseEvent->DTSTART->getDateType()===Property\DateTime::DATE) { + $this->endDate->modify('+1 day'); + } + } + $this->currentDate = clone $this->startDate; + + $rrule = (string)$this->baseEvent->RRULE; + + $parts = explode(';', $rrule); + + // If no rrule was specified, we create a default setting + if (!$rrule) { + $this->frequency = 'daily'; + $this->count = 1; + } else foreach($parts as $part) { + + list($key, $value) = explode('=', $part, 2); + + switch(strtoupper($key)) { + + case 'FREQ' : + if (!in_array( + strtolower($value), + array('secondly','minutely','hourly','daily','weekly','monthly','yearly') + )) { + throw new \InvalidArgumentException('Unknown value for FREQ=' . strtoupper($value)); + + } + $this->frequency = strtolower($value); + break; + + case 'UNTIL' : + $this->until = DateTimeParser::parse($value); + + // In some cases events are generated with an UNTIL= + // parameter before the actual start of the event. + // + // Not sure why this is happening. We assume that the + // intention was that the event only recurs once. + // + // So we are modifying the parameter so our code doesn't + // break. + if($this->until < $this->baseEvent->DTSTART->getDateTime()) { + $this->until = $this->baseEvent->DTSTART->getDateTime(); + } + break; + + case 'COUNT' : + $this->count = (int)$value; + break; + + case 'INTERVAL' : + $this->interval = (int)$value; + if ($this->interval < 1) { + throw new \InvalidArgumentException('INTERVAL in RRULE must be a positive integer!'); + } + break; + + case 'BYSECOND' : + $this->bySecond = explode(',', $value); + break; + + case 'BYMINUTE' : + $this->byMinute = explode(',', $value); + break; + + case 'BYHOUR' : + $this->byHour = explode(',', $value); + break; + + case 'BYDAY' : + $this->byDay = explode(',', strtoupper($value)); + break; + + case 'BYMONTHDAY' : + $this->byMonthDay = explode(',', $value); + break; + + case 'BYYEARDAY' : + $this->byYearDay = explode(',', $value); + break; + + case 'BYWEEKNO' : + $this->byWeekNo = explode(',', $value); + break; + + case 'BYMONTH' : + $this->byMonth = explode(',', $value); + break; + + case 'BYSETPOS' : + $this->bySetPos = explode(',', $value); + break; + + case 'WKST' : + $this->weekStart = strtoupper($value); + break; + + } + + } + + // Parsing exception dates + if (isset($this->baseEvent->EXDATE)) { + foreach($this->baseEvent->EXDATE as $exDate) { + + foreach(explode(',', (string)$exDate) as $exceptionDate) { + + $this->exceptionDates[] = + DateTimeParser::parse($exceptionDate, $this->startDate->getTimeZone()); + + } + + } + + } + + } + + /** + * Returns the current item in the list + * + * @return DateTime + */ + public function current() { + + if (!$this->valid()) return null; + return clone $this->currentDate; + + } + + /** + * This method returns the startdate for the current iteration of the + * event. + * + * @return DateTime + */ + public function getDtStart() { + + if (!$this->valid()) return null; + return clone $this->currentDate; + + } + + /** + * This method returns the enddate for the current iteration of the + * event. + * + * @return DateTime + */ + public function getDtEnd() { + + if (!$this->valid()) return null; + $dtEnd = clone $this->currentDate; + $dtEnd->add( $this->startDate->diff( $this->endDate ) ); + return clone $dtEnd; + + } + + /** + * Returns a VEVENT object with the updated start and end date. + * + * Any recurrence information is removed, and this function may return an + * 'overridden' event instead. + * + * This method always returns a cloned instance. + * + * @return Component\VEvent + */ + public function getEventObject() { + + if ($this->currentOverriddenEvent) { + return clone $this->currentOverriddenEvent; + } + $event = clone $this->baseEvent; + unset($event->RRULE); + unset($event->EXDATE); + unset($event->RDATE); + unset($event->EXRULE); + + $event->DTSTART->setDateTime($this->getDTStart(), $event->DTSTART->getDateType()); + if (isset($event->DTEND)) { + $event->DTEND->setDateTime($this->getDtEnd(), $event->DTSTART->getDateType()); + } + if ($this->counter > 0) { + $event->{'RECURRENCE-ID'} = (string)$event->DTSTART; + } + + return $event; + + } + + /** + * Returns the current item number + * + * @return int + */ + public function key() { + + return $this->counter; + + } + + /** + * Whether or not there is a 'next item' + * + * @return bool + */ + public function valid() { + + if (!is_null($this->count)) { + return $this->counter < $this->count; + } + if (!is_null($this->until)) { + return $this->currentDate <= $this->until; + } + return true; + + } + + /** + * Resets the iterator + * + * @return void + */ + public function rewind() { + + $this->currentDate = clone $this->startDate; + $this->counter = 0; + + } + + /** + * This method allows you to quickly go to the next occurrence after the + * specified date. + * + * Note that this checks the current 'endDate', not the 'stardDate'. This + * means that if you forward to January 1st, the iterator will stop at the + * first event that ends *after* January 1st. + * + * @param DateTime $dt + * @return void + */ + public function fastForward(\DateTime $dt) { + + while($this->valid() && $this->getDTEnd() <= $dt) { + $this->next(); + } + + } + + /** + * Returns true if this recurring event never ends. + * + * @return bool + */ + public function isInfinite() { + + return !$this->count && !$this->until; + + } + + /** + * Goes on to the next iteration + * + * @return void + */ + public function next() { + + /* + if (!is_null($this->count) && $this->counter >= $this->count) { + $this->currentDate = null; + }*/ + + + $previousStamp = $this->currentDate->getTimeStamp(); + + while(true) { + + $this->currentOverriddenEvent = null; + + // If we have a next date 'stored', we use that + if ($this->nextDate) { + $this->currentDate = $this->nextDate; + $currentStamp = $this->currentDate->getTimeStamp(); + $this->nextDate = null; + } else { + + // Otherwise, we calculate it + switch($this->frequency) { + + case 'hourly' : + $this->nextHourly(); + break; + + case 'daily' : + $this->nextDaily(); + break; + + case 'weekly' : + $this->nextWeekly(); + break; + + case 'monthly' : + $this->nextMonthly(); + break; + + case 'yearly' : + $this->nextYearly(); + break; + + } + $currentStamp = $this->currentDate->getTimeStamp(); + + // Checking exception dates + foreach($this->exceptionDates as $exceptionDate) { + if ($this->currentDate == $exceptionDate) { + $this->counter++; + continue 2; + } + } + foreach($this->overriddenDates as $overriddenDate) { + if ($this->currentDate == $overriddenDate) { + continue 2; + } + } + + } + + // Checking overridden events + foreach($this->overriddenEvents as $index=>$event) { + if ($index > $previousStamp && $index <= $currentStamp) { + + // We're moving the 'next date' aside, for later use. + $this->nextDate = clone $this->currentDate; + + $this->currentDate = $event->DTSTART->getDateTime(); + $this->currentOverriddenEvent = $event; + + break; + } + } + + break; + + } + + /* + if (!is_null($this->until)) { + if($this->currentDate > $this->until) { + $this->currentDate = null; + } + }*/ + + $this->counter++; + + } + + /** + * Does the processing for advancing the iterator for hourly frequency. + * + * @return void + */ + protected function nextHourly() { + + if (!$this->byHour) { + $this->currentDate->modify('+' . $this->interval . ' hours'); + return; + } + } + + /** + * Does the processing for advancing the iterator for daily frequency. + * + * @return void + */ + protected function nextDaily() { + + if (!$this->byHour && !$this->byDay) { + $this->currentDate->modify('+' . $this->interval . ' days'); + return; + } + + if (isset($this->byHour)) { + $recurrenceHours = $this->getHours(); + } + + if (isset($this->byDay)) { + $recurrenceDays = $this->getDays(); + } + + do { + + if ($this->byHour) { + if ($this->currentDate->format('G') == '23') { + // to obey the interval rule + $this->currentDate->modify('+' . $this->interval-1 . ' days'); + } + + $this->currentDate->modify('+1 hours'); + + } else { + $this->currentDate->modify('+' . $this->interval . ' days'); + + } + + // Current day of the week + $currentDay = $this->currentDate->format('w'); + + // Current hour of the day + $currentHour = $this->currentDate->format('G'); + + } while (($this->byDay && !in_array($currentDay, $recurrenceDays)) || ($this->byHour && !in_array($currentHour, $recurrenceHours))); + + } + + /** + * Does the processing for advancing the iterator for weekly frequency. + * + * @return void + */ + protected function nextWeekly() { + + if (!$this->byHour && !$this->byDay) { + $this->currentDate->modify('+' . $this->interval . ' weeks'); + return; + } + + if ($this->byHour) { + $recurrenceHours = $this->getHours(); + } + + if ($this->byDay) { + $recurrenceDays = $this->getDays(); + } + + // First day of the week: + $firstDay = $this->dayMap[$this->weekStart]; + + do { + + if ($this->byHour) { + $this->currentDate->modify('+1 hours'); + } else { + $this->currentDate->modify('+1 days'); + } + + // Current day of the week + $currentDay = (int) $this->currentDate->format('w'); + + // Current hour of the day + $currentHour = (int) $this->currentDate->format('G'); + + // We need to roll over to the next week + if ($currentDay === $firstDay && (!$this->byHour || $currentHour == '0')) { + $this->currentDate->modify('+' . $this->interval-1 . ' weeks'); + + // We need to go to the first day of this week, but only if we + // are not already on this first day of this week. + if($this->currentDate->format('w') != $firstDay) { + $this->currentDate->modify('last ' . $this->dayNames[$this->dayMap[$this->weekStart]]); + } + } + + // We have a match + } while (($this->byDay && !in_array($currentDay, $recurrenceDays)) || ($this->byHour && !in_array($currentHour, $recurrenceHours))); + } + + /** + * Does the processing for advancing the iterator for monthly frequency. + * + * @return void + */ + protected function nextMonthly() { + + $currentDayOfMonth = $this->currentDate->format('j'); + if (!$this->byMonthDay && !$this->byDay) { + + // If the current day is higher than the 28th, rollover can + // occur to the next month. We Must skip these invalid + // entries. + if ($currentDayOfMonth < 29) { + $this->currentDate->modify('+' . $this->interval . ' months'); + } else { + $increase = 0; + do { + $increase++; + $tempDate = clone $this->currentDate; + $tempDate->modify('+ ' . ($this->interval*$increase) . ' months'); + } while ($tempDate->format('j') != $currentDayOfMonth); + $this->currentDate = $tempDate; + } + return; + } + + while(true) { + + $occurrences = $this->getMonthlyOccurrences(); + + foreach($occurrences as $occurrence) { + + // The first occurrence thats higher than the current + // day of the month wins. + if ($occurrence > $currentDayOfMonth) { + break 2; + } + + } + + // If we made it all the way here, it means there were no + // valid occurrences, and we need to advance to the next + // month. + $this->currentDate->modify('first day of this month'); + $this->currentDate->modify('+ ' . $this->interval . ' months'); + + // This goes to 0 because we need to start counting at hte + // beginning. + $currentDayOfMonth = 0; + + } + + $this->currentDate->setDate($this->currentDate->format('Y'), $this->currentDate->format('n'), $occurrence); + + } + + /** + * Does the processing for advancing the iterator for yearly frequency. + * + * @return void + */ + protected function nextYearly() { + + $currentMonth = $this->currentDate->format('n'); + $currentYear = $this->currentDate->format('Y'); + $currentDayOfMonth = $this->currentDate->format('j'); + + // No sub-rules, so we just advance by year + if (!$this->byMonth) { + + // Unless it was a leap day! + if ($currentMonth==2 && $currentDayOfMonth==29) { + + $counter = 0; + do { + $counter++; + // Here we increase the year count by the interval, until + // we hit a date that's also in a leap year. + // + // We could just find the next interval that's dividable by + // 4, but that would ignore the rule that there's no leap + // year every year that's dividable by a 100, but not by + // 400. (1800, 1900, 2100). So we just rely on the datetime + // functions instead. + $nextDate = clone $this->currentDate; + $nextDate->modify('+ ' . ($this->interval*$counter) . ' years'); + } while ($nextDate->format('n')!=2); + $this->currentDate = $nextDate; + + return; + + } + + // The easiest form + $this->currentDate->modify('+' . $this->interval . ' years'); + return; + + } + + $currentMonth = $this->currentDate->format('n'); + $currentYear = $this->currentDate->format('Y'); + $currentDayOfMonth = $this->currentDate->format('j'); + + $advancedToNewMonth = false; + + // If we got a byDay or getMonthDay filter, we must first expand + // further. + if ($this->byDay || $this->byMonthDay) { + + while(true) { + + $occurrences = $this->getMonthlyOccurrences(); + + foreach($occurrences as $occurrence) { + + // The first occurrence that's higher than the current + // day of the month wins. + // If we advanced to the next month or year, the first + // occurrence is always correct. + if ($occurrence > $currentDayOfMonth || $advancedToNewMonth) { + break 2; + } + + } + + // If we made it here, it means we need to advance to + // the next month or year. + $currentDayOfMonth = 1; + $advancedToNewMonth = true; + do { + + $currentMonth++; + if ($currentMonth>12) { + $currentYear+=$this->interval; + $currentMonth = 1; + } + } while (!in_array($currentMonth, $this->byMonth)); + + $this->currentDate->setDate($currentYear, $currentMonth, $currentDayOfMonth); + + } + + // If we made it here, it means we got a valid occurrence + $this->currentDate->setDate($currentYear, $currentMonth, $occurrence); + return; + + } else { + + // These are the 'byMonth' rules, if there are no byDay or + // byMonthDay sub-rules. + do { + + $currentMonth++; + if ($currentMonth>12) { + $currentYear+=$this->interval; + $currentMonth = 1; + } + } while (!in_array($currentMonth, $this->byMonth)); + $this->currentDate->setDate($currentYear, $currentMonth, $currentDayOfMonth); + + return; + + } + + } + + /** + * Returns all the occurrences for a monthly frequency with a 'byDay' or + * 'byMonthDay' expansion for the current month. + * + * The returned list is an array of integers with the day of month (1-31). + * + * @return array + */ + protected function getMonthlyOccurrences() { + + $startDate = clone $this->currentDate; + + $byDayResults = array(); + + // Our strategy is to simply go through the byDays, advance the date to + // that point and add it to the results. + if ($this->byDay) foreach($this->byDay as $day) { + + $dayName = $this->dayNames[$this->dayMap[substr($day,-2)]]; + + // Dayname will be something like 'wednesday'. Now we need to find + // all wednesdays in this month. + $dayHits = array(); + + $checkDate = clone $startDate; + $checkDate->modify('first day of this month'); + $checkDate->modify($dayName); + + do { + $dayHits[] = $checkDate->format('j'); + $checkDate->modify('next ' . $dayName); + } while ($checkDate->format('n') === $startDate->format('n')); + + // So now we have 'all wednesdays' for month. It is however + // possible that the user only really wanted the 1st, 2nd or last + // wednesday. + if (strlen($day)>2) { + $offset = (int)substr($day,0,-2); + + if ($offset>0) { + // It is possible that the day does not exist, such as a + // 5th or 6th wednesday of the month. + if (isset($dayHits[$offset-1])) { + $byDayResults[] = $dayHits[$offset-1]; + } + } else { + + // if it was negative we count from the end of the array + $byDayResults[] = $dayHits[count($dayHits) + $offset]; + } + } else { + // There was no counter (first, second, last wednesdays), so we + // just need to add the all to the list). + $byDayResults = array_merge($byDayResults, $dayHits); + + } + + } + + $byMonthDayResults = array(); + if ($this->byMonthDay) foreach($this->byMonthDay as $monthDay) { + + // Removing values that are out of range for this month + if ($monthDay > $startDate->format('t') || + $monthDay < 0-$startDate->format('t')) { + continue; + } + if ($monthDay>0) { + $byMonthDayResults[] = $monthDay; + } else { + // Negative values + $byMonthDayResults[] = $startDate->format('t') + 1 + $monthDay; + } + } + + // If there was just byDay or just byMonthDay, they just specify our + // (almost) final list. If both were provided, then byDay limits the + // list. + if ($this->byMonthDay && $this->byDay) { + $result = array_intersect($byMonthDayResults, $byDayResults); + } elseif ($this->byMonthDay) { + $result = $byMonthDayResults; + } else { + $result = $byDayResults; + } + $result = array_unique($result); + sort($result, SORT_NUMERIC); + + // The last thing that needs checking is the BYSETPOS. If it's set, it + // means only certain items in the set survive the filter. + if (!$this->bySetPos) { + return $result; + } + + $filteredResult = array(); + foreach($this->bySetPos as $setPos) { + + if ($setPos<0) { + $setPos = count($result)-($setPos+1); + } + if (isset($result[$setPos-1])) { + $filteredResult[] = $result[$setPos-1]; + } + } + + sort($filteredResult, SORT_NUMERIC); + return $filteredResult; + + } + + protected function getHours() + { + $recurrenceHours = array(); + foreach($this->byHour as $byHour) { + $recurrenceHours[] = $byHour; + } + + return $recurrenceHours; + } + + protected function getDays() + { + $recurrenceDays = array(); + foreach($this->byDay as $byDay) { + + // The day may be preceeded with a positive (+n) or + // negative (-n) integer. However, this does not make + // sense in 'weekly' so we ignore it here. + $recurrenceDays[] = $this->dayMap[substr($byDay,-2)]; + + } + + return $recurrenceDays; + } +} + diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Splitter/ICalendar.php b/plugins/libcalendaring/lib/Sabre/VObject/Splitter/ICalendar.php new file mode 100644 index 00000000..657cfb81 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Splitter/ICalendar.php @@ -0,0 +1,111 @@ +children as $component) { + if (!$component instanceof VObject\Component) { + continue; + } + + // Get all timezones + if ($component->name === 'VTIMEZONE') { + $this->vtimezones[(string)$component->TZID] = $component; + continue; + } + + // Get component UID for recurring Events search + if($component->UID) { + $uid = (string)$component->UID; + } else { + // Generating a random UID + $uid = sha1(microtime()) . '-vobjectimport'; + } + + // Take care of recurring events + if (!array_key_exists($uid, $this->objects)) { + $this->objects[$uid] = VObject\Component::create('VCALENDAR'); + } + + $this->objects[$uid]->add(clone $component); + } + + } + + /** + * Every time getNext() is called, a new object will be parsed, until we + * hit the end of the stream. + * + * When the end is reached, null will be returned. + * + * @return Sabre\VObject\Component|null + */ + public function getNext() { + + if($object=array_shift($this->objects)) { + + // create our baseobject + $object->version = '2.0'; + $object->prodid = '-//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; + $object->calscale = 'GREGORIAN'; + + // add vtimezone information to obj (if we have it) + foreach ($this->vtimezones as $vtimezone) { + $object->add($vtimezone); + } + + return $object; + + } else { + + return null; + + } + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Splitter/SplitterInterface.php b/plugins/libcalendaring/lib/Sabre/VObject/Splitter/SplitterInterface.php new file mode 100644 index 00000000..c0126883 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Splitter/SplitterInterface.php @@ -0,0 +1,39 @@ +input = $input; + + } + + /** + * Every time getNext() is called, a new object will be parsed, until we + * hit the end of the stream. + * + * When the end is reached, null will be returned. + * + * @return Sabre\VObject\Component|null + */ + public function getNext() { + + $vcard = ''; + + do { + + if (feof($this->input)) { + return false; + } + + $line = fgets($this->input); + $vcard .= $line; + + } while(strtoupper(substr($line,0,4))!=="END:"); + + $object = VObject\Reader::read($vcard); + + if($object->name !== 'VCARD') { + throw new \InvalidArgumentException("Thats no vCard!", 1); + } + + return $object; + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/StringUtil.php b/plugins/libcalendaring/lib/Sabre/VObject/StringUtil.php new file mode 100644 index 00000000..ea88e1e8 --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/StringUtil.php @@ -0,0 +1,61 @@ +'Australia/Darwin', + 'AUS Eastern Standard Time'=>'Australia/Sydney', + 'Afghanistan Standard Time'=>'Asia/Kabul', + 'Alaskan Standard Time'=>'America/Anchorage', + 'Arab Standard Time'=>'Asia/Riyadh', + 'Arabian Standard Time'=>'Asia/Dubai', + 'Arabic Standard Time'=>'Asia/Baghdad', + 'Argentina Standard Time'=>'America/Buenos_Aires', + 'Armenian Standard Time'=>'Asia/Yerevan', + 'Atlantic Standard Time'=>'America/Halifax', + 'Azerbaijan Standard Time'=>'Asia/Baku', + 'Azores Standard Time'=>'Atlantic/Azores', + 'Bangladesh Standard Time'=>'Asia/Dhaka', + 'Canada Central Standard Time'=>'America/Regina', + 'Cape Verde Standard Time'=>'Atlantic/Cape_Verde', + 'Caucasus Standard Time'=>'Asia/Yerevan', + 'Cen. Australia Standard Time'=>'Australia/Adelaide', + 'Central America Standard Time'=>'America/Guatemala', + 'Central Asia Standard Time'=>'Asia/Almaty', + 'Central Brazilian Standard Time'=>'America/Cuiaba', + 'Central Europe Standard Time'=>'Europe/Budapest', + 'Central European Standard Time'=>'Europe/Warsaw', + 'Central Pacific Standard Time'=>'Pacific/Guadalcanal', + 'Central Standard Time'=>'America/Chicago', + 'Central Standard Time (Mexico)'=>'America/Mexico_City', + 'China Standard Time'=>'Asia/Shanghai', + 'Dateline Standard Time'=>'Etc/GMT+12', + 'E. Africa Standard Time'=>'Africa/Nairobi', + 'E. Australia Standard Time'=>'Australia/Brisbane', + 'E. Europe Standard Time'=>'Europe/Minsk', + 'E. South America Standard Time'=>'America/Sao_Paulo', + 'Eastern Standard Time'=>'America/New_York', + 'Egypt Standard Time'=>'Africa/Cairo', + 'Ekaterinburg Standard Time'=>'Asia/Yekaterinburg', + 'FLE Standard Time'=>'Europe/Kiev', + 'Fiji Standard Time'=>'Pacific/Fiji', + 'GMT Standard Time'=>'Europe/London', + 'GTB Standard Time'=>'Europe/Istanbul', + 'Georgian Standard Time'=>'Asia/Tbilisi', + 'Greenland Standard Time'=>'America/Godthab', + 'Greenwich Standard Time'=>'Atlantic/Reykjavik', + 'Hawaiian Standard Time'=>'Pacific/Honolulu', + 'India Standard Time'=>'Asia/Calcutta', + 'Iran Standard Time'=>'Asia/Tehran', + 'Israel Standard Time'=>'Asia/Jerusalem', + 'Jordan Standard Time'=>'Asia/Amman', + 'Kamchatka Standard Time'=>'Asia/Kamchatka', + 'Korea Standard Time'=>'Asia/Seoul', + 'Magadan Standard Time'=>'Asia/Magadan', + 'Mauritius Standard Time'=>'Indian/Mauritius', + 'Mexico Standard Time'=>'America/Mexico_City', + 'Mexico Standard Time 2'=>'America/Chihuahua', + 'Mid-Atlantic Standard Time'=>'Etc/GMT-2', + 'Middle East Standard Time'=>'Asia/Beirut', + 'Montevideo Standard Time'=>'America/Montevideo', + 'Morocco Standard Time'=>'Africa/Casablanca', + 'Mountain Standard Time'=>'America/Denver', + 'Mountain Standard Time (Mexico)'=>'America/Chihuahua', + 'Myanmar Standard Time'=>'Asia/Rangoon', + 'N. Central Asia Standard Time'=>'Asia/Novosibirsk', + 'Namibia Standard Time'=>'Africa/Windhoek', + 'Nepal Standard Time'=>'Asia/Katmandu', + 'New Zealand Standard Time'=>'Pacific/Auckland', + 'Newfoundland Standard Time'=>'America/St_Johns', + 'North Asia East Standard Time'=>'Asia/Irkutsk', + 'North Asia Standard Time'=>'Asia/Krasnoyarsk', + 'Pacific SA Standard Time'=>'America/Santiago', + 'Pacific Standard Time'=>'America/Los_Angeles', + 'Pacific Standard Time (Mexico)'=>'America/Santa_Isabel', + 'Pakistan Standard Time'=>'Asia/Karachi', + 'Paraguay Standard Time'=>'America/Asuncion', + 'Romance Standard Time'=>'Europe/Paris', + 'Russian Standard Time'=>'Europe/Moscow', + 'SA Eastern Standard Time'=>'America/Cayenne', + 'SA Pacific Standard Time'=>'America/Bogota', + 'SA Western Standard Time'=>'America/La_Paz', + 'SE Asia Standard Time'=>'Asia/Bangkok', + 'Samoa Standard Time'=>'Pacific/Apia', + 'Singapore Standard Time'=>'Asia/Singapore', + 'South Africa Standard Time'=>'Africa/Johannesburg', + 'Sri Lanka Standard Time'=>'Asia/Colombo', + 'Syria Standard Time'=>'Asia/Damascus', + 'Taipei Standard Time'=>'Asia/Taipei', + 'Tasmania Standard Time'=>'Australia/Hobart', + 'Tokyo Standard Time'=>'Asia/Tokyo', + 'Tonga Standard Time'=>'Pacific/Tongatapu', + 'US Eastern Standard Time'=>'America/Indianapolis', + 'US Mountain Standard Time'=>'America/Phoenix', + 'UTC+12'=>'Etc/GMT-12', + 'UTC-02'=>'Etc/GMT+2', + 'UTC-11'=>'Etc/GMT+11', + 'Ulaanbaatar Standard Time'=>'Asia/Ulaanbaatar', + 'Venezuela Standard Time'=>'America/Caracas', + 'Vladivostok Standard Time'=>'Asia/Vladivostok', + 'W. Australia Standard Time'=>'Australia/Perth', + 'W. Central Africa Standard Time'=>'Africa/Lagos', + 'W. Europe Standard Time'=>'Europe/Berlin', + 'West Asia Standard Time'=>'Asia/Tashkent', + 'West Pacific Standard Time'=>'Pacific/Port_Moresby', + 'Yakutsk Standard Time'=>'Asia/Yakutsk', + + // Microsoft exchange timezones + // Source: + // http://msdn.microsoft.com/en-us/library/ms988620%28v=exchg.65%29.aspx + // + // Correct timezones deduced with help from: + // http://en.wikipedia.org/wiki/List_of_tz_database_time_zones + 'Universal Coordinated Time' => 'UTC', + 'Casablanca, Monrovia' => 'Africa/Casablanca', + 'Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London' => 'Europe/Lisbon', + 'Greenwich Mean Time; Dublin, Edinburgh, London' => 'Europe/London', + 'Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin', + 'Belgrade, Pozsony, Budapest, Ljubljana, Prague' => 'Europe/Prague', + 'Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris', + 'Paris, Madrid, Brussels, Copenhagen' => 'Europe/Paris', + 'Prague, Central Europe' => 'Europe/Prague', + 'Sarajevo, Skopje, Sofija, Vilnius, Warsaw, Zagreb' => 'Europe/Sarajevo', + 'West Central Africa' => 'Africa/Luanda', // This was a best guess + 'Athens, Istanbul, Minsk' => 'Europe/Athens', + 'Bucharest' => 'Europe/Bucharest', + 'Cairo' => 'Africa/Cairo', + 'Harare, Pretoria' => 'Africa/Harare', + 'Helsinki, Riga, Tallinn' => 'Europe/Helsinki', + 'Israel, Jerusalem Standard Time' => 'Asia/Jerusalem', + 'Baghdad' => 'Asia/Baghdad', + 'Arab, Kuwait, Riyadh' => 'Asia/Kuwait', + 'Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow', + 'East Africa, Nairobi' => 'Africa/Nairobi', + 'Tehran' => 'Asia/Tehran', + 'Abu Dhabi, Muscat' => 'Asia/Muscat', // Best guess + 'Baku, Tbilisi, Yerevan' => 'Asia/Baku', + 'Kabul' => 'Asia/Kabul', + 'Ekaterinburg' => 'Asia/Yekaterinburg', + 'Islamabad, Karachi, Tashkent' => 'Asia/Karachi', + 'Kolkata, Chennai, Mumbai, New Delhi, India Standard Time' => 'Asia/Calcutta', + 'Kathmandu, Nepal' => 'Asia/Kathmandu', + 'Almaty, Novosibirsk, North Central Asia' => 'Asia/Almaty', + 'Astana, Dhaka' => 'Asia/Dhaka', + 'Sri Jayawardenepura, Sri Lanka' => 'Asia/Colombo', + 'Rangoon' => 'Asia/Rangoon', + 'Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok', + 'Krasnoyarsk' => 'Asia/Krasnoyarsk', + 'Beijing, Chongqing, Hong Kong SAR, Urumqi' => 'Asia/Shanghai', + 'Irkutsk, Ulaan Bataar' => 'Asia/Irkutsk', + 'Kuala Lumpur, Singapore' => 'Asia/Singapore', + 'Perth, Western Australia' => 'Australia/Perth', + 'Taipei' => 'Asia/Taipei', + 'Osaka, Sapporo, Tokyo' => 'Asia/Tokyo', + 'Seoul, Korea Standard time' => 'Asia/Seoul', + 'Yakutsk' => 'Asia/Yakutsk', + 'Adelaide, Central Australia' => 'Australia/Adelaide', + 'Darwin' => 'Australia/Darwin', + 'Brisbane, East Australia' => 'Australia/Brisbane', + 'Canberra, Melbourne, Sydney, Hobart (year 2000 only)' => 'Australia/Sydney', + 'Guam, Port Moresby' => 'Pacific/Guam', + 'Hobart, Tasmania' => 'Australia/Hobart', + 'Vladivostok' => 'Asia/Vladivostok', + 'Magadan, Solomon Is., New Caledonia' => 'Asia/Magadan', + 'Auckland, Wellington' => 'Pacific/Auckland', + 'Fiji Islands, Kamchatka, Marshall Is.' => 'Pacific/Fiji', + 'Nuku\'alofa, Tonga' => 'Pacific/Tongatapu', + 'Azores' => 'Atlantic/Azores', + 'Cape Verde Is.' => 'Atlantic/Cape_Verde', + 'Mid-Atlantic' => 'America/Noronha', + 'Brasilia' => 'America/Sao_Paulo', // Best guess + 'Buenos Aires' => 'America/Argentina/Buenos_Aires', + 'Greenland' => 'America/Godthab', + 'Newfoundland' => 'America/St_Johns', + 'Atlantic Time (Canada)' => 'America/Halifax', + 'Caracas, La Paz' => 'America/Caracas', + 'Santiago' => 'America/Santiago', + 'Bogota, Lima, Quito' => 'America/Bogota', + 'Eastern Time (US & Canada)' => 'America/New_York', + 'Indiana (East)' => 'America/Indiana/Indianapolis', + 'Central America' => 'America/Guatemala', + 'Central Time (US & Canada)' => 'America/Chicago', + 'Mexico City, Tegucigalpa' => 'America/Mexico_City', + 'Saskatchewan' => 'America/Edmonton', + 'Arizona' => 'America/Phoenix', + 'Mountain Time (US & Canada)' => 'America/Denver', // Best guess + 'Pacific Time (US & Canada); Tijuana' => 'America/Los_Angeles', // Best guess + 'Alaska' => 'America/Anchorage', + 'Hawaii' => 'Pacific/Honolulu', + 'Midway Island, Samoa' => 'Pacific/Midway', + 'Eniwetok, Kwajalein, Dateline Time' => 'Pacific/Kwajalein', + + // The following list are timezone names that could be generated by + // Lotus / Domino + 'Dateline' => 'Etc/GMT-12', + 'Samoa' => 'Pacific/Apia', + 'Hawaiian' => 'Pacific/Honolulu', + 'Alaskan' => 'America/Anchorage', + 'Pacific' => 'America/Los_Angeles', + 'Pacific Standard Time' => 'America/Los_Angeles', + 'Mexico Standard Time 2' => 'America/Chihuahua', + 'Mountain' => 'America/Denver', + 'Mountain Standard Time' => 'America/Chihuahua', + 'US Mountain' => 'America/Phoenix', + 'Canada Central' => 'America/Edmonton', + 'Central America' => 'America/Guatemala', + 'Central' => 'America/Chicago', + 'Central Standard Time' => 'America/Mexico_City', + 'Mexico' => 'America/Mexico_City', + 'Eastern' => 'America/New_York', + 'SA Pacific' => 'America/Bogota', + 'US Eastern' => 'America/Indiana/Indianapolis', + 'Venezuela' => 'America/Caracas', + 'Atlantic' => 'America/Halifax', + 'Central Brazilian' => 'America/Manaus', + 'Pacific SA' => 'America/Santiago', + 'SA Western' => 'America/La_Paz', + 'Newfoundland' => 'America/St_Johns', + 'Argentina' => 'America/Argentina/Buenos_Aires', + 'E. South America' => 'America/Belem', + 'Greenland' => 'America/Godthab', + 'Montevideo' => 'America/Montevideo', + 'SA Eastern' => 'America/Belem', + 'Mid-Atlantic' => 'Etc/GMT-2', + 'Azores' => 'Atlantic/Azores', + 'Cape Verde' => 'Atlantic/Cape_Verde', + 'Greenwich' => 'Atlantic/Reykjavik', // No I'm serious.. Greenwich is not GMT. + 'Morocco' => 'Africa/Casablanca', + 'Central Europe' => 'Europe/Prague', + 'Central European' => 'Europe/Sarajevo', + 'Romance' => 'Europe/Paris', + 'W. Central Africa' => 'Africa/Lagos', // Best guess + 'W. Europe' => 'Europe/Amsterdam', + 'E. Europe' => 'Europe/Minsk', + 'Egypt' => 'Africa/Cairo', + 'FLE' => 'Europe/Helsinki', + 'GTB' => 'Europe/Athens', + 'Israel' => 'Asia/Jerusalem', + 'Jordan' => 'Asia/Amman', + 'Middle East' => 'Asia/Beirut', + 'Namibia' => 'Africa/Windhoek', + 'South Africa' => 'Africa/Harare', + 'Arab' => 'Asia/Kuwait', + 'Arabic' => 'Asia/Baghdad', + 'E. Africa' => 'Africa/Nairobi', + 'Georgian' => 'Asia/Tbilisi', + 'Russian' => 'Europe/Moscow', + 'Iran' => 'Asia/Tehran', + 'Arabian' => 'Asia/Muscat', + 'Armenian' => 'Asia/Yerevan', + 'Azerbijan' => 'Asia/Baku', + 'Caucasus' => 'Asia/Yerevan', + 'Mauritius' => 'Indian/Mauritius', + 'Afghanistan' => 'Asia/Kabul', + 'Ekaterinburg' => 'Asia/Yekaterinburg', + 'Pakistan' => 'Asia/Karachi', + 'West Asia' => 'Asia/Tashkent', + 'India' => 'Asia/Calcutta', + 'Sri Lanka' => 'Asia/Colombo', + 'Nepal' => 'Asia/Kathmandu', + 'Central Asia' => 'Asia/Dhaka', + 'N. Central Asia' => 'Asia/Almaty', + 'Myanmar' => 'Asia/Rangoon', + 'North Asia' => 'Asia/Krasnoyarsk', + 'SE Asia' => 'Asia/Bangkok', + 'China' => 'Asia/Shanghai', + 'North Asia East' => 'Asia/Irkutsk', + 'Singapore' => 'Asia/Singapore', + 'Taipei' => 'Asia/Taipei', + 'W. Australia' => 'Australia/Perth', + 'Korea' => 'Asia/Seoul', + 'Tokyo' => 'Asia/Tokyo', + 'Yakutsk' => 'Asia/Yakutsk', + 'AUS Central' => 'Australia/Darwin', + 'Cen. Australia' => 'Australia/Adelaide', + 'AUS Eastern' => 'Australia/Sydney', + 'E. Australia' => 'Australia/Brisbane', + 'Tasmania' => 'Australia/Hobart', + 'Vladivostok' => 'Asia/Vladivostok', + 'West Pacific' => 'Pacific/Guam', + 'Central Pacific' => 'Asia/Magadan', + 'Fiji' => 'Pacific/Fiji', + 'New Zealand' => 'Pacific/Auckland', + 'Tonga' => 'Pacific/Tongatapu', + ); + + /** + * List of microsoft exchange timezone ids. + * + * Source: http://msdn.microsoft.com/en-us/library/aa563018(loband).aspx + */ + public static $microsoftExchangeMap = array( + 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. + 2 => 'Europe/Lisbon', + 1 => 'Europe/London', + 4 => 'Europe/Berlin', + 6 => 'Europe/Prague', + 3 => 'Europe/Paris', + 69 => 'Africa/Luanda', // This was a best guess + 7 => 'Europe/Athens', + 5 => 'Europe/Bucharest', + 49 => 'Africa/Cairo', + 50 => 'Africa/Harare', + 59 => 'Europe/Helsinki', + 27 => 'Asia/Jerusalem', + 26 => 'Asia/Baghdad', + 74 => 'Asia/Kuwait', + 51 => 'Europe/Moscow', + 56 => 'Africa/Nairobi', + 25 => 'Asia/Tehran', + 24 => 'Asia/Muscat', // Best guess + 54 => 'Asia/Baku', + 48 => 'Asia/Kabul', + 58 => 'Asia/Yekaterinburg', + 47 => 'Asia/Karachi', + 23 => 'Asia/Calcutta', + 62 => 'Asia/Kathmandu', + 46 => 'Asia/Almaty', + 71 => 'Asia/Dhaka', + 66 => 'Asia/Colombo', + 61 => 'Asia/Rangoon', + 22 => 'Asia/Bangkok', + 64 => 'Asia/Krasnoyarsk', + 45 => 'Asia/Shanghai', + 63 => 'Asia/Irkutsk', + 21 => 'Asia/Singapore', + 73 => 'Australia/Perth', + 75 => 'Asia/Taipei', + 20 => 'Asia/Tokyo', + 72 => 'Asia/Seoul', + 70 => 'Asia/Yakutsk', + 19 => 'Australia/Adelaide', + 44 => 'Australia/Darwin', + 18 => 'Australia/Brisbane', + 76 => 'Australia/Sydney', + 43 => 'Pacific/Guam', + 42 => 'Australia/Hobart', + 68 => 'Asia/Vladivostok', + 41 => 'Asia/Magadan', + 17 => 'Pacific/Auckland', + 40 => 'Pacific/Fiji', + 67 => 'Pacific/Tongatapu', + 29 => 'Atlantic/Azores', + 53 => 'Atlantic/Cape_Verde', + 30 => 'America/Noronha', + 8 => 'America/Sao_Paulo', // Best guess + 32 => 'America/Argentina/Buenos_Aires', + 60 => 'America/Godthab', + 28 => 'America/St_Johns', + 9 => 'America/Halifax', + 33 => 'America/Caracas', + 65 => 'America/Santiago', + 35 => 'America/Bogota', + 10 => 'America/New_York', + 34 => 'America/Indiana/Indianapolis', + 55 => 'America/Guatemala', + 11 => 'America/Chicago', + 37 => 'America/Mexico_City', + 36 => 'America/Edmonton', + 38 => 'America/Phoenix', + 12 => 'America/Denver', // Best guess + 13 => 'America/Los_Angeles', // Best guess + 14 => 'America/Anchorage', + 15 => 'Pacific/Honolulu', + 16 => 'Pacific/Midway', + 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 + */ + static public function getTimeZone($tzid, Component $vcalendar = null, $failIfUncertain = false) { + + // First we will just see if the tzid is a support timezone identifier. + try { + return new \DateTimeZone($tzid); + } catch (\Exception $e) { + } + + // Next, we check if the tzid is somewhere in our tzid map. + if (isset(self::$map[$tzid])) { + return new \DateTimeZone(self::$map[$tzid]); + } + + // 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)) { + return new \DateTimeZone('Etc/GMT' . $matches[1] . ltrim(substr($matches[2],0,2),'0')); + } + + 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 (substr($lic,0,8)==='SystemV/') { + $lic = substr($lic,8); + } + + try { + return new \DateTimeZone($lic); + } catch (\Exception $e) { + } + + } + // 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'}->value; + + // 2 can mean both Europe/Lisbon and Europe/Sarajevo. + if ($cdoId===2 && strpos((string)$vtimezone->TZID, 'Sarajevo')!==false) { + 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()); + + } + +} diff --git a/plugins/libcalendaring/lib/Sabre/VObject/Version.php b/plugins/libcalendaring/lib/Sabre/VObject/Version.php new file mode 100644 index 00000000..373980ee --- /dev/null +++ b/plugins/libcalendaring/lib/Sabre/VObject/Version.php @@ -0,0 +1,24 @@ +//' $SRCDIR/String.php -echo "\n" -sed 's///' $SRCDIR/iCalendar.php | sed -E "s/include_once.+//; s/NLS::getCharset\(\)/'UTF-8'/" -echo "\n" - -for fn in `ls $SRCDIR/iCalendar/*.php | grep -v 'vcard.php'`; do - sed 's///' $fn | sed -E "s/(include|require)_once.+//" -done; diff --git a/plugins/libcalendaring/lib/get_sabre_vobject.sh b/plugins/libcalendaring/lib/get_sabre_vobject.sh new file mode 100755 index 00000000..6cff8d20 --- /dev/null +++ b/plugins/libcalendaring/lib/get_sabre_vobject.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +# Download and install the Sabre\Vobject library for this plugin + +wget 'https://github.com/fruux/sabre-vobject/archive/2.1.0.tar.gz' -O sabre-vobject-2.1.0.tar.gz +tar xf sabre-vobject-2.1.0.tar.gz + +mv sabre-vobject-2.1.0/lib/* . +rm -rf sabre-vobject-2.1.0 + diff --git a/plugins/libcalendaring/lib/sabre-vobject-2.1.0.tar.gz b/plugins/libcalendaring/lib/sabre-vobject-2.1.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..339a992b229dcbfcd037bccf528e0dfae3d56643 GIT binary patch literal 86355 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[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 * @author Thomas Bruederli - * @author Bogomil "Bogo" Shopov * - * Copyright (C) 2010, Lazlo Westerhof * Copyright (C) 2013, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify @@ -25,550 +21,703 @@ * along with this program. If not, see . */ +use \Sabre\VObject; /** * Class to parse and build vCalendar (iCalendar) files * - * Uses the Horde:iCalendar class for parsing. To install: - * > pear channel-discover pear.horde.org - * > pear install horde/Horde_Icalendar + * Uses the SabreTooth VObject library, version 2.1. + * + * Download from https://github.com/fruux/sabre-vobject/archive/2.1.0.zip + * and place the lib files in this plugin's lib directory * */ class libvcalendar { - const EOL = "\r\n"; - - private $timezone; + private $timezone; + private $attach_uri = null; + private $prodid = '-//Roundcube//Roundcube libcalendaring//Sabre//Sabre VObject//EN'; + private $type_component_map = array('event' => 'VEVENT', 'task' => 'VTODO'); + private $attendee_keymap = array('name' => 'CN', 'status' => 'PARTSTAT', 'role' => 'ROLE', 'cutype' => 'CUTYPE', 'rsvp' => 'RSVP'); - public $method; - public $events = array(); + public $method; + public $objects = array(); - function __construct($timezone = null) - { - $this->timezone = $timezone ? $timezone : new DateTimezone('UTC'); - } - - /** - * Import events from iCalendar format - * - * @param string vCalendar input - * @param string Input charset (from envelope) - * @return array List of events extracted from the input - */ - public function import($vcal, $charset = RCMAIL_CHARSET) - { - $parser = $this->get_parser(); - $parser->parsevCalendar($vcal, 'VCALENDAR', $charset); - $this->method = $parser->getAttributeDefault('METHOD', ''); - $this->events = $seen = array(); - if ($data = $parser->getComponents()) { - foreach ($data as $comp) { - if ($comp->getType() == 'vEvent') { - $event = $this->_to_rcube_format($comp); - if (!$seen[$event['uid']]++) - $this->events[] = $event; + /** + * Default constructor + */ + function __construct($tz = null) + { + // load Sabre\VObject classes + if (!class_exists('\Sabre\VObject\Reader')) { + require_once(__DIR__ . '/lib/Sabre/VObject/includes.php'); } - } + + $this->timezone = $tz ? $tz : new DateTimezone('UTC'); + $this->prodid = '-//Roundcube//Roundcube libcalendaring ' . RCUBE_VERSION . '//Sabre//Sabre VObject ' . VObject\Version::VERSION . '//EN'; } - - return $this->events; - } - /** - * Read iCalendar events from a file - * - * @param string File path to read from - * @return array List of events extracted from the file - */ - public function import_from_file($filepath) - { - $this->events = $seen = array(); - $fp = fopen($filepath, 'r'); - - // check file content first - $begin = fread($fp, 1024); - if (!preg_match('/BEGIN:VCALENDAR/i', $begin)) - return $this->events; - - $parser = $this->get_parser(); - $buffer = ''; - - fseek($fp, 0); - while (($line = fgets($fp, 2048)) !== false) { - $buffer .= $line; - if (preg_match('/END:VEVENT/i', $line)) { - if (preg_match('/BEGIN:VCALENDAR/i', $buffer)) - $buffer .= self::EOL ."END:VCALENDAR"; - $parser->parsevCalendar($buffer, 'VCALENDAR', RCMAIL_CHARSET, false); - $buffer = ''; - } + /** + * Setter for timezone information + */ + public function set_timezone($tz) + { + $this->timezone = $tz; } - fclose($fp); - if ($data = $parser->getComponents()) { - foreach ($data as $comp) { - if ($comp->getType() == 'vEvent') { - $event = $this->_to_rcube_format($comp); - if (!$seen[$event['uid']]++) - $this->events[] = $event; + /** + * Setter for URI template for attachment links + */ + public function set_attach_uri($uri) + { + $this->attach_uri = $uri; + } + + /** + * Setter for a custom PRODID attribute + */ + public function set_prodid($prodid) + { + $this->prodid = $prodid; + } + + /** + * Free resources by clearing member vars + */ + public function reset() + { + $this->method = ''; + $this->objects = array(); + } + + /** + * Import events from iCalendar format + * + * @param string vCalendar input + * @param string Input charset (from envelope) + * @return array List of events extracted from the input + */ + public function import($vcal, $charset = 'UTF-8') + { + // TODO: convert charset to UTF-8 if other + + try { + $vobject = VObject\Reader::read($vcal, VObject\Reader::OPTION_FORGIVING | VObject\Reader::OPTION_IGNORE_INVALID_LINES); + if ($vobject) + return $this->import_from_vobject($vobject); } - } + catch (Exception $e) { + throw $e; + rcube::raise_error(array( + 'code' => 600, 'type' => 'php', + 'file' => __FILE__, 'line' => __LINE__, + 'message' => "iCal data parse error: " . $e->getMessage()), + true, false); + } + + return array(); } - $this->method = $parser->getAttributeDefault('METHOD', ''); + /** + * Read iCalendar events from a file + * + * @param string File path to read from + * @return array List of events extracted from the file + */ + public function import_from_file($filepath) + { + $this->objects = array(); + $fp = fopen($filepath, 'r'); - return $this->events; - } + // check file content first + $begin = fread($fp, 1024); + if (!preg_match('/BEGIN:VCALENDAR/i', $begin)) { + return $this->objects; + } + fclose($fp); - /** - * Load iCal parser from the Horde lib - */ - public function get_parser() - { - if (!class_exists('Horde_iCalendar')) - require_once(__DIR__ . '/lib/Horde_iCalendar.php'); - - // set target charset for parsed events - $GLOBALS['_HORDE_STRING_CHARSET'] = RCMAIL_CHARSET; - - return new Horde_iCalendar; - } - - /** - * Convert the given File_IMC_Parse_Vcalendar_Event object to the internal event format - */ - private function _to_rcube_format($ve) - { - $event = array( - 'uid' => $ve->getAttributeDefault('UID'), - 'changed' => $this->_date2time($ve->getAttributeDefault('DTSTAMP', 0)), - 'title' => $ve->getAttributeDefault('SUMMARY'), - 'start' => $this->_date2time($ve->getAttribute('DTSTART')), - 'end' => $this->_date2time($ve->getAttribute('DTEND')), - // set defaults - 'free_busy' => 'busy', - 'priority' => 0, - 'attendees' => array(), - ); - - // check for all-day dates - if (is_array($ve->getAttribute('DTSTART'))) - $event['allday'] = true; - - if ($event['allday']) - $event['end']->sub(new DateInterval('PT23H')); - - // assign current timezone to event start/end - if (is_a($event['start'], 'DateTime')) - $event['start']->setTimezone($this->timezone); - else - unset($event['start']); - - if (is_a($event['end'], 'DateTime')) - $event['end']->setTimezone($this->timezone); - else - unset($event['end']); - - // map other attributes to internal fields - $_attendees = array(); - foreach ($ve->getAllAttributes() as $attr) { - switch ($attr['name']) { - case 'ORGANIZER': - $organizer = array( - 'name' => $attr['params']['CN'], - 'email' => preg_replace('/^mailto:/i', '', $attr['value']), - 'role' => 'ORGANIZER', - 'status' => 'ACCEPTED', - ); - if (isset($_attendees[$organizer['email']])) { - $i = $_attendees[$organizer['email']]; - $event['attendees'][$i]['role'] = $organizer['role']; - } - break; - - case 'ATTENDEE': - $attendee = array( - 'name' => $attr['params']['CN'], - 'email' => preg_replace('/^mailto:/i', '', $attr['value']), - 'role' => $attr['params']['ROLE'] ? $attr['params']['ROLE'] : 'REQ-PARTICIPANT', - 'status' => $attr['params']['PARTSTAT'], - 'rsvp' => $attr['params']['RSVP'] == 'TRUE', - ); - if ($organizer && $organizer['email'] == $attendee['email']) - $attendee['role'] = 'ORGANIZER'; - - $event['attendees'][] = $attendee; - $_attendees[$attendee['email']] = count($event['attendees']) - 1; - break; - - case 'TRANSP': - $event['free_busy'] = $attr['value'] == 'TRANSPARENT' ? 'free' : 'busy'; - break; - - case 'STATUS': - if ($attr['value'] == 'TENTATIVE') - $event['free_busy'] = 'tentative'; - else if ($attr['value'] == 'CANCELLED') - $event['cancelled'] = true; - break; - - case 'PRIORITY': - if (is_numeric($attr['value'])) { - $event['priority'] = $attr['value']; - } - break; - - case 'RRULE': - // parse recurrence rule attributes - foreach (explode(';', $attr['value']) as $par) { - list($k, $v) = explode('=', $par); - $params[$k] = $v; - } - if ($params['UNTIL']) - $params['UNTIL'] = date_create($params['UNTIL']); - if (!$params['INTERVAL']) - $params['INTERVAL'] = 1; - - $event['recurrence'] = $params; - break; - - case 'EXDATE': - $event['recurrence']['EXDATE'][] = $this->_date2time($attr['value']); - break; - - case 'RECURRENCE-ID': - $event['recurrence_id'] = $this->_date2time($attr['value']); - break; - - case 'SEQUENCE': - $event['sequence'] = intval($attr['value']); - break; - - case 'CATEGORIES': - case 'DESCRIPTION': - case 'LOCATION': - case 'URL': - $event[strtolower($attr['name'])] = $attr['value']; - break; - - case 'CLASS': - case 'X-CALENDARSERVER-ACCESS': - $event['sensitivity'] = strtolower($attr['value']); - break; - - case 'X-MICROSOFT-CDO-BUSYSTATUS': - if ($attr['value'] == 'OOF') - $event['free_busy'] == 'outofoffice'; - else if (in_array($attr['value'], array('FREE', 'BUSY', 'TENTATIVE'))) - $event['free_busy'] = strtolower($attr['value']); - break; - - case 'ATTACH': - // decode inline attachment - if (strtoupper($attr['params']['VALUE']) == 'BINARY' && !empty($attr['value'])) { - $data = !strcasecmp($attr['params']['ENCODING'], 'BASE64') ? base64_decode($attr['value']) : $attr['value']; - $mimetype = $attr['params']['FMTTYPE'] ? $attr['params']['FMTTYPE'] : rcube_mime::file_content_type($data, $attr['params']['X-LABEL'], 'application/octet-stream', true); - $extensions = rcube_mime::get_mime_extensions($mimetype); - $filename = $attr['params']['X-LABEL'] ? $attr['params']['X-LABEL'] : 'attachment' . count($event['attachments']) . '.' . $extensions[0]; - $event['attachments'][] = array( - 'mimetype' => $mimetype, - 'name' => $filename, - 'data' => $data, - 'size' => strlen($data), - ); - } - else if (!empty($attr['value']) && preg_match('!^[hftps]+://!', $attr['value'])) { - // TODO: add support for displaying/managing link attachments in UI - $event['links'][] = $attr['value']; - } - break; - - default: - if (substr($attr['name'], 0, 2) == 'X-') - $event['x-custom'][] = array($attr['name'], $attr['value']); - } + return $this->import(file_get_contents($filepath)); } - // find alarms - if ($valarm = $ve->findComponent('valarm')) { - $action = 'DISPLAY'; - $trigger = null; - - foreach ($valarm->getAllAttributes() as $attr) { - switch ($attr['name']) { - case 'TRIGGER': - if ($attr['params']['VALUE'] == 'DATE-TIME') { - $trigger = '@' . $attr['value']; + /** + * Import objects from an already parsed Sabre\VObject\Component object + * + * @param object Sabre\VObject\Component to read from + * @return array List of events extracted from the file + */ + public function import_from_vobject($vobject) + { + $this->objects = $seen = array(); + + if ($vobject->name == 'VCALENDAR') { + $this->method = strval($vobject->METHOD); + + foreach ($vobject->getBaseComponents() as $ve) { + if ($ve->name == 'VEVENT' || $ve->name == 'VTODO') { + // convert to hash array representation + $object = $this->_to_array($ve); + + if (!$seen[$object['uid']]++) { + // parse recurrence exceptions + if ($object['recurrence']) { + foreach ($vobject->children as $i => $component) { + if ($component->name == 'VEVENT' && isset($component->{'RECURRENCE-ID'})) { + $object['recurrence']['EXCEPTIONS'][] = $this->_to_array($component); + } + } + } + + $this->objects[] = $object; + } + } } - else { - $trigger = $attr['value']; - $offset = abs($trigger); - $unit = 'S'; - if ($offset % 86400 == 0) { - $unit = 'D'; - $trigger = intval($trigger / 86400); - } - else if ($offset % 3600 == 0) { - $unit = 'H'; - $trigger = intval($trigger / 3600); - } - else if ($offset % 60 == 0) { - $unit = 'M'; - $trigger = intval($trigger / 60); - } - } - break; - - case 'ACTION': - $action = $attr['value']; - break; } - } - if ($trigger) - $event['alarms'] = $trigger . $unit . ':' . $action; + + return $this->objects; } - // add organizer to attendees list if not already present - if ($organizer && !isset($_attendees[$organizer['email']])) - array_unshift($event['attendees'], $organizer); + /** + * Convert the given VEvent object to a libkolab compatible array representation + * + * @param object Vevent object to convert + * @return array Hash array with object properties + */ + private function _to_array($ve) + { + $event = array( + 'uid' => strval($ve->UID), + 'title' => strval($ve->SUMMARY), + 'created' => $ve->CREATED ? $ve->CREATED->getDateTime() : null, + 'changed' => null, + '_type' => $ve->name == 'VTODO' ? 'task' : 'event', + // set defaults + 'free_busy' => 'busy', + 'priority' => 0, + 'attendees' => array(), + ); - // make sure the event has an UID -# if (!$event['uid']) -# $event['uid'] = $this->cal->generate_uid(); - - return $event; - } - - /** - * Helper method to correctly interpret an all-day date value - */ - private function _date2time($prop) - { - // create timestamp at 12:00 in user's timezone - if (is_array($prop)) - return date_create(sprintf('%04d%02d%02dT120000', $prop['year'], $prop['month'], $prop['mday']), $this->timezone); - else if (is_numeric($prop)) - return date_create('@'.$prop); - - return $prop; - } - - - /** - * Free resources by clearing member vars - */ - public function reset() - { - $this->method = ''; - $this->events = array(); - } - - /** - * Export events to iCalendar format - * - * @param array Events as array - * @param string VCalendar method to advertise - * @param boolean Directly send data to stdout instead of returning - * @param callable Callback function to fetch attachment contents, false if no attachment export - * @return string Events in iCalendar format (http://tools.ietf.org/html/rfc5545) - */ - public function export($events, $method = null, $write = false, $get_attachment = false, $recurrence_id = null) - { - $memory_limit = parse_bytes(ini_get('memory_limit')); - - if (!$recurrence_id) { - $ical = "BEGIN:VCALENDAR" . self::EOL; - $ical .= "VERSION:2.0" . self::EOL; - $ical .= "PRODID:-//Roundcube Webmail " . RCMAIL_VERSION . "//NONSGML Calendar//EN" . self::EOL; - $ical .= "CALSCALE:GREGORIAN" . self::EOL; - - if ($method) - $ical .= "METHOD:" . strtoupper($method) . self::EOL; - - if ($write) { - echo $ical; - $ical = ''; + if ($ve->{'LAST-MODIFIED'}) { + $event['changed'] = $ve->{'LAST-MODIFIED'}->getDateTime(); + } + else if ($ve->DTSTAMP) { + $event['changed'] = $ve->DTSTAMP->getDateTime(); } - } - foreach ($events as $event) { - $vevent = "BEGIN:VEVENT" . self::EOL; - $vevent .= "UID:" . self::escape($event['uid']) . self::EOL; - $vevent .= $this->format_datetime("DTSTAMP", $event['changed'] ?: new DateTime(), false, true) . self::EOL; - if ($event['sequence']) - $vevent .= "SEQUENCE:" . intval($event['sequence']) . self::EOL; - if ($recurrence_id) - $vevent .= $recurrence_id . self::EOL; + // map other attributes to internal fields + $_attendees = array(); + foreach ($ve->children as $prop) { + if (!($prop instanceof VObject\Property)) + continue; + + switch ($prop->name) { + case 'DTSTART': + case 'DTEND': + case 'DUE': + $propmap = array('DTSTART' => 'start', 'DTEND' => 'end', 'DUE' => 'due'); + $event[$propmap[$prop->name]] = self::convert_datetime($prop); + break; + + case 'TRANSP': + $event['free_busy'] = $prop->value == 'TRANSPARENT' ? 'free' : 'busy'; + break; + + case 'STATUS': + if ($prop->value == 'TENTATIVE') + $event['free_busy'] = 'tentative'; + else if ($prop->value == 'CANCELLED') + $event['cancelled'] = true; + else if ($prop->value == 'COMPLETED') + $event['complete'] = 100; + break; + + case 'PRIORITY': + if (is_numeric($prop->value)) + $event['priority'] = $prop->value; + break; + + case 'RRULE': + $params = array(); + // parse recurrence rule attributes + foreach (explode(';', $prop->value) as $par) { + list($k, $v) = explode('=', $par); + $params[$k] = $v; + } + if ($params['UNTIL']) + $params['UNTIL'] = date_create($params['UNTIL']); + if (!$params['INTERVAL']) + $params['INTERVAL'] = 1; + + $event['recurrence'] = $params; + break; + + case 'EXDATE': + $event['recurrence']['EXDATE'] = array_merge((array)$event['recurrence']['EXDATE'], (array)self::convert_datetime($prop)); + break; + + case 'RECURRENCE-ID': + // $event['recurrence_id'] = self::convert_datetime($prop); + break; + + case 'RELATED-TO': + if ($prop->offsetGet('RELTYPE') == 'PARENT') { + $event['parent_id'] = $prop->value; + } + break; + + case 'SEQUENCE': + $event['sequence'] = intval($prop->value); + break; + + case 'PERCENT-COMPLETE': + $event['complete'] = intval($prop->value); + break; + + case 'DESCRIPTION': + case 'LOCATION': + case 'URL': + $event[strtolower($prop->name)] = $prop->value; + break; + + case 'CATEGORY': + case 'CATEGORIES': + $event['categories'] = $prop->getParts(); + break; + + case 'CLASS': + case 'X-CALENDARSERVER-ACCESS': + $event['sensitivity'] = strtolower($prop->value); + break; + + case 'X-MICROSOFT-CDO-BUSYSTATUS': + if ($prop->value == 'OOF') + $event['free_busy'] == 'outofoffice'; + else if (in_array($prop->value, array('FREE', 'BUSY', 'TENTATIVE'))) + $event['free_busy'] = strtolower($prop->value); + break; + + case 'ATTENDEE': + case 'ORGANIZER': + $params = array(); + foreach ($prop->parameters as $param) { + switch ($param->name) { + case 'RSVP': $params[$param->name] = strtolower($param->value) == 'true'; break; + default: $params[$param->name] = $param->value; break; + } + } + $attendee = self::map_keys($params, array_flip($this->attendee_keymap)); + $attendee['email'] = preg_replace('/^mailto:/i', '', $prop->value); + + if ($prop->name == 'ORGANIZER') { + $attendee['role'] = 'ORGANIZER'; + $attendee['status'] = 'ACCEPTED'; + $event['organizer'] = $attendee; + } + else if ($attendee['email'] != $event['organizer']['email']) { + $event['attendees'][] = $attendee; + } + break; + + case 'ATTACH': + $params = self::parameters_array($prop); + if (substr($prop->value, 0, 4) == 'http' && !strpos($prop->value, ':attachment:')) { + $event['links'][] = $prop->value; + } + else if (strlen($prop->value) && strtoupper($params['VALUE']) == 'BINARY') { + $attachment = self::map_keys($params, array('FMTTYPE' => 'mimetype', 'X-LABEL' => 'name')); + $attachment['data'] = base64_decode($prop->value); + $attachment['size'] = strlen($attachment['content']); + $event['attachments'][] = $attachment; + } + break; + + default: + if (substr($prop->name, 0, 2) == 'X-') + $event['x-custom'][] = array($prop->name, strval($prop->value)); + break; + } + } + + // check DURATION property if no end date is set + if (empty($event['end']) && $ve->DURATION) { + try { + $duration = new DateInterval(strval($ve->DURATION)); + $end = clone $event['start']; + $end->add($duration); + $event['end'] = $end; + } + catch (\Exception $e) { + trigger_error(strval($e), E_USER_WARNING); + } + } + + // check for all-day dates + if ($event['start']->_dateonly) { + $event['allday'] = true; + } + + // shift end-date by one day (except Thunderbird) + if ($event['allday'] && is_object($event['end'])) { + $event['end']->sub(new \DateInterval('PT23H')); + } + + // sanity-check and fix end date + if (empty($event['end'])) { + $event['end'] = clone $event['start']; + } + else if ($event['end'] < $event['start']) { + $event['end'] = clone $event['start']; + } + + // make organizer part of the attendees list for compatibility reasons + if (!empty($event['organizer']) && is_array($event['attendees'])) { + array_unshift($event['attendees'], $event['organizer']); + } + + // find alarms + if ($valarms = $ve->select('VALARM')) { + $action = 'DISPLAY'; + $trigger = null; + + $valarm = reset($valarms); + foreach ($valarm->children as $prop) { + switch ($prop->name) { + case 'TRIGGER': + foreach ($prop->parameters as $param) { + if ($param->name == 'VALUE' && $param->value == 'DATE-TIME') { + $trigger = '@' . $prop->getDateTime()->format('U'); + } + } + if (!$trigger) { + $trigger = preg_replace('/PT/', '', $prop->value); + } + break; + + case 'ACTION': + $action = $prop->value; + break; + } + } + + if ($trigger) + $event['alarms'] = $trigger . ':' . $action; + } - // correctly set all-day dates - if ($event['allday']) { - $event['end'] = clone $event['end']; - $event['end']->add(new DateInterval('P1D')); // ends the next day - $vevent .= $this->format_datetime("DTSTART", $event['start'], true) . self::EOL; - $vevent .= $this->format_datetime("DTEND", $event['end'], true) . self::EOL; + // assign current timezone to event start/end + if ($event['start'] instanceof DateTime) { + $event['start']->setTimezone($this->timezone); } else { - $vevent .= $this->format_datetime("DTSTART", $event['start'], false) . self::EOL; - $vevent .= $this->format_datetime("DTEND", $event['end'], false) . self::EOL; - } - $vevent .= "SUMMARY:" . self::escape($event['title']) . self::EOL; - $vevent .= "DESCRIPTION:" . self::escape($event['description']) . self::EOL; - - if (!empty($event['attendees'])){ - $vevent .= $this->_get_attendees($event['attendees']); + unset($event['start']); } - if (!empty($event['location'])) { - $vevent .= "LOCATION:" . self::escape($event['location']) . self::EOL; + if ($event['end'] instanceof DateTime) { + $event['end']->setTimezone($this->timezone); } - if (!empty($event['url'])) { - $vevent .= "URL:" . self::escape($event['url']) . self::EOL; + else { + unset($event['end']); } + + // minimal validation + if (empty($event['uid']) || empty($event['start']) != empty($event['end'])) { + throw new VObject\ParseException('Object validation failed: missing mandatory object properties'); + } + + return $event; + } + + /** + * Helper method to correctly interpret an all-day date value + */ + public static function convert_datetime($prop) + { + if (empty($prop)) { + return null; + } + else if ($prop instanceof VObject\Property\MultiDateTime) { + $dt = array(); + $dateonly = ($prop->getDateType() & VObject\Property\DateTime::DATE); + foreach ($prop->getDateTimes() as $item) { + $item->_dateonly = $dateonly; + $dt[] = $item; + } + } + else if ($prop instanceof VObject\Property\DateTime) { + $dt = $prop->getDateTime(); + if ($prop->getDateType() & VObject\Property\DateTime::DATE) { + $dt->_dateonly = true; + } + } + else if ($prop instanceof DateTime) { + $dt = $prop; + } + + return $dt; + } + + + /** + * Create a Sabre\VObject\Property instance from a PHP DateTime object + * + * @param string Property name + * @param object DateTime + */ + public static function datetime_prop($name, $dt, $utc = false) + { + $vdt = new VObject\Property\DateTime($name); + $vdt->setDateTime($dt, $dt->_dateonly ? VObject\Property\DateTime::DATE : + ($utc ? VObject\Property\DateTime::UTC : VObject\Property\DateTime::LOCALTZ)); + return $vdt; + } + + /** + * Copy values from one hash array to another using a key-map + */ + public static function map_keys($values, $map) + { + $out = array(); + foreach ($map as $from => $to) { + if (isset($values[$from])) + $out[$to] = $values[$from]; + } + return $out; + } + + /** + * + */ + private static function parameters_array($prop) + { + $params = array(); + foreach ($prop->parameters as $param) { + $params[strtoupper($param->name)] = $param->value; + } + return $params; + } + + + /** + * Export events to iCalendar format + * + * @param array Events as array + * @param string VCalendar method to advertise + * @param boolean Directly send data to stdout instead of returning + * @param callable Callback function to fetch attachment contents, false if no attachment export + * @return string Events in iCalendar format (http://tools.ietf.org/html/rfc5545) + */ + public function export($objects, $method = null, $write = false, $get_attachment = false, $recurrence_id = null) + { + $memory_limit = parse_bytes(ini_get('memory_limit')); + + // encapsulate in VCALENDAR container + $vcal = VObject\Component::create('VCALENDAR'); + $vcal->version = '2.0'; + $vcal->prodid = $this->prodid; + $vcal->calscale = 'GREGORIAN'; + + if (!empty($method)) { + $vcal->METHOD = $method; + } + + // TODO: include timezone information + + // write vcalendar header + if ($write) { + echo preg_replace('/END:VCALENDAR[\r\n]*$/m', '', $vcal->serialize()); + } + + foreach ($objects as $object) { + $this->_to_ical($object, !$write?$vcal:false, $get_attachment); + } + + if ($write) { + echo "END:VCALENDAR\r\n"; + return true; + } + else { + return $vcal->serialize(); + } + } + + /** + * Build a valid iCal format block from the given event + * + * @param array Hash array with event/task properties from libkolab + * @param object VCalendar object to append event to or false for directly sending data to stdout + * @param callable Callback function to fetch attachment contents, false if no attachment export + * @param object RECURRENCE-ID property when serializing a recurrence exception + */ + private function _to_ical($event, $vcal, $get_attachment, $recurrence_id = null) + { + $type = $event['_type'] ?: 'event'; + $ve = VObject\Component::create($this->type_component_map[$type]); + $ve->add('UID', $event['uid']); + + // all-day events end the next day + if ($event['allday'] && !empty($event['end'])) { + $event['end'] = clone $event['end']; + $event['end']->add(new \DateInterval('P1D')); + $event['end']->_dateonly = true; + } + if (!empty($event['created'])) + $ve->add(self::datetime_prop('CREATED', $event['created'], true)); + if (!empty($event['changed'])) + $ve->add(self::datetime_prop('DTSTAMP', $event['changed'], true)); + if (!empty($event['start'])) + $ve->add(self::datetime_prop('DTSTART', $event['start'], false)); + if (!empty($event['end'])) + $ve->add(self::datetime_prop('DTEND', $event['end'], false)); + if (!empty($event['due'])) + $ve->add(self::datetime_prop('DUE', $event['due'], false)); + + if ($recurrence_id) + $ve->add($recurrence_id); + + $ve->add('SUMMARY', $event['title']); + + if ($event['location']) + $ve->add('LOCATION', $event['location']); + if ($event['description']) + $ve->add('DESCRIPTION', strtr($event['description'], array("\r\n" => "\n", "\r" => "\n"))); // normalize line endings + + if ($event['sequence']) + $ve->add('SEQUENCE', $event['sequence']); + if ($event['recurrence'] && !$recurrence_id) { - $vevent .= "RRULE:" . libcalendaring::to_rrule($event['recurrence'], self::EOL) . self::EOL; - - foreach ((array)$event['recurrence']['EXDATE'] as $ex) { - $vevent .= $this->format_datetime("EXDATE", $ex, false, true) . self::EOL; - } + if ($exdates = $event['recurrence']['EXDATE']) { + unset($event['recurrence']['EXDATE']); // don't serialize EXDATEs into RRULE value + } + + $ve->add('RRULE', libcalendaring::to_rrule($event['recurrence'])); + + // add EXDATEs each one per line (for Thunderbird Lightning) + if ($exdates) { + foreach ($exdates as $ex) { + if ($ex instanceof \DateTime) { + $exd = clone $event['start']; + $exd->setDate($ex->format('Y'), $ex->format('n'), $ex->format('j')); + $exd->setTimeZone(new \DateTimeZone('UTC')); + $ve->add(new VObject\Property('EXDATE', $exd->format('Ymd\\THis\\Z'))); + } + } + } } - if (!empty($event['categories'])) { - $vevent .= "CATEGORIES:" . self::escape($event['categories']) . self::EOL; + + if ($event['categories']) { + $cat = VObject\Property::create('CATEGORIES'); + $cat->setParts((array)$event['categories']); + $ve->add($cat); } - if (!empty($event['sensitivity']) && $event['sensitivity'] != 'public') { - $vevent .= "CLASS:" . strtoupper($event['sensitivity']) . self::EOL; - } - if ($event['alarms']) { - list($trigger, $action) = explode(':', $event['alarms']); - $val = libcalendaring::parse_alaram_value($trigger); - - $vevent .= "BEGIN:VALARM\n"; - if ($val[1]) $vevent .= "TRIGGER:" . preg_replace('/^([-+])(.+)/', '\\1PT\\2', $trigger) . self::EOL; - else $vevent .= "TRIGGER;VALUE=DATE-TIME:" . gmdate('Ymd\THis\Z', $val[0]) . self::EOL; - if ($action) $vevent .= "ACTION:" . self::escape(strtoupper($action)) . self::EOL; - $vevent .= "END:VALARM\n"; - } - - $vevent .= "TRANSP:" . ($event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE') . self::EOL; - - if ($event['priority']) { - $vevent .= "PRIORITY:" . $event['priority'] . self::EOL; - } - + + $ve->add('TRANSP', $event['free_busy'] == 'free' ? 'TRANSPARENT' : 'OPAQUE'); + + if ($event['priority']) + $ve->add('PRIORITY', $event['priority']); + if ($event['cancelled']) - $vevent .= "STATUS:CANCELLED" . self::EOL; + $ve->add('STATUS', 'CANCELLED'); else if ($event['free_busy'] == 'tentative') - $vevent .= "STATUS:TENTATIVE" . self::EOL; - - foreach ((array)$event['x-custom'] as $prop) - $vevent .= $prop[0] . ':' . self::escape($prop[1]) . self::EOL; - - // export attachments using the given callback function - if (is_callable($get_attachment) && !empty($event['attachments'])) { - foreach ((array)$event['attachments'] as $attach) { - // check available memory and skip attachment export if we can't buffer it - if ($memory_limit > 0 && ($memory_used = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024) - && $attach['size'] && $memory_used + $attach['size'] * 3 > $memory_limit) { + $ve->add('STATUS', 'TENTATIVE'); + else if ($event['complete'] == 100) + $ve->add('STATUS', 'COMPLETED'); + + if (!empty($event['sensitivity'])) + $ve->add('CLASS', strtoupper($event['sensitivity'])); + + if (isset($event['complete'])) { + $ve->add('PERCENT-COMPLETE', intval($event['complete'])); + // Apple iCal required the COMPLETED date to be set in order to consider a task complete + if ($event['complete'] == 100) + $ve->add(self::datetime_prop('COMPLETED', $event['changed'] ?: new DateTime('now - 1 hour'), true)); + } + + if ($event['alarms']) { + $va = VObject\Component::create('VALARM'); + list($trigger, $va->action) = explode(':', $event['alarms']); + $val = libcalendaring::parse_alaram_value($trigger); + if ($val[1]) $va->add('TRIGGER', preg_replace('/^([-+])(.+)/', '\\1PT\\2', $trigger)); + else $va->add('TRIGGER', gmdate('Ymd\THis\Z', $val[0]), array('VALUE' => 'DATE-TIME')); + $ve->add($va); + } + + foreach ((array)$event['attendees'] as $attendee) { + if ($attendee['role'] == 'ORGANIZER') { + if (empty($event['organizer'])) + $event['organizer'] = $attendee; continue; } - // TODO: let the callback print the data directly to stdout (with b64 encoding) - if ($data = call_user_func($get_attachment, $attach['id'], $event)) { - $vevent .= sprintf('ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=%s;X-LABEL=%s:', - self::escape($attach['mimetype']), self::escape($attach['name'])); - $vevent .= base64_encode($data) . self::EOL; - } - unset($data); // attempt to free memory - } + $attendee['rsvp'] = $attendee['rsvp'] ? 'TRUE' : null; + $ve->add('ATTENDEE', 'mailto:' . $attendee['email'], self::map_keys($attendee, $this->attendee_keymap)); + } + + if ($event['organizer']) { + $ve->add('ORGANIZER', 'mailto:' . $event['organizer']['email'], self::map_keys($event['organizer'], array('name' => 'CN'))); + } + + foreach ((array)$event['url'] as $url) { + $ve->add('URL', $url); + } + + if (!empty($event['parent_id'])) { + $ve->add('RELATED-TO', $event['parent_id'], array('RELTYPE' => 'PARENT')); + } + + // export attachments + if (!empty($event['attachments'])) { + foreach ((array)$event['attachments'] as $attach) { + // check available memory and skip attachment export if we can't buffer it + if (is_callable($get_attachment) && $memory_limit > 0 && ($memory_used = function_exists('memory_get_usage') ? memory_get_usage() : 16*1024*1024) + && $attach['size'] && $memory_used + $attach['size'] * 3 > $memory_limit) { + continue; + } + // embed attachments using the given callback function + if (is_callable($get_attachment) && ($data = call_user_func($get_attachment, $attach['id'], $event))) { + // embed attachments for iCal + $ve->add('ATTACH', + base64_encode($data), + array('VALUE' => 'BINARY', 'ENCODING' => 'BASE64', 'FMTTYPE' => $attach['mimetype'], 'X-LABEL' => $attach['name'])); + unset($data); // attempt to free memory + } + // list attachments as absolute URIs + else if (!empty($this->attach_uri)) { + $ve->add('ATTACH', + strtr($this->attach_uri, array( + '{{id}}' => urlencode($attach['id']), + '{{name}}' => urlencode($attach['name']), + '{{mimetype}}' => urlencode($attach['mimetype']), + )), + array('FMTTYPE' => $attach['mimetype'], 'VALUE' => 'URI')); + } + } + } + + foreach ((array)$event['links'] as $uri) { + $ve->add('ATTACH', $uri); + } + + // add custom properties + foreach ((array)$event['x-custom'] as $prop) { + $ve->add($prop[0], $prop[1]); + } + + // append to vcalendar container + if ($vcal) { + $vcal->add($ve); + } + else { // serialize and send to stdout + echo $ve->serialize(); } - - $vevent .= "END:VEVENT" . self::EOL; // append recurrence exceptions - if ($event['recurrence']['EXCEPTIONS'] && !$recurrence_id) { - foreach ($event['recurrence']['EXCEPTIONS'] as $ex) { - $exdate = clone $event['start']; - $exdate->setDate($ex['start']->format('Y'), $ex['start']->format('n'), $ex['start']->format('j')); - $vevent .= $this->export(array($ex), null, false, $get_attachment, - $this->format_datetime('RECURRENCE-ID', $exdate, $event['allday'])); - } + if ($event['recurrence']['EXCEPTIONS']) { + foreach ($event['recurrence']['EXCEPTIONS'] as $ex) { + $exdate = clone $event['start']; + $exdate->setDate($ex['start']->format('Y'), $ex['start']->format('n'), $ex['start']->format('j')); + $recurrence_id = self::datetime_prop('RECURRENCE-ID', $exdate, true); + // if ($ex['thisandfuture']) // not supported by any client :-( + // $recurrence_id->add('RANGE', 'THISANDFUTURE'); + $this->_to_ical($ex, $vcal, $get_attachment, $recurrence_id); + } } - - if ($write) - echo rcube_vcard::rfc2425_fold($vevent); - else - $ical .= $vevent; - } - - if (!$recurrence_id) { - $ical .= "END:VCALENDAR" . self::EOL; - - if ($write) { - echo $ical; - return true; - } - } - - // fold lines to 75 chars - return rcube_vcard::rfc2425_fold($ical); - } - - private function format_datetime($attr, $dt, $dateonly = false, $utc = false) - { - if (is_numeric($dt)) - $dt = new DateTime('@'.$dt); - - if ($utc) - $dt->setTimezone(new DateTimeZone('UTC')); - - if ($dateonly) { - return $attr . ';VALUE=DATE:' . $dt->format('Ymd'); } - else { - // ;TZID=Europe/Zurich:20120706T210000 - $tz = $dt->getTimezone(); - $tzname = $tz ? $tz->getName() : null; - $tzid = $tzname && $tzname != 'UTC' && $tzname != '+00:00' ? ';TZID=' . self::escape($tzname) : ''; - return $attr . $tzid . ':' . $dt->format('Ymd\THis' . ($tzid ? '' : '\Z')); - } - } - - /** - * Escape values according to RFC 2445 4.3.11 - */ - private function escape($str) - { - return strtr($str, array('\\' => '\\\\', "\n" => '\n', ';' => '\;', ',' => '\,')); - } - - /** - * Construct the orginizer of the event. - * @param Array Attendees and roles - * - */ - private function _get_attendees($ats) - { - $organizer = ""; - $attendees = ""; - foreach ($ats as $at) { - if ($at['role'] == "ORGANIZER") { - if ($at['email']) { - $organizer .= "ORGANIZER;"; - if (!empty($at['name'])) - $organizer .= 'CN="' . $at['name'] . '"'; - $organizer .= ":mailto:". $at['email'] . self::EOL; - } - } - else if ($at['email']) { - //I am an attendee - $attendees .= "ATTENDEE;ROLE=" . $at['role'] . ";PARTSTAT=" . $at['status']; - if ($at['rsvp']) - $attendees .= ";RSVP=TRUE"; - if (!empty($at['name'])) - $attendees .= ';CN="' . $at['name'] . '"'; - $attendees .= ":mailto:" . $at['email'] . self::EOL; - } - } - - return $organizer . $attendees; - } } diff --git a/plugins/libcalendaring/tests/libvcalendar.php b/plugins/libcalendaring/tests/libvcalendar.php index b28c7ace..3a560b28 100644 --- a/plugins/libcalendaring/tests/libvcalendar.php +++ b/plugins/libcalendaring/tests/libvcalendar.php @@ -123,14 +123,14 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase $this->assertEquals('-H', $alarm[1], "Alarm unit"); // categories, class - $this->assertEquals('libcalendaring tests', $event['categories'], "Event categories"); - $this->assertEquals(2, $event['sensitivity'], "Class/sensitivity = confidential"); + $this->assertEquals('libcalendaring tests', join(',', (array)$event['categories']), "Event categories"); + $this->assertEquals('confidential', $event['sensitivity'], "Class/sensitivity = confidential"); } /** * Test for iCal export from internal hash array representation * - * @depend test_extended + * @depends test_extended */ function test_export() { @@ -156,7 +156,7 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase $this->assertContains('SEQUENCE:' . $event['sequence'], $ics, "Export Sequence number"); $this->assertContains('CLASS:CONFIDENTIAL', $ics, "Sensitivity => Class"); $this->assertContains('DESCRIPTION:*Exported by', $ics, "Export Description"); - $this->assertContains('ORGANIZER;CN="Rolf Test":mailto:rolf@', $ics, "Export organizer"); + $this->assertContains('ORGANIZER;CN=Rolf Test:mailto:rolf@', $ics, "Export organizer"); $this->assertRegExp('/ATTENDEE.*;ROLE=REQ-PARTICIPANT/', $ics, "Export Attendee ROLE"); $this->assertRegExp('/ATTENDEE.*;PARTSTAT=NEEDS-ACTION/', $ics, "Export Attendee Status"); $this->assertRegExp('/ATTENDEE.*;RSVP=TRUE/', $ics, "Export Attendee RSVP"); @@ -182,7 +182,8 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase } /** - * @depend test_export + * @depends test_extended + * @depends test_export */ function test_export_multiple() { @@ -202,7 +203,7 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase } /** - * @depend test_export + * @depends test_export */ function test_export_recurrence_exceptions() { @@ -233,13 +234,13 @@ class libvcalendar_test extends PHPUnit_Framework_TestCase $this->assertEquals($num, substr_count($ics, 'UID:'.$event['uid']), "Recurrence Exceptions with same UID"); $this->assertEquals($num, substr_count($ics, 'END:VEVENT'), "VEVENT encapsulation END"); - $this->assertContains('RECURRENCE-ID:20130814', $ics, "Recurrence-ID (1) being the exception date"); - $this->assertContains('RECURRENCE-ID:20131113', $ics, "Recurrence-ID (2) being the exception date"); + $this->assertContains('RECURRENCE-ID;VALUE=DATE-TIME:20130814', $ics, "Recurrence-ID (1) being the exception date"); + $this->assertContains('RECURRENCE-ID;VALUE=DATE-TIME:20131113', $ics, "Recurrence-ID (2) being the exception date"); $this->assertContains('SUMMARY:'.$exception2['title'], $ics, "Exception title"); } /** - * @depend test_export + * @depends test_export */ function test_export_direct() {