initial horde/date package in git
authorChuck Hagenbuch <chuck@horde.org>
Fri, 29 May 2009 22:24:49 +0000 (18:24 -0400)
committerChuck Hagenbuch <chuck@horde.org>
Sat, 30 May 2009 01:02:36 +0000 (21:02 -0400)
35 files changed:
framework/Date/lib/Horde/Date.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Recurrence.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Day.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/DayName.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/DayPortion.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Exception.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Fortnight.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Hour.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Minute.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Month.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/MonthName.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Season.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/SeasonName.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Second.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Time.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Week.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Weekend.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Repeater/Year.php [new file with mode: 0644]
framework/Date/lib/Horde/Date/Span.php [new file with mode: 0644]
framework/Date/package.xml [new file with mode: 0644]
framework/Date/test/Horde/Date/AllTests.php [new file with mode: 0644]
framework/Date/test/Horde/Date/DateTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/RecurrenceTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/DayNameTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/DayTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/HourTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/MonthNameTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/MonthTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/TimeTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/WeekTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/WeekendTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/Repeater/YearTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/SpanTest.php [new file with mode: 0644]
framework/Date/test/Horde/Date/fixtures/bug2813.ics [new file with mode: 0644]

diff --git a/framework/Date/lib/Horde/Date.php b/framework/Date/lib/Horde/Date.php
new file mode 100644 (file)
index 0000000..564a426
--- /dev/null
@@ -0,0 +1,963 @@
+<?php
+/**
+ * Horde Date wrapper/logic class, including some calculation
+ * functions.
+ *
+ * @category Horde
+ * @package  Horde_Date
+ *
+ * @TODO in format():
+ *   http://php.net/intldateformatter
+ *
+ * @TODO on timezones:
+ *   http://trac.agavi.org/ticket/1008
+ *   http://trac.agavi.org/changeset/3659
+ *
+ * @TODO on switching to PHP::DateTime:
+ *   The only thing ever stored in the database *IS* Unix timestamps. Doing
+ *   anything other than that is unmanageable, yet some frameworks use 'server
+ *   based' times in their systems, simply because they do not bother with
+ *   daylight saving and only 'serve' one timezone!
+ *
+ *   The second you have to manage 'real' time across timezones then daylight
+ *   saving becomes essential, BUT only on the display side! Since the browser
+ *   only provides a time offset, this is useless and to be honest should simply
+ *   be ignored ( until it is upgraded to provide the correct information ;)
+ *   ). So we need a 'display' function that takes a simple numeric epoch, and a
+ *   separate timezone id into which the epoch is to be 'converted'. My W3C
+ *   mapping works simply because ADOdb then converts that to it's own simple
+ *   offset abbreviation - in my case GMT or BST. As long as DateTime passes the
+ *   full 64 bit number the date range from 100AD is also preserved ( and
+ *   further back if 2 digit years are disabled ). If I want to display the
+ *   'real' timezone with this 'time' then I just add it in place of ADOdb's
+ *   'timezone'. I am tempted to simply adjust the ADOdb class to take a
+ *   timezone in place of the simple GMT switch it currently uses.
+ *
+ *   The return path is just the reverse and simply needs to take the client
+ *   display offset off prior to storage of the UTC epoch. SO we use
+ *   DateTimeZone to get an offset value for the clients timezone and simply add
+ *   or subtract this from a timezone agnostic display on the client end when
+ *   entering new times.
+ *
+ *
+ *   It's not really feasible to store dates in specific timezone, as most
+ *   national/local timezones support DST - and that is a pain to support, as
+ *   eg.  sorting breaks when some timestamps get repeated. That's why it's
+ *   usually better to store datetimes as either UTC datetime or plain unix
+ *   timestamp. I usually go with the former - using database datetime type.
+ */
+class Horde_Date
+{
+    const DATE_SUNDAY = 0;
+    const DATE_MONDAY = 1;
+    const DATE_TUESDAY = 2;
+    const DATE_WEDNESDAY = 3;
+    const DATE_THURSDAY = 4;
+    const DATE_FRIDAY = 5;
+    const DATE_SATURDAY = 6;
+
+    const MASK_SUNDAY = 1;
+    const MASK_MONDAY = 2;
+    const MASK_TUESDAY = 4;
+    const MASK_WEDNESDAY = 8;
+    const MASK_THURSDAY = 16;
+    const MASK_FRIDAY = 32;
+    const MASK_SATURDAY = 64;
+    const MASK_WEEKDAYS = 62;
+    const MASK_WEEKEND = 65;
+    const MASK_ALLDAYS = 127;
+
+    const MASK_SECOND = 1;
+    const MASK_MINUTE = 2;
+    const MASK_HOUR = 4;
+    const MASK_DAY = 8;
+    const MASK_MONTH = 16;
+    const MASK_YEAR = 32;
+    const MASK_ALLPARTS = 63;
+
+    const DATE_DEFAULT = 'Y-m-d H:i:s';
+    const DATE_JSON = 'Y-m-d\TH:i:s';
+
+    /**
+     * Year
+     *
+     * @var integer
+     */
+    protected $_year;
+
+    /**
+     * Month
+     *
+     * @var integer
+     */
+    protected $_month;
+
+    /**
+     * Day
+     *
+     * @var integer
+     */
+    protected $_mday;
+
+    /**
+     * Hour
+     *
+     * @var integer
+     */
+    protected $_hour = 0;
+
+    /**
+     * Minute
+     *
+     * @var integer
+     */
+    protected $_min = 0;
+
+    /**
+     * Second
+     *
+     * @var integer
+     */
+    protected $_sec = 0;
+
+    /**
+     * String representation of the date's timezone.
+     *
+     * @var string
+     */
+    protected $_timezone;
+
+    /**
+     * Default format for __toString()
+     *
+     * @var string
+     */
+    protected $_defaultFormat = self::DATE_DEFAULT;
+
+    /**
+     * Default specs that are always supported.
+     * @var string
+     */
+    protected static $_defaultSpecs = '%CdDeHImMnRStTyY';
+
+    /**
+     * Internally supported strftime() specifiers.
+     * @var string
+     */
+    protected static $_supportedSpecs = '';
+
+    /**
+     * Map of required correction masks.
+     *
+     * @see __set()
+     *
+     * @var array
+     */
+    protected static $_corrections = array(
+        'year'  => self::MASK_YEAR,
+        'month' => self::MASK_MONTH,
+        'mday'  => self::MASK_DAY,
+        'hour'  => self::MASK_HOUR,
+        'min'   => self::MASK_MINUTE,
+        'sec'   => self::MASK_SECOND,
+    );
+
+    var $_formatCache = array();
+
+    /**
+     * Build 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
+     */
+    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) {
+            $args = func_get_args();
+            // 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})(Z?)/', $date, $parts) &&
+                  empty($parts[7])) {
+            $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];
+        } 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 {
+            try {
+                $date = new DateTime($date);
+                $date->setTimezone(new DateTimeZone(date_default_timezone_get()));
+                $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');
+            } catch (Exception $e) {}
+        }
+    }
+
+    /**
+     * Returns a simple string representation of the date object
+     *
+     * @return string  This object converted to a string.
+     */
+    function __toString()
+    {
+        try {
+            return $this->format($this->_defaultFormat);
+        } catch (Exception $e) {
+            return '';
+        }
+    }
+
+    /**
+     * Returns a DateTime object representing this object.
+     *
+     * @return DateTime
+     */
+    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;
+    }
+
+    /**
+     * 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.
+     */
+    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.
+     */
+    function __set($name, $value)
+    {
+        if ($name == 'day') {
+            $name = 'mday';
+        }
+
+        if ($name != 'year' && $name != 'month' && $name != 'mday' &&
+            $name != 'hour' && $name != 'min' && $name != 'sec') {
+            throw new InvalidArgumentException('Undefined property ' . $name);
+        }
+
+        $this->{'_' . $name} = $value;
+        $this->_correct(self::$_corrections[$name]);
+        $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.
+     */
+    function __isset($name)
+    {
+        if ($name == 'day') {
+            $name = 'mday';
+        }
+        return ($name == 'year' || $name == 'month' || $name == 'mday' ||
+                $name == 'hour' || $name == 'min' || $name == 'sec') &&
+            isset($this->{'_' . $name});
+    }
+
+    /**
+     * Add 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)) {
+            foreach ($factor as $property => $value) {
+                $d->$property += $value;
+            }
+        } else {
+            $d->sec += $factor;
+        }
+        return $d;
+    }
+
+    /**
+     * Subtract 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.
+     */
+    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();
+    }
+
+    /**
+     * Set the default date format used in __toString()
+     *
+     * @param string $format
+     */
+    public function setDefaultFormat($format)
+    {
+        $this->_defaultFormat = $format;
+    }
+
+    /**
+     * Returns whether a year is a leap year.
+     *
+     * @static
+     *
+     * @param integer $year  The year.
+     *
+     * @return boolan  True if the year is a leap year.
+     */
+    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.
+     */
+    function firstDayOfWeek($week, $year)
+    {
+        return new Horde_Date(sprintf('%04dW%02d', $year, $week));
+    }
+
+    /**
+     * Returns the number of days in the specified month.
+     *
+     * @static
+     *
+     * @param integer $month  The month
+     * @param integer $year   The year.
+     *
+     * @return integer  The number of days in the month.
+     */
+    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 the day of the week (0 = Sunday, 6 = Saturday) of this date.
+     *
+     * @return integer  The day of the week.
+     */
+    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.
+     */
+    function dayOfYear()
+    {
+        return $this->format('z') + 1;
+    }
+
+    /**
+     * Returns the week of the month.
+     *
+     * @return integer  The week number.
+     */
+    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.
+     */
+    function weekOfYear()
+    {
+        return $this->format('W');
+    }
+
+    /**
+     * Return the number of weeks in the given year (52 or 53).
+     *
+     * @static
+     *
+     * @param integer $year  The year to count the number of weeks in.
+     *
+     * @return integer $numWeeks   The number of weeks in $year.
+     */
+    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();
+    }
+
+    /**
+     * Set 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).
+     */
+    function setNthWeekday($weekday, $nth = 1)
+    {
+        if ($weekday < self::DATE_SUNDAY || $weekday > self::DATE_SATURDAY) {
+            return;
+        }
+
+        $this->_mday = 1;
+        $first = $this->dayOfWeek();
+         if ($weekday < $first) {
+            $this->_mday = 8 + $weekday - $first;
+        } else {
+            $this->_mday = $weekday - $first + 1;
+        }
+        $this->_mday += 7 * $nth - 7;
+        $this->_correct(self::MASK_DAY);
+    }
+
+    /**
+     * Is the date currently represented by this object a valid date?
+     *
+     * @return boolean  Validity, counting leap years, etc.
+     */
+    function isValid()
+    {
+        return ($this->_year >= 0 && $this->_year <= 9999);
+    }
+
+    /**
+     * Correct any over- or underflows in any of the date's members.
+     *
+     * @param integer $mask  We may not want to correct some overflows.
+     */
+    protected function _correct($mask = self::MASK_ALLPARTS)
+    {
+        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->_year += (int)($this->_month / 12);
+            $this->_month %= 12;
+            if ($this->_month < 1) {
+                $this->_year--;
+                $this->_month += 12;
+            }
+        }
+
+        if ($mask & self::MASK_DAY) {
+            while ($this->_mday > 28 &&
+                   $this->_mday > Horde_Date::daysInMonth($this->_month, $this->_year)) {
+                $this->_mday -= Horde_Date::daysInMonth($this->_month, $this->_year);
+                ++$this->_month;
+                $this->_correct(self::MASK_MONTH);
+            }
+            while ($this->_mday < 1) {
+                --$this->_month;
+                $this->_correct(self::MASK_MONTH);
+                $this->_mday += Horde_Date::daysInMonth($this->_month, $this->_year);
+            }
+        }
+    }
+
+    /**
+     * Compare 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)
+     */
+    function compareDate($other)
+    {
+        if (!is_a($other, '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.
+     */
+    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.
+     */
+    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.
+     */
+    function equals($other)
+    {
+        return $this->compareDate($other) == 0;
+    }
+
+    /**
+     * Compare 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)
+     */
+    function compareTime($other)
+    {
+        if (!is_a($other, '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;
+    }
+
+    /**
+     * Compare 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)
+     */
+    function compareDateTime($other)
+    {
+        if (!is_a($other, 'Horde_Date')) {
+            $other = new Horde_Date($other);
+        }
+
+        if ($diff = $this->compareDate($other)) {
+            return $diff;
+        }
+
+        return $this->compareTime($other);
+    }
+
+    /**
+     * Get 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.
+     */
+    function tzOffset($colon = true)
+    {
+        return $colon ? $this->format('P') : $this->format('O');
+    }
+
+    /**
+     * Return the unix timestamp representation of this date.
+     *
+     * @return integer  A unix timestamp.
+     */
+    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');
+    }
+
+    /**
+     * Return the unix timestamp representation of this date, 12:00am.
+     *
+     * @return integer  A unix timestamp.
+     */
+    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');
+    }
+
+    /**
+     * Format date and time to be passed around as a short url parameter.
+     *
+     * @return string  Date and time.
+     */
+    function dateString()
+    {
+        return sprintf('%04d%02d%02d', $this->_year, $this->_month, $this->_mday);
+    }
+
+    /**
+     * Format date and time to the ISO format used by JSON.
+     *
+     * @return string  Date and time.
+     */
+    function toJson()
+    {
+        return $this->format(self::DATE_JSON);
+    }
+
+    /**
+     * Format time using the specifiers available in date() or in the DateTime
+     * class' format() method.
+     *
+     * @param string $format
+     *
+     * @return string  Formatted time.
+     */
+    function format($format)
+    {
+        if (!isset($this->_formatCache[$format])) {
+            $this->_formatCache[$format] = $this->toDateTime()->format($format);
+        }
+        return $this->_formatCache[$format];
+    }
+
+    /**
+     * Format date and time using strftime() format.
+     *
+     * @return string  strftime() formatted date and time.
+     */
+    function strftime($format)
+    {
+        if (preg_match('/%[^' . self::$_supportedSpecs . ']/', $format)) {
+            return strftime($format, $this->timestamp());
+        } else {
+            return $this->_strftime($format);
+        }
+    }
+
+    /**
+     * Format date and time using a limited set of the strftime() format.
+     *
+     * @return string  strftime() formatted date and time.
+     */
+    function _strftime($format)
+    {
+        if (preg_match('/%[bBpxX]/', $format)) {
+            require_once 'Horde/NLS.php';
+        }
+
+        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(NLS::getLangInfo(constant(\'ABMON_\' . (int)$this->_month)))',
+                  '$this->_strftime(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(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(NLS::getLangInfo(D_FMT))',
+                  '$this->_strftime(NLS::getLangInfo(T_FMT))',
+                  'substr(sprintf(\'%04d\', $this->_year), -2)',
+                  (int)$this->_year,
+                  '%'),
+            $format);
+    }
+
+    /**
+     * Handle 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');
+        } 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();
+            }
+        }
+    }
+
+    protected function _initializeTimezone($timezone)
+    {
+        if (empty($timezone)) {
+            $timezone = date_default_timezone_get();
+        }
+        $this->_timezone = $timezone;
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Recurrence.php b/framework/Date/lib/Horde/Date/Recurrence.php
new file mode 100644 (file)
index 0000000..fcee1f6
--- /dev/null
@@ -0,0 +1,1475 @@
+<?php
+/**
+ * This file contains the Horde_Date_Recurrence class and according constants.
+ *
+ * Copyright 2007-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.
+ *
+ * @since    Horde 3.2
+ * @category Horde
+ * @package  Horde_Date
+ */
+
+/**
+ * The Horde_Date_Recurrence class implements algorithms for calculating
+ * recurrences of events, including several recurrence types, intervals,
+ * exceptions, and conversion from and to vCalendar and iCalendar recurrence
+ * rules.
+ *
+ * All methods expecting dates as parameters accept all values that the
+ * Horde_Date constructor accepts, i.e. a timestamp, another Horde_Date
+ * object, an ISO time string or a hash.
+ *
+ * @author   Jan Schneider <jan@horde.org>
+ * @since    Horde 3.2
+ * @category Horde
+ * @package  Horde_Date
+ */
+class Horde_Date_Recurrence
+{
+    /** No Recurrence **/
+    const RECUR_NONE = 0;
+
+    /** Recurs daily. */
+    const RECUR_DAILY = 1;
+
+    /** Recurs weekly. */
+    const RECUR_WEEKLY = 2;
+
+    /** Recurs monthly on the same date. */
+    const RECUR_MONTHLY_DATE = 3;
+
+    /** Recurs monthly on the same week day. */
+    const RECUR_MONTHLY_WEEKDAY = 4;
+
+    /** Recurs yearly on the same date. */
+    const RECUR_YEARLY_DATE = 5;
+
+    /** Recurs yearly on the same day of the year. */
+    const RECUR_YEARLY_DAY = 6;
+
+    /** Recurs yearly on the same week day. */
+    const RECUR_YEARLY_WEEKDAY = 7;
+
+    /**
+     * The start time of the event.
+     *
+     * @var Horde_Date
+     */
+    var $start;
+
+    /**
+     * The end date of the recurrence interval.
+     *
+     * @var Horde_Date
+     */
+    var $recurEnd = null;
+
+    /**
+     * The number of recurrences.
+     *
+     * @var integer
+     */
+    var $recurCount = null;
+
+    /**
+     * The type of recurrence this event follows. RECUR_* constant.
+     *
+     * @var integer
+     */
+    var $recurType = self::RECUR_NONE;
+
+    /**
+     * The length of time between recurrences. The time unit depends on the
+     * recurrence type.
+     *
+     * @var integer
+     */
+    var $recurInterval = 1;
+
+    /**
+     * Any additional recurrence data.
+     *
+     * @var integer
+     */
+    var $recurData = null;
+
+    /**
+     * All the exceptions from recurrence for this event.
+     *
+     * @var array
+     */
+    var $exceptions = array();
+
+    /**
+     * All the dates this recurrence has been marked as completed.
+     *
+     * @var array
+     */
+    var $completions = array();
+
+    /**
+     * Constructor.
+     *
+     * @param Horde_Date $start  Start of the recurring event.
+     */
+    function __construct($start)
+    {
+        $this->start = is_a($start, 'Horde_Date') ? clone $start : new Horde_Date($start);
+    }
+
+    /**
+     * Checks if this event recurs on a given day of the week.
+     *
+     * @param integer $dayMask  A mask consisting of Horde_Date::MASK_*
+     *                          constants specifying the day(s) to check.
+     *
+     * @return boolean  True if this event recurs on the given day(s).
+     */
+    function recurOnDay($dayMask)
+    {
+        return ($this->recurData & $dayMask);
+    }
+
+    /**
+     * Specifies the days this event recurs on.
+     *
+     * @param integer $dayMask  A mask consisting of Horde_Date::MASK_*
+     *                          constants specifying the day(s) to recur on.
+     */
+    function setRecurOnDay($dayMask)
+    {
+        $this->recurData = $dayMask;
+    }
+
+    /**
+     * Returns the days this event recurs on.
+     *
+     * @return integer  A mask consisting of Horde_Date::MASK_* constants
+     *                  specifying the day(s) this event recurs on.
+     */
+    function getRecurOnDays()
+    {
+        return $this->recurData;
+    }
+
+    /**
+     * Returns whether this event has a specific recurrence type.
+     *
+     * @param integer $recurrence  RECUR_* constant of the
+     *                             recurrence type to check for.
+     *
+     * @return boolean  True if the event has the specified recurrence type.
+     */
+    function hasRecurType($recurrence)
+    {
+        return ($recurrence == $this->recurType);
+    }
+
+    /**
+     * Sets a recurrence type for this event.
+     *
+     * @param integer $recurrence  A RECUR_* constant.
+     */
+    function setRecurType($recurrence)
+    {
+        $this->recurType = $recurrence;
+    }
+
+    /**
+     * Returns recurrence type of this event.
+     *
+     * @return integer  A RECUR_* constant.
+     */
+    function getRecurType()
+    {
+        return $this->recurType;
+    }
+
+    /**
+     * Returns a description of this event's recurring type.
+     *
+     * @return string  Human readable recurring type.
+     */
+    function getRecurName()
+    {
+        switch ($this->getRecurType()) {
+            case self::RECUR_NONE: return _("No recurrence");
+            case self::RECUR_DAILY: return _("Daily");
+            case self::RECUR_WEEKLY: return _("Weekly");
+            case self::RECUR_MONTHLY_DATE:
+            case self::RECUR_MONTHLY_WEEKDAY: return _("Monthly");
+            case self::RECUR_YEARLY_DATE:
+            case self::RECUR_YEARLY_DAY:
+            case self::RECUR_YEARLY_WEEKDAY: return _("Yearly");
+        }
+    }
+
+    /**
+     * Sets the length of time between recurrences of this event.
+     *
+     * @param integer $interval  The time between recurrences.
+     */
+    function setRecurInterval($interval)
+    {
+        if ($interval > 0) {
+            $this->recurInterval = $interval;
+        }
+    }
+
+    /**
+     * Retrieves the length of time between recurrences of this event.
+     *
+     * @return integer  The number of seconds between recurrences.
+     */
+    function getRecurInterval()
+    {
+        return $this->recurInterval;
+    }
+
+    /**
+     * Sets the number of recurrences of this event.
+     *
+     * @param integer $count  The number of recurrences.
+     */
+    function setRecurCount($count)
+    {
+        if ($count > 0) {
+            $this->recurCount = (int)$count;
+            // Recurrence counts and end dates are mutually exclusive.
+            $this->recurEnd = null;
+        } else {
+            $this->recurCount = null;
+        }
+    }
+
+    /**
+     * Retrieves the number of recurrences of this event.
+     *
+     * @return integer  The number recurrences.
+     */
+    function getRecurCount()
+    {
+        return $this->recurCount;
+    }
+
+    /**
+     * Returns whether this event has a recurrence with a fixed count.
+     *
+     * @return boolean  True if this recurrence has a fixed count.
+     */
+    function hasRecurCount()
+    {
+        return isset($this->recurCount);
+    }
+
+    /**
+     * Sets the start date of the recurrence interval.
+     *
+     * @param Horde_Date $start  The recurrence start.
+     */
+    function setRecurStart($start)
+    {
+        $this->start = clone $start;
+    }
+
+    /**
+     * Retrieves the start date of the recurrence interval.
+     *
+     * @return Horde_Date  The recurrence start.
+     */
+    function getRecurStart()
+    {
+        return $this->start;
+    }
+
+    /**
+     * Sets the end date of the recurrence interval.
+     *
+     * @param Horde_Date $end  The recurrence end.
+     */
+    function setRecurEnd($end)
+    {
+        if (!empty($end)) {
+            // Recurrence counts and end dates are mutually exclusive.
+            $this->recurCount = null;
+            $this->recurEnd = clone $end;
+        } else {
+            $this->recurEnd = $end;
+        }
+    }
+
+    /**
+     * Retrieves the end date of the recurrence interval.
+     *
+     * @return Horde_Date  The recurrence end.
+     */
+    function getRecurEnd()
+    {
+        return $this->recurEnd;
+    }
+
+    /**
+     * Returns whether this event has a recurrence end.
+     *
+     * @return boolean  True if this recurrence ends.
+     */
+    function hasRecurEnd()
+    {
+        return isset($this->recurEnd) && isset($this->recurEnd->year) &&
+            $this->recurEnd->year != 9999;
+    }
+
+    /**
+     * Finds the next recurrence of this event that's after $afterDate.
+     *
+     * @param Horde_Date $after  Return events after this date.
+     *
+     * @return Horde_Date|boolean  The date of the next recurrence or false
+     *                             if the event does not recur after
+     *                             $afterDate.
+     */
+    function nextRecurrence($after)
+    {
+        if (!is_a($after, 'Horde_Date')) {
+            $after = new Horde_Date($after);
+        }
+
+        if ($this->start->compareDateTime($after) >= 0) {
+            return clone $this->start;
+        }
+
+        if ($this->recurInterval == 0) {
+            return false;
+        }
+
+        switch ($this->getRecurType()) {
+        case self::RECUR_DAILY:
+            $diff = Date_Calc::dateDiff($this->start->mday, $this->start->month, $this->start->year, $after->mday, $after->month, $after->year);
+            $recur = ceil($diff / $this->recurInterval);
+            if ($this->recurCount && $recur >= $this->recurCount) {
+                return false;
+            }
+            $recur *= $this->recurInterval;
+            $next = clone $this->start;
+            list($next->mday, $next->month, $next->year) = explode('/', Date_Calc::daysToDate(Date_Calc::dateToDays($next->mday, $next->month, $next->year) + $recur, '%e/%m/%Y'));
+            if ((!$this->hasRecurEnd() ||
+                 $next->compareDateTime($this->recurEnd) <= 0) &&
+                $next->compareDateTime($after) >= 0) {
+                return $next;
+            }
+            break;
+
+        case self::RECUR_WEEKLY:
+            if (empty($this->recurData)) {
+                return false;
+            }
+
+            $start_week = Horde_Date::firstDayOfWeek($this->start->format('W'),
+                                                     $this->start->year);
+            $start_week->hour = $this->start->hour;
+            $start_week->min  = $this->start->min;
+            $start_week->sec  = $this->start->sec;
+
+            // Make sure we are not at the ISO-8601 first week of year while
+            // still in month 12...and adjust the year ahead if we are.
+            $week = $after->format('W');
+            if ($week == 1 && $after->month == 12) {
+                $theYear = $after->year + 1;
+            } else {
+                $theYear = $after->year;
+            }
+            $after_week = Horde_Date::firstDayOfWeek($week, $theYear);
+            $after_week_end = clone $after_week;
+            $after_week_end->mday += 7;
+
+            $diff = Date_Calc::dateDiff($start_week->mday, $start_week->month, $start_week->year,
+                                        $after_week->mday, $after_week->month, $after_week->year);
+            $recur = $diff + ($diff % ($this->recurInterval * 7));
+            if ($this->recurCount &&
+                ceil($recur / 7) / $this->recurInterval >= $this->recurCount) {
+                return false;
+            }
+            $next = clone $start_week;
+            $next->mday += $recur;
+            while ($next->compareDateTime($after) < 0 &&
+                   $next->compareDateTime($after_week_end) < 0) {
+                ++$next->mday;
+            }
+            if (!$this->hasRecurEnd() ||
+                $next->compareDateTime($this->recurEnd) <= 0) {
+                if ($next->compareDateTime($after_week_end) >= 0) {
+                    return $this->nextRecurrence($after_week_end);
+                }
+                while (!$this->recurOnDay((int)pow(2, $next->dayOfWeek())) &&
+                       $next->compareDateTime($after_week_end) < 0) {
+                    ++$next->mday;
+                }
+                if (!$this->hasRecurEnd() ||
+                    $next->compareDateTime($this->recurEnd) <= 0) {
+                    if ($next->compareDateTime($after_week_end) >= 0) {
+                        return $this->nextRecurrence($after_week_end);
+                    } else {
+                        return $next;
+                    }
+                }
+            }
+            break;
+
+        case self::RECUR_MONTHLY_DATE:
+            $start = clone $this->start;
+            if ($after->compareDateTime($start) < 0) {
+                $after = clone $start;
+            } else {
+                $after = clone $after;
+            }
+
+            // If we're starting past this month's recurrence of the event,
+            // look in the next month on the day the event recurs.
+            if ($after->mday > $start->mday) {
+                ++$after->month;
+                $after->mday = $start->mday;
+            }
+
+            // Adjust $start to be the first match.
+            $offset = ($after->month - $start->month) + ($after->year - $start->year) * 12;
+            $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval;
+
+            if ($this->recurCount &&
+                ($offset / $this->recurInterval) >= $this->recurCount) {
+                return false;
+            }
+            $start->month += $offset;
+            $count = $offset / $this->recurInterval;
+
+            do {
+                if ($this->recurCount &&
+                    $count++ >= $this->recurCount) {
+                    return false;
+                }
+
+                // Bail if we've gone past the end of recurrence.
+                if ($this->hasRecurEnd() &&
+                    $this->recurEnd->compareDateTime($start) < 0) {
+                    return false;
+                }
+                if ($start->isValid()) {
+                    return $start;
+                }
+
+                // If the interval is 12, and the date isn't valid, then we
+                // need to see if February 29th is an option. If not, then the
+                // event will _never_ recur, and we need to stop checking to
+                // avoid an infinite loop.
+                if ($this->recurInterval == 12 && ($start->month != 2 || $start->mday > 29)) {
+                    return false;
+                }
+
+                // Add the recurrence interval.
+                $start->month += $this->recurInterval;
+            } while (true);
+
+            break;
+
+        case self::RECUR_MONTHLY_WEEKDAY:
+            // Start with the start date of the event.
+            $estart = clone $this->start;
+
+            // What day of the week, and week of the month, do we recur on?
+            $nth = ceil($this->start->mday / 7);
+            $weekday = $estart->dayOfWeek();
+
+            // Adjust $estart to be the first candidate.
+            $offset = ($after->month - $estart->month) + ($after->year - $estart->year) * 12;
+            $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval;
+
+            // Adjust our working date until it's after $after.
+            $estart->month += $offset - $this->recurInterval;
+
+            $count = $offset / $this->recurInterval;
+            do {
+                if ($this->recurCount &&
+                    $count++ >= $this->recurCount) {
+                    return false;
+                }
+
+                $estart->month += $this->recurInterval;
+
+                $next = clone $estart;
+                $next->setNthWeekday($weekday, $nth);
+
+                if ($next->compareDateTime($after) < 0) {
+                    // We haven't made it past $after yet, try again.
+                    continue;
+                }
+                if ($this->hasRecurEnd() &&
+                    $next->compareDateTime($this->recurEnd) > 0) {
+                    // We've gone past the end of recurrence; we can give up
+                    // now.
+                    return false;
+                }
+
+                // We have a candidate to return.
+                break;
+            } while (true);
+
+            return $next;
+
+        case self::RECUR_YEARLY_DATE:
+            // Start with the start date of the event.
+            $estart = clone $this->start;
+            $after = clone $after;
+
+            if ($after->month > $estart->month ||
+                ($after->month == $estart->month && $after->mday > $estart->mday)) {
+                ++$after->year;
+                $after->month = $estart->month;
+                $after->mday = $estart->mday;
+            }
+
+            // Seperate case here for February 29th
+            if ($estart->month == 2 && $estart->mday == 29) {
+                while (!Horde_Date::isLeapYear($after->year)) {
+                    ++$after->year;
+                }
+            }
+
+            // Adjust $estart to be the first candidate.
+            $offset = $after->year - $estart->year;
+            if ($offset > 0) {
+                $offset = floor(($offset + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval;
+                $estart->year += $offset;
+            }
+
+            // We've gone past the end of recurrence; give up.
+            if ($this->recurCount &&
+                $offset >= $this->recurCount) {
+                return false;
+            }
+            if ($this->hasRecurEnd() &&
+                $this->recurEnd->compareDateTime($estart) < 0) {
+                return false;
+            }
+
+            return $estart;
+
+        case self::RECUR_YEARLY_DAY:
+            // Check count first.
+            $dayofyear = $this->start->dayOfYear();
+            $count = ($after->year - $this->start->year) / $this->recurInterval + 1;
+            if ($this->recurCount &&
+                ($count > $this->recurCount ||
+                 ($count == $this->recurCount &&
+                  $after->dayOfYear() > $dayofyear))) {
+                return false;
+            }
+
+            // Start with a rough interval.
+            $estart = clone $this->start;
+            $estart->year += floor($count - 1) * $this->recurInterval;
+
+            // Now add the difference to the required day of year.
+            $estart->mday += $dayofyear - $estart->dayOfYear();
+
+            // Add an interval if the estimation was wrong.
+            if ($estart->compareDate($after) < 0) {
+                $estart->year += $this->recurInterval;
+                $estart->mday += $dayofyear - $estart->dayOfYear();
+            }
+
+            // We've gone past the end of recurrence; give up.
+            if ($this->hasRecurEnd() &&
+                $this->recurEnd->compareDateTime($estart) < 0) {
+                return false;
+            }
+
+            return $estart;
+
+        case self::RECUR_YEARLY_WEEKDAY:
+            // Start with the start date of the event.
+            $estart = clone $this->start;
+
+            // What day of the week, and week of the month, do we recur on?
+            $nth = ceil($this->start->mday / 7);
+            $weekday = $estart->dayOfWeek();
+
+            // Adjust $estart to be the first candidate.
+            $offset = floor(($after->year - $estart->year + $this->recurInterval - 1) / $this->recurInterval) * $this->recurInterval;
+
+            // Adjust our working date until it's after $after.
+            $estart->year += $offset - $this->recurInterval;
+
+            $count = $offset / $this->recurInterval;
+            do {
+                if ($this->recurCount &&
+                    $count++ >= $this->recurCount) {
+                    return false;
+                }
+
+                $estart->year += $this->recurInterval;
+
+                $next = clone $estart;
+                $next->setNthWeekday($weekday, $nth);
+
+                if ($next->compareDateTime($after) < 0) {
+                    // We haven't made it past $after yet, try again.
+                    continue;
+                }
+                if ($this->hasRecurEnd() &&
+                    $next->compareDateTime($this->recurEnd) > 0) {
+                    // We've gone past the end of recurrence; we can give up
+                    // now.
+                    return false;
+                }
+
+                // We have a candidate to return.
+                break;
+            } while (true);
+
+            return $next;
+        }
+
+        // We didn't find anything, the recurType was bad, or something else
+        // went wrong - return false.
+        return false;
+    }
+
+    /**
+     * Returns whether this event has any date that matches the recurrence
+     * rules and is not an exception.
+     *
+     * @return boolean  True if an active recurrence exists.
+     */
+    function hasActiveRecurrence()
+    {
+        if (!$this->hasRecurEnd()) {
+            return true;
+        }
+
+        $next = $this->nextRecurrence(new Horde_Date($this->start));
+        while (is_object($next)) {
+            if (!$this->hasException($next->year, $next->month, $next->mday) &&
+                !$this->hasCompletion($next->year, $next->month, $next->mday)) {
+                return true;
+            }
+
+            $next = $this->nextRecurrence(array('year' => $next->year,
+                                                'month' => $next->month,
+                                                'mday' => $next->mday + 1,
+                                                'hour' => $next->hour,
+                                                'min' => $next->min,
+                                                'sec' => $next->sec));
+        }
+
+        return false;
+    }
+
+    /**
+     * Returns the next active recurrence.
+     *
+     * @param Horde_Date $afterDate  Return events after this date.
+     *
+     * @return Horde_Date|boolean The date of the next active
+     *                             recurrence or false if the event
+     *                             has no active recurrence after
+     *                             $afterDate.
+     */
+    function nextActiveRecurrence($afterDate)
+    {
+        $next = $this->nextRecurrence($afterDate);
+        while (is_object($next)) {
+            if (!$this->hasException($next->year, $next->month, $next->mday) &&
+                !$this->hasCompletion($next->year, $next->month, $next->mday)) {
+                return $next;
+            }
+            $next->mday++;
+            $next = $this->nextRecurrence($next);
+        }
+
+        return false;
+    }
+
+    /**
+     * Adds an exception to a recurring event.
+     *
+     * @param integer $year   The year of the execption.
+     * @param integer $month  The month of the execption.
+     * @param integer $mday   The day of the month of the exception.
+     */
+    function addException($year, $month, $mday)
+    {
+        $this->exceptions[] = sprintf('%04d%02d%02d', $year, $month, $mday);
+    }
+
+    /**
+     * Deletes an exception from a recurring event.
+     *
+     * @param integer $year   The year of the execption.
+     * @param integer $month  The month of the execption.
+     * @param integer $mday   The day of the month of the exception.
+     */
+    function deleteException($year, $month, $mday)
+    {
+        $key = array_search(sprintf('%04d%02d%02d', $year, $month, $mday), $this->exceptions);
+        if ($key !== false) {
+            unset($this->exceptions[$key]);
+        }
+    }
+
+    /**
+     * Checks if an exception exists for a given reccurence of an event.
+     *
+     * @param integer $year   The year of the reucrance.
+     * @param integer $month  The month of the reucrance.
+     * @param integer $mday   The day of the month of the reucrance.
+     *
+     * @return boolean  True if an exception exists for the given date.
+     */
+    function hasException($year, $month, $mday)
+    {
+        return in_array(sprintf('%04d%02d%02d', $year, $month, $mday),
+                        $this->getExceptions());
+    }
+
+    /**
+     * Retrieves all the exceptions for this event.
+     *
+     * @return array  Array containing the dates of all the exceptions in
+     *                YYYYMMDD form.
+     */
+    function getExceptions()
+    {
+        return $this->exceptions;
+    }
+
+    /**
+     * Adds a completion to a recurring event.
+     *
+     * @param integer $year   The year of the execption.
+     * @param integer $month  The month of the execption.
+     * @param integer $mday   The day of the month of the completion.
+     */
+    function addCompletion($year, $month, $mday)
+    {
+        $this->completions[] = sprintf('%04d%02d%02d', $year, $month, $mday);
+    }
+
+    /**
+     * Deletes a completion from a recurring event.
+     *
+     * @param integer $year   The year of the execption.
+     * @param integer $month  The month of the execption.
+     * @param integer $mday   The day of the month of the completion.
+     */
+    function deleteCompletion($year, $month, $mday)
+    {
+        $key = array_search(sprintf('%04d%02d%02d', $year, $month, $mday), $this->completions);
+        if ($key !== false) {
+            unset($this->completions[$key]);
+        }
+    }
+
+    /**
+     * Checks if a completion exists for a given reccurence of an event.
+     *
+     * @param integer $year   The year of the reucrance.
+     * @param integer $month  The month of the recurrance.
+     * @param integer $mday   The day of the month of the recurrance.
+     *
+     * @return boolean  True if a completion exists for the given date.
+     */
+    function hasCompletion($year, $month, $mday)
+    {
+        return in_array(sprintf('%04d%02d%02d', $year, $month, $mday),
+                        $this->getCompletions());
+    }
+
+    /**
+     * Retrieves all the completions for this event.
+     *
+     * @return array  Array containing the dates of all the completions in
+     *                YYYYMMDD form.
+     */
+    function getCompletions()
+    {
+        return $this->completions;
+    }
+
+    /**
+     * Parses a vCalendar 1.0 recurrence rule.
+     *
+     * @link http://www.imc.org/pdi/vcal-10.txt
+     * @link http://www.shuchow.com/vCalAddendum.html
+     *
+     * @param string $rrule  A vCalendar 1.0 conform RRULE value.
+     */
+    function fromRRule10($rrule)
+    {
+        if (!$rrule) {
+            return;
+        }
+
+        if (!preg_match('/([A-Z]+)(\d+)?(.*)/', $rrule, $matches)) {
+            // No recurrence data - event does not recur.
+            $this->setRecurType(self::RECUR_NONE);
+        }
+
+        // Always default the recurInterval to 1.
+        $this->setRecurInterval(!empty($matches[2]) ? $matches[2] : 1);
+
+        $remainder = trim($matches[3]);
+
+        switch ($matches[1]) {
+        case 'D':
+            $this->setRecurType(self::RECUR_DAILY);
+            break;
+
+        case 'W':
+            $this->setRecurType(self::RECUR_WEEKLY);
+            if (!empty($remainder)) {
+                $maskdays = array('SU' => Horde_Date::MASK_SUNDAY,
+                                  'MO' => Horde_Date::MASK_MONDAY,
+                                  'TU' => Horde_Date::MASK_TUESDAY,
+                                  'WE' => Horde_Date::MASK_WEDNESDAY,
+                                  'TH' => Horde_Date::MASK_THURSDAY,
+                                  'FR' => Horde_Date::MASK_FRIDAY,
+                                  'SA' => Horde_Date::MASK_SATURDAY);
+                $mask = 0;
+                while (preg_match('/^ ?[A-Z]{2} ?/', $remainder, $matches)) {
+                    $day = trim($matches[0]);
+                    $remainder = substr($remainder, strlen($matches[0]));
+                    $mask |= $maskdays[$day];
+                }
+                $this->setRecurOnDay($mask);
+            } else {
+                // Recur on the day of the week of the original recurrence.
+                $maskdays = array(Horde_Date::DATE_SUNDAY => Horde_Date::MASK_SUNDAY,
+                                  Horde_Date::DATE_MONDAY => Horde_Date::MASK_MONDAY,
+                                  Horde_Date::DATE_TUESDAY => Horde_Date::MASK_TUESDAY,
+                                  Horde_Date::DATE_WEDNESDAY => Horde_Date::MASK_WEDNESDAY,
+                                  Horde_Date::DATE_THURSDAY => Horde_Date::MASK_THURSDAY,
+                                  Horde_Date::DATE_FRIDAY => Horde_Date::MASK_FRIDAY,
+                                  Horde_Date::DATE_SATURDAY => Horde_Date::MASK_SATURDAY);
+                $this->setRecurOnDay($maskdays[$this->start->dayOfWeek()]);
+            }
+            break;
+
+        case 'MP':
+            $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY);
+            break;
+
+        case 'MD':
+            $this->setRecurType(self::RECUR_MONTHLY_DATE);
+            break;
+
+        case 'YM':
+            $this->setRecurType(self::RECUR_YEARLY_DATE);
+            break;
+
+        case 'YD':
+            $this->setRecurType(self::RECUR_YEARLY_DAY);
+            break;
+        }
+
+        // We don't support modifiers at the moment, strip them.
+        while ($remainder && !preg_match('/^(#\d+|\d{8})($| |T\d{6})/', $remainder)) {
+               $remainder = substr($remainder, 1);
+        }
+        if (!empty($remainder)) {
+            if (strpos($remainder, '#') !== false) {
+                $this->setRecurCount(substr($remainder, 1));
+            } else {
+                list($year, $month, $mday) = sscanf($remainder, '%04d%02d%02d');
+                $this->setRecurEnd(new Horde_Date(array('year' => $year,
+                                                        'month' => $month,
+                                                        'mday' => $mday)));
+            }
+        }
+    }
+
+    /**
+     * Creates a vCalendar 1.0 recurrence rule.
+     *
+     * @link http://www.imc.org/pdi/vcal-10.txt
+     * @link http://www.shuchow.com/vCalAddendum.html
+     *
+     * @param Horde_iCalendar $calendar  A Horde_iCalendar object instance.
+     *
+     * @return string  A vCalendar 1.0 conform RRULE value.
+     */
+    function toRRule10($calendar)
+    {
+        switch ($this->recurType) {
+        case self::RECUR_NONE:
+            return '';
+
+        case self::RECUR_DAILY:
+            $rrule = 'D' . $this->recurInterval;
+            break;
+
+        case self::RECUR_WEEKLY:
+            $rrule = 'W' . $this->recurInterval;
+            $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA');
+
+            for ($i = 0; $i <= 7 ; ++$i) {
+                if ($this->recurOnDay(pow(2, $i))) {
+                    $rrule .= ' ' . $vcaldays[$i];
+                }
+            }
+            break;
+
+        case self::RECUR_MONTHLY_DATE:
+            $rrule = 'MD' . $this->recurInterval . ' ' . trim($this->start->mday);
+            break;
+
+        case self::RECUR_MONTHLY_WEEKDAY:
+            $next_week = new Horde_Date($this->start);
+            $next_week->mday += 7;
+
+            if ($this->start->month != $next_week->month) {
+                $p = 5;
+            } else {
+                $p = (int)($this->start->mday / 7);
+                if (($this->start->mday % 7) > 0) {
+                    $p++;
+                }
+            }
+
+            $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA');
+            $rrule = 'MP' . $this->recurInterval . ' ' . $p . '+ ' . $vcaldays[$this->start->dayOfWeek()];
+            break;
+
+        case self::RECUR_YEARLY_DATE:
+            $rrule = 'YM' . $this->recurInterval . ' ' . trim($this->start->month);
+            break;
+
+        case self::RECUR_YEARLY_DAY:
+            $rrule = 'YD' . $this->recurInterval . ' ' . $this->start->dayOfYear();
+            break;
+
+        default:
+            return '';
+        }
+
+        if ($this->hasRecurEnd()) {
+            $recurEnd = clone $this->recurEnd;
+            $recurEnd->mday++;
+            return $rrule . ' ' . $calendar->_exportDateTime($recurEnd);
+        }
+
+        return $rrule . ' #' . (int)$this->getRecurCount();
+    }
+
+    /**
+     * Parses an iCalendar 2.0 recurrence rule.
+     *
+     * @link http://rfc.net/rfc2445.html#s4.3.10
+     * @link http://rfc.net/rfc2445.html#s4.8.5
+     * @link http://www.shuchow.com/vCalAddendum.html
+     *
+     * @param string $rrule  An iCalendar 2.0 conform RRULE value.
+     */
+    function fromRRule20($rrule)
+    {
+        // Parse the recurrence rule into keys and values.
+        $rdata = array();
+        $parts = explode(';', $rrule);
+        foreach ($parts as $part) {
+            list($key, $value) = explode('=', $part, 2);
+            $rdata[String::upper($key)] = $value;
+        }
+
+        if (isset($rdata['FREQ'])) {
+            // Always default the recurInterval to 1.
+            $this->setRecurInterval(isset($rdata['INTERVAL']) ? $rdata['INTERVAL'] : 1);
+
+            switch (String::upper($rdata['FREQ'])) {
+            case 'DAILY':
+                $this->setRecurType(self::RECUR_DAILY);
+                break;
+
+            case 'WEEKLY':
+                $this->setRecurType(self::RECUR_WEEKLY);
+                if (isset($rdata['BYDAY'])) {
+                    $maskdays = array('SU' => Horde_Date::MASK_SUNDAY,
+                                      'MO' => Horde_Date::MASK_MONDAY,
+                                      'TU' => Horde_Date::MASK_TUESDAY,
+                                      'WE' => Horde_Date::MASK_WEDNESDAY,
+                                      'TH' => Horde_Date::MASK_THURSDAY,
+                                      'FR' => Horde_Date::MASK_FRIDAY,
+                                      'SA' => Horde_Date::MASK_SATURDAY);
+                    $days = explode(',', $rdata['BYDAY']);
+                    $mask = 0;
+                    foreach ($days as $day) {
+                        $mask |= $maskdays[$day];
+                    }
+                    $this->setRecurOnDay($mask);
+                } else {
+                    // Recur on the day of the week of the original
+                    // recurrence.
+                    $maskdays = array(
+                        Horde_Date::DATE_SUNDAY => Horde_Date::MASK_SUNDAY,
+                        Horde_Date::DATE_MONDAY => Horde_Date::MASK_MONDAY,
+                        Horde_Date::DATE_TUESDAY => Horde_Date::MASK_TUESDAY,
+                        Horde_Date::DATE_WEDNESDAY => Horde_Date::MASK_WEDNESDAY,
+                        Horde_Date::DATE_THURSDAY => Horde_Date::MASK_THURSDAY,
+                        Horde_Date::DATE_FRIDAY => Horde_Date::MASK_FRIDAY,
+                        Horde_Date::DATE_SATURDAY => Horde_Date::MASK_SATURDAY);
+                    $this->setRecurOnDay($maskdays[$this->start->dayOfWeek()]);
+                }
+                break;
+
+            case 'MONTHLY':
+                if (isset($rdata['BYDAY'])) {
+                    $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY);
+                } else {
+                    $this->setRecurType(self::RECUR_MONTHLY_DATE);
+                }
+                break;
+
+            case 'YEARLY':
+                if (isset($rdata['BYYEARDAY'])) {
+                    $this->setRecurType(self::RECUR_YEARLY_DAY);
+                } elseif (isset($rdata['BYDAY'])) {
+                    $this->setRecurType(self::RECUR_YEARLY_WEEKDAY);
+                } else {
+                    $this->setRecurType(self::RECUR_YEARLY_DATE);
+                }
+                break;
+            }
+
+            if (isset($rdata['UNTIL'])) {
+                list($year, $month, $mday) = sscanf($rdata['UNTIL'],
+                                                    '%04d%02d%02d');
+                $this->setRecurEnd(new Horde_Date(array('year' => $year,
+                                                        'month' => $month,
+                                                        'mday' => $mday)));
+            }
+            if (isset($rdata['COUNT'])) {
+                $this->setRecurCount($rdata['COUNT']);
+            }
+        } else {
+            // No recurrence data - event does not recur.
+            $this->setRecurType(self::RECUR_NONE);
+        }
+    }
+
+    /**
+     * Creates an iCalendar 2.0 recurrence rule.
+     *
+     * @link http://rfc.net/rfc2445.html#s4.3.10
+     * @link http://rfc.net/rfc2445.html#s4.8.5
+     * @link http://www.shuchow.com/vCalAddendum.html
+     *
+     * @param Horde_iCalendar $calendar  A Horde_iCalendar object instance.
+     *
+     * @return string  An iCalendar 2.0 conform RRULE value.
+     */
+    function toRRule20($calendar)
+    {
+        switch ($this->recurType) {
+        case self::RECUR_NONE:
+            return '';
+
+        case self::RECUR_DAILY:
+            $rrule = 'FREQ=DAILY;INTERVAL='  . $this->recurInterval;
+            break;
+
+        case self::RECUR_WEEKLY:
+            $rrule = 'FREQ=WEEKLY;INTERVAL=' . $this->recurInterval . ';BYDAY=';
+            $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA');
+
+            for ($i = $flag = 0; $i <= 7 ; ++$i) {
+                if ($this->recurOnDay(pow(2, $i))) {
+                    if ($flag) {
+                        $rrule .= ',';
+                    }
+                    $rrule .= $vcaldays[$i];
+                    $flag = true;
+                }
+            }
+            break;
+
+        case self::RECUR_MONTHLY_DATE:
+            $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->recurInterval;
+            break;
+
+        case self::RECUR_MONTHLY_WEEKDAY:
+            $next_week = new Horde_Date($this->start);
+            $next_week->mday += 7;
+            if ($this->start->month != $next_week->month) {
+                $p = 5;
+            } else {
+                $p = (int)($this->start->mday / 7);
+                if (($this->start->mday % 7) > 0) {
+                    $p++;
+                }
+            }
+            $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA');
+            $rrule = 'FREQ=MONTHLY;INTERVAL=' . $this->recurInterval
+                . ';BYDAY=' . $p . $vcaldays[$this->start->dayOfWeek()];
+            break;
+
+        case self::RECUR_YEARLY_DATE:
+            $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval;
+            break;
+
+        case self::RECUR_YEARLY_DAY:
+            $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval
+                . ';BYYEARDAY=' . $this->start->dayOfYear();
+            break;
+
+        case self::RECUR_YEARLY_WEEKDAY:
+            $vcaldays = array('SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA');
+            $weekday = new Horde_Date(array('month' => $this->start->month,
+                                            'mday' => 1,
+                                            'year' => $this->start->year));
+            $rrule = 'FREQ=YEARLY;INTERVAL=' . $this->recurInterval
+                . ';BYDAY='
+                . ($this->start->weekOfYear() - $weekday->weekOfYear() + 1)
+                . $vcaldays[$this->start->dayOfWeek()]
+                . ';BYMONTH=' . $this->start->month;
+            break;
+        }
+
+        if ($this->hasRecurEnd()) {
+            $recurEnd = clone $this->recurEnd;
+            $recurEnd->mday++;
+            $rrule .= ';UNTIL=' . $calendar->_exportDateTime($recurEnd);
+        }
+        if ($count = $this->getRecurCount()) {
+            $rrule .= ';COUNT=' . $count;
+        }
+        return $rrule;
+    }
+
+    /**
+     * Parses the recurrence data from a hash.
+     *
+     * @param array $hash  The hash to convert.
+     *
+     * @return boolean  True if the hash seemed valid, false otherwise.
+     */
+    function fromHash($hash)
+    {
+        if (!isset($hash['interval']) || !isset($hash['interval']) ||
+            !isset($hash['range-type'])) {
+            $this->setRecurType(self::RECUR_NONE);
+            return false;
+        }
+
+        $this->setRecurInterval((int) $hash['interval']);
+
+        $parse_day = false;
+        $set_daymask = false;
+        $update_month = false;
+        $update_daynumber = false;
+        $update_weekday = false;
+        $nth_weekday = -1;
+
+        switch ($hash['cycle']) {
+        case 'daily':
+            $this->setRecurType(self::RECUR_DAILY);
+            break;
+
+        case 'weekly':
+            $this->setRecurType(self::RECUR_WEEKLY);
+            $parse_day = true;
+            $set_daymask = true;
+            break;
+
+        case 'monthly':
+            if (!isset($hash['daynumber'])) {
+                $this->setRecurType(self::RECUR_NONE);
+                return false;
+            }
+
+            switch ($hash['type']) {
+            case 'daynumber':
+                $this->setRecurType(self::RECUR_MONTHLY_DATE);
+                $update_daynumber = true;
+                break;
+
+            case 'weekday':
+                $this->setRecurType(self::RECUR_MONTHLY_WEEKDAY);
+                $nth_weekday = (int) $hash['daynumber'];
+                $hash['daynumber'] = 1;
+                $parse_day = true;
+                $update_daynumber = true;
+                $update_weekday = true;
+                break;
+            }
+            break;
+
+        case 'yearly':
+            if (!isset($hash['type'])) {
+                $this->setRecurType(self::RECUR_NONE);
+                return false;
+            }
+
+            switch ($hash['type']) {
+            case 'monthday':
+                $this->setRecurType(self::RECUR_YEARLY_DATE);
+                $update_month = true;
+                $update_daynumber = true;
+                break;
+
+            case 'yearday':
+                if (!isset($hash['month'])) {
+                    $this->setRecurType(self::RECUR_NONE);
+                    return false;
+                }
+
+                $this->setRecurType(self::RECUR_YEARLY_DAY);
+                // Start counting days in January.
+                $hash['month'] = 'january';
+                $update_month = true;
+                $update_daynumber = true;
+                break;
+
+            case 'weekday':
+                if (!isset($hash['daynumber'])) {
+                    $this->setRecurType(self::RECUR_NONE);
+                    return false;
+                }
+
+                $this->setRecurType(self::RECUR_YEARLY_WEEKDAY);
+                $nth_weekday = (int) $hash['daynumber'];
+                $hash['daynumber'] = 1;
+                $parse_day = true;
+                $update_month = true;
+                $update_daynumber = true;
+                $update_weekday = true;
+                break;
+            }
+        }
+
+        switch ($hash['range-type']) {
+        case 'number':
+            if (!isset($hash['range'])) {
+                $this->setRecurType(self::RECUR_NONE);
+                return false;
+            }
+
+            $this->setRecurCount((int) $hash['range']);
+            break;
+
+        case 'date':
+            $recur_end = new Horde_Date($hash['range']);
+            $recur_end->hour = 23;
+            $recur_end->min = 59;
+            $recur_end->sec = 59;
+            $this->setRecurEnd($recur_end);
+            break;
+        }
+
+        // Need to parse <day>?
+        $last_found_day = -1;
+        if ($parse_day) {
+            if (!isset($hash['day'])) {
+                $this->setRecurType(self::RECUR_NONE);
+                return false;
+            }
+
+            $mask = 0;
+            $bits = array(
+                'monday' => Horde_Date::MASK_MONDAY,
+                'tuesday' => Horde_Date::MASK_TUESDAY,
+                'wednesday' => Horde_Date::MASK_WEDNESDAY,
+                'thursday' => Horde_Date::MASK_THURSDAY,
+                'friday' => Horde_Date::MASK_FRIDAY,
+                'saturday' => Horde_Date::MASK_SATURDAY,
+                'sunday' => Horde_Date::MASK_SUNDAY,
+            );
+            $days = array(
+                'monday' => Horde_Date::DATE_MONDAY,
+                'tuesday' => Horde_Date::DATE_TUESDAY,
+                'wednesday' => Horde_Date::DATE_WEDNESDAY,
+                'thursday' => Horde_Date::DATE_THURSDAY,
+                'friday' => Horde_Date::DATE_FRIDAY,
+                'saturday' => Horde_Date::DATE_SATURDAY,
+                'sunday' => Horde_Date::DATE_SUNDAY,
+            );
+
+            foreach ($hash['day'] as $day) {
+                // Validity check.
+                if (empty($day) || !isset($bits[$day])) {
+                    continue;
+                }
+
+                $mask |= $bits[$day];
+                $last_found_day = $days[$day];
+            }
+
+            if ($set_daymask) {
+                $this->setRecurOnDay($mask);
+            }
+        }
+
+        if ($update_month || $update_daynumber || $update_weekday) {
+            if ($update_month) {
+                $month2number = array(
+                    'january'   => 1,
+                    'february'  => 2,
+                    'march'     => 3,
+                    'april'     => 4,
+                    'may'       => 5,
+                    'june'      => 6,
+                    'july'      => 7,
+                    'august'    => 8,
+                    'september' => 9,
+                    'october'   => 10,
+                    'november'  => 11,
+                    'december'  => 12,
+                );
+
+                if (isset($month2number[$hash['month']])) {
+                    $this->start->month = $month2number[$hash['month']];
+                }
+            }
+
+            if ($update_daynumber) {
+                if (!isset($hash['daynumber'])) {
+                    $this->setRecurType(self::RECUR_NONE);
+                    return false;
+                }
+
+                $this->start->mday = $hash['daynumber'];
+            }
+
+            if ($update_weekday) {
+                $this->start->setNthWeekday($last_found_day, $nth_weekday);
+            }
+        }
+
+        // Exceptions.
+        if (isset($hash['exceptions'])) {
+            $this->exceptions = $hash['exceptions'];
+        }
+
+        if (isset($hash['completions'])) {
+            $this->completions = $hash['completions'];
+        }
+
+        return true;
+    }
+
+    /**
+     * Export this object into a hash.
+     *
+     * @return array  The recurrence hash.
+     */
+    function toHash()
+    {
+        if ($this->getRecurType() == self::RECUR_NONE) {
+            return array();
+        }
+
+        $day2number = array(
+            0 => 'sunday',
+            1 => 'monday',
+            2 => 'tuesday',
+            3 => 'wednesday',
+            4 => 'thursday',
+            5 => 'friday',
+            6 => 'saturday'
+        );
+        $month2number = array(
+            1 => 'january',
+            2 => 'february',
+            3 => 'march',
+            4 => 'april',
+            5 => 'may',
+            6 => 'june',
+            7 => 'july',
+            8 => 'august',
+            9 => 'september',
+            10 => 'october',
+            11 => 'november',
+            12 => 'december'
+        );
+
+        $hash = array('interval' => $this->getRecurInterval());
+        $start = $this->getRecurStart();
+
+        switch ($this->getRecurType()) {
+        case self::RECUR_DAILY:
+            $hash['cycle'] = 'daily';
+            break;
+
+        case self::RECUR_WEEKLY:
+            $hash['cycle'] = 'weekly';
+            $bits = array(
+                'monday' => Horde_Date::MASK_MONDAY,
+                'tuesday' => Horde_Date::MASK_TUESDAY,
+                'wednesday' => Horde_Date::MASK_WEDNESDAY,
+                'thursday' => Horde_Date::MASK_THURSDAY,
+                'friday' => Horde_Date::MASK_FRIDAY,
+                'saturday' => Horde_Date::MASK_SATURDAY,
+                'sunday' => Horde_Date::MASK_SUNDAY,
+            );
+            $days = array();
+            foreach($bits as $name => $bit) {
+                if ($this->recurOnDay($bit)) {
+                    $days[] = $name;
+                }
+            }
+            $hash['day'] = $days;
+            break;
+
+        case self::RECUR_MONTHLY_DATE:
+            $hash['cycle'] = 'monthly';
+            $hash['type'] = 'daynumber';
+            $hash['daynumber'] = $start->mday;
+            break;
+
+        case self::RECUR_MONTHLY_WEEKDAY:
+            $hash['cycle'] = 'monthly';
+            $hash['type'] = 'weekday';
+            $hash['daynumber'] = $start->weekOfMonth();
+            $hash['day'] = array ($day2number[$start->dayOfWeek()]);
+            break;
+
+        case self::RECUR_YEARLY_DATE:
+            $hash['cycle'] = 'yearly';
+            $hash['type'] = 'monthday';
+            $hash['daynumber'] = $start->mday;
+            $hash['month'] = $month2number[$start->month];
+            break;
+
+        case self::RECUR_YEARLY_DAY:
+            $hash['cycle'] = 'yearly';
+            $hash['type'] = 'yearday';
+            $hash['daynumber'] = $start->dayOfYear();
+            break;
+
+        case self::RECUR_YEARLY_WEEKDAY:
+            $hash['cycle'] = 'yearly';
+            $hash['type'] = 'weekday';
+            $hash['daynumber'] = $start->weekOfMonth();
+            $hash['day'] = array ($day2number[$start->dayOfWeek()]);
+            $hash['month'] = $month2number[$start->month];
+        }
+
+        if ($this->hasRecurCount()) {
+            $hash['range-type'] = 'number';
+            $hash['range'] = $this->getRecurCount();
+        } elseif ($this->hasRecurEnd()) {
+            $date = $this->getRecurEnd();
+            $hash['range-type'] = 'date';
+            $hash['range'] = $date->datestamp();
+        } else {
+            $hash['range-type'] = 'none';
+            $hash['range'] = '';
+        }
+
+        // Recurrence exceptions
+        $hash['exceptions'] = $this->exceptions;
+        $hash['completions'] = $this->completions;
+
+        return $hash;
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater.php b/framework/Date/lib/Horde/Date/Repeater.php
new file mode 100644 (file)
index 0000000..5fdd964
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+/**
+ */
+
+/**
+ */
+abstract class Horde_Date_Repeater
+{
+    public $now;
+
+    /**
+     * returns the width (in seconds or months) of this repeatable.
+     */
+    abstract public function width();
+
+    /**
+     * returns the next occurance of this repeatable.
+     */
+    public function next($pointer)
+    {
+        if (is_null($this->now)) {
+            throw new Horde_Date_Repeater_Exception('Start point must be set before calling next()');
+        }
+
+        if (!in_array($pointer, array('future', 'none', 'past'))) {
+            throw new Horde_Date_Repeater_Exception("First argument 'pointer' must be one of 'past', 'future', 'none'");
+        }
+    }
+
+    public function this($pointer)
+    {
+        if (is_null($this->now)) {
+            throw new Horde_Date_Repeater_Exception('Start point must be set before calling this()');
+        }
+
+        if (!in_array($pointer, array('future', 'none', 'past'))) {
+            throw new Horde_Date_Repeater_Exception("First argument 'pointer' must be one of 'past', 'future', 'none'");
+        }
+    }
+
+    public function __toString()
+    {
+        return 'repeater';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Day.php b/framework/Date/lib/Horde/Date/Repeater/Day.php
new file mode 100644 (file)
index 0000000..7cc544b
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+class Horde_Date_Repeater_Day extends Horde_Date_Repeater
+{
+    // (24 * 60 * 60)
+    const DAY_SECONDS = 86400;
+
+    public $currentDayStart;
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        if (!$this->currentDayStart) {
+            $this->currentDayStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day));
+        }
+
+        $direction = ($pointer == 'future') ? 1 : -1;
+        $this->currentDayStart->day += $direction;
+
+        $end = clone $this->currentDayStart;
+        $end->day += 1;
+
+        return new Horde_Date_Span($this->currentDayStart, $end);
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'future':
+            $dayBegin = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour + 1));
+            $dayEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day + 1));
+            break;
+
+        case 'past':
+            $dayBegin = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day));
+            $dayEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour));
+            break;
+
+        case 'none':
+            $dayBegin = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day));
+            $dayEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day + 1));
+            break;
+        }
+
+        return new Horde_Date_Span($dayBegin, $dayEnd);
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $span->add(array('day' => $direction * $amount));
+    }
+
+    public function width()
+    {
+        return self::DAY_SECONDS;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-day';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/DayName.php b/framework/Date/lib/Horde/Date/Repeater/DayName.php
new file mode 100644 (file)
index 0000000..578b966
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+class Horde_Date_Repeater_DayName extends Horde_Date_Repeater
+{
+    // (24 * 60 * 60)
+    const DAY_SECONDS = 86400;
+
+    public $currentDayStart;
+    public $type;
+
+    public function __construct($type)
+    {
+        $this->type = $type;
+    }
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        $direction = ($pointer == 'future') ? 1 : -1;
+
+        if (!$this->currentDayStart) {
+            $this->currentDayStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day + $direction));
+
+            $dayNum = $this->_dayNumber($this->type);
+            while ($this->currentDayStart->dayOfWeek() != $dayNum) {
+                $this->currentDayStart->day += $direction;
+            }
+        } else {
+            $this->currentDayStart->day += $direction * 7;
+        }
+
+        $end = clone $this->currentDayStart;
+        $end->day++;
+        return new Horde_Date_Span($this->currentDayStart, $end);
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::next($pointer);
+
+        if ($pointer == 'none') {
+            $pointer = 'future';
+        }
+        return $this->next($pointer);
+    }
+
+    public function width()
+    {
+        return self::DAY_SECONDS;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-dayname-' . $this->type;
+    }
+
+    protected function _dayNumber($dayName)
+    {
+        $days = array(
+            'monday' => Horde_Date::DATE_MONDAY,
+            'tuesday' => Horde_Date::DATE_TUESDAY,
+            'wednesday' => Horde_Date::DATE_WEDNESDAY,
+            'thursday' => Horde_Date::DATE_THURSDAY,
+            'friday' => Horde_Date::DATE_FRIDAY,
+            'saturday' => Horde_Date::DATE_SATURDAY,
+            'sunday' => Horde_Date::DATE_SUNDAY,
+        );
+        if (!isset($days[$dayName])) {
+            throw new InvalidArgumentException('Invalid day name "' . $dayName . '"');
+        }
+        return $days[$dayName];
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/DayPortion.php b/framework/Date/lib/Horde/Date/Repeater/DayPortion.php
new file mode 100644 (file)
index 0000000..ee63cc2
--- /dev/null
@@ -0,0 +1,146 @@
+<?php
+class Horde_Date_Repeater_DayPortion extends Horde_Date_Repeater
+{
+    /**
+     * 6am-12am (6 * 60 * 60, 12 * 60 * 60)
+     */
+    public static $morning = array(21600, 43200);
+
+    /**
+     * 1pm-5pm (13 * 60 * 60, 17 * 60 * 60)
+     */
+    public static $afternoon = array(46800, 61200);
+
+    /**
+     * 5pm-8pm (17 * 60 * 60, 20 * 60 * 60)
+     */
+    public static $evening = array(61200, 72000);
+
+    /**
+     * 8pm-12pm (20 * 60 * 60, 24 * 60 * 60)
+     */
+    public static $night = array(72000, 86400);
+
+    public $range;
+    public $currentSpan;
+    public $type;
+
+    public function __construct($type)
+    {
+        $this->type = $type;
+
+        if (is_int($type)) {
+            $this->range = array(($type * 3600), (($type + 12) * 3600));
+        } else {
+            $lookup = array(
+                'am' => array(0, (12 * 3600 - 1)),
+                'pm' => array((12 * 3600), (24 * 3600 - 1)),
+                'morning' => self::$morning,
+                'afternoon' => self::$afternoon,
+                'evening' => self::$evening,
+                'night' => self::$night,
+            );
+            if (!isset($lookup[$type])) {
+                throw new InvalidArgumentException("Invalid type '$type' for Repeater_DayPortion");
+            }
+            $this->range = $lookup[$type];
+        }
+    }
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        $fullDay = 60 * 60 * 24;
+
+        if (!$this->currentSpan) {
+            $nowSeconds = $this->now->hour * 3600 + $this->now->min * 60 + $this->now->sec;
+            if ($nowSeconds < $this->range[0]) {
+                switch ($pointer) {
+                case 'future':
+                    $rangeStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'sec' => $this->range[0]));
+                    break;
+
+                case 'past':
+                    $rangeStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day - 1, 'sec' => $this->range[0]));
+                    break;
+                }
+            } elseif ($nowSeconds > $this->range[1]) {
+                switch ($pointer) {
+                case 'future':
+                    $rangeStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day + 1, 'sec' => $this->range[0]));
+                    break;
+
+                case 'past':
+                    $rangeStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'sec' => $this->range[0]));
+                    break;
+                }
+            } else {
+                switch ($pointer) {
+                case 'future':
+                    $rangeStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day + 1, 'sec' => $this->range[0]));
+                    break;
+
+                case 'past':
+                    $rangeStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day - 1, 'sec' => $this->range[0]));
+                    break;
+                }
+            }
+
+            $rangeEnd = $rangeStart->add($this->range[1] - $this->range[0]);
+            $this->currentSpan = new Horde_Date_Span($rangeStart, $rangeEnd);
+        } else {
+            switch ($pointer) {
+            case 'future':
+                $this->currentSpan = $this->currentSpan->add(array('day' => 1));
+                break;
+
+            case 'past':
+                $this->currentSpan = $this->currentSpan->add(array('day' => -1));
+                break;
+            }
+        }
+
+        return $this->currentSpan;
+    }
+
+    public function this($context = 'future')
+    {
+        parent::this($context);
+
+        $rangeStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'sec' => $this->range[0]));
+        $this->currentSpan = new Horde_Date_Span($rangeStart, $rangeStart->add($this->range[1] - $this->range[0]));
+        return $this->currentSpan;
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $this->now = $span->begin;
+        $portionSpan = $this->next($pointer);
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $portionSpan->add(array('day' => $direction * ($amount - 1)));
+    }
+
+    public function width()
+    {
+        if (!$this->range) {
+            throw new Horde_Date_Repeater_Exception('Range has not been set');
+        }
+
+        if ($this->currentSpan) {
+            return $this->currentSpan->width();
+        }
+
+        if (is_int($this->type)) {
+            return (12 * 3600);
+        } else {
+            return $this->range[1] - $this->range[0];
+        }
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-dayportion-' . $this->type;
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Exception.php b/framework/Date/lib/Horde/Date/Repeater/Exception.php
new file mode 100644 (file)
index 0000000..0fb573e
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+class Horde_Date_Repeater_Exception extends Exception
+{
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Fortnight.php b/framework/Date/lib/Horde/Date/Repeater/Fortnight.php
new file mode 100644 (file)
index 0000000..00f4ae8
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+class Horde_Date_Repeater_Fortnight extends Horde_Date_Repeater
+{
+    // (14 * 24 * 60 * 60)
+    const FORTNIGHT_SECONDS = 1209600;
+
+    public $currentFortnightStart;
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        if (!$this->currentFortnightStart) {
+            switch ($pointer) {
+            case 'future':
+                $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+                $sundayRepeater->now = $this->now;
+                $nextSundaySpan = $sundayRepeater->next('future');
+                $this->currentFortnightStart = $nextSundaySpan->begin;
+                break;
+
+            case 'past':
+                $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+                $sundayRepeater->now = clone $this->now;
+                $sundayRepeater->now->day++;
+                $sundayRepeater->next('past');
+                $sundayRepeater->next('past');
+                $lastSundaySpan = $sundayRepeater->next('past');
+                $this->currentFortnightStart = $lastSundaySpan->begin;
+                break;
+            }
+        } else {
+            $direction = ($pointer == 'future') ? 1 : -1;
+            $this->currentFortnightStart->add($direction * self::FORTNIGHT_SECONDS);
+        }
+
+        return new Horde_Date_Span($this->currentFortnightStart, $this->currentFortnightStart->add(self::FORTNIGHT_SECONDS));
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'future':
+        case 'none':
+            $thisFortnightStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour + 1));
+            $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+            $sundayRepeater->now = $this->now;
+            $sundayRepeater->this('future');
+            $thisSundaySpan = $sundayRepeater->this('future');
+            $thisFortnightEnd = $thisSundaySpan->begin;
+            return new Horde_Date_Span($thisFortnightStart, $thisFortnightEnd);
+
+        case 'past':
+            $thisFortnightEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour));
+            $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+            $sundayRepeater->now = $this->now;
+            $lastSundaySpan = $sundayRepeater->next('past');
+            $thisFortnightStart = $lastSundaySpan->begin;
+            return new Horde_Date_Span($thisFortnightStart, $thisFortnightEnd);
+        }
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $span->add($direction * $amount * self::FORTNIGHT_SECONDS);
+    }
+
+    public function width()
+    {
+        return self::FORTNIGHT_SECONDS;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-fortnight';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Hour.php b/framework/Date/lib/Horde/Date/Repeater/Hour.php
new file mode 100644 (file)
index 0000000..31b30b2
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+class Horde_Date_Repeater_Hour extends Horde_Date_Repeater
+{
+    public $currentHourStart;
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        $direction = ($pointer == 'future') ? 1 : -1;
+        if (!$this->currentHourStart) {
+            $this->currentHourStart = new Horde_Date(array('month' => $this->now->month, 'year' => $this->now->year, 'day' => $this->now->day, 'hour' => $this->now->hour));
+        }
+        $this->currentHourStart->hour += $direction;
+
+        $end = clone $this->currentHourStart;
+        $end->hour++;
+        return new Horde_Date_Span($this->currentHourStart, $end);
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'future':
+            $hourStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour, 'min' => $this->now->min + 1));
+            $hourEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour + 1));
+            break;
+
+        case 'past':
+            $hourStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour));
+            $hourEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour, 'min' => $this->now->min));
+            break;
+
+        case 'none':
+            $hourStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour));
+            $hourEnd = $hourStart->add(array('hour' => 1));
+            break;
+        }
+
+        return new Horde_Date_Span($hourStart, $hourEnd);
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $span->add(array('hour' => $direction * $amount));
+    }
+
+    public function width()
+    {
+        return 3600;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-hour';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Minute.php b/framework/Date/lib/Horde/Date/Repeater/Minute.php
new file mode 100644 (file)
index 0000000..0f5ff0a
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+class Horde_Date_Repeater_Minute extends Horde_Date_Repeater
+{
+    public $currentMinuteStart;
+
+    public function next($pointer = 'future')
+    {
+        parent::next($pointer);
+
+        if (!$this->currentMinuteStart) {
+            $this->currentMinuteStart = new Horde_Date(array('month' => $this->now->month, 'year' => $this->now->year, 'day' => $this->now->day, 'hour' => $this->now->hour, 'min' => $this->now->min));
+        }
+        $direction = ($pointer == 'future') ? 1 : -1;
+        $this->currentMinuteStart->min += $direction;
+
+        $end = clone $this->currentMinuteStart;
+        $end->min++;
+        return new Horde_Date_Span($this->currentMinuteStart, $end);
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'future':
+            $minuteBegin = clone $this->now;
+            $minuteEnd = new Horde_Date(array('month' => $this->now->month, 'year' => $this->now->year, 'day' => $this->now->day, 'hour' => $this->now->hour, 'min' => $this->now->min));
+            break;
+
+        case 'past':
+            $minuteBegin = new Horde_Date(array('month' => $this->now->month, 'year' => $this->now->year, 'day' => $this->now->day, 'hour' => $this->now->hour, 'min' => $this->now->min));
+            $minuteEnd = clone $this->now;
+            break;
+
+        case 'none':
+            $minuteBegin = new Horde_Date(array('month' => $this->now->month, 'year' => $this->now->year, 'day' => $this->now->day, 'hour' => $this->now->hour, 'min' => $this->now->min));
+            $minuteEnd = new Horde_Date(array('month' => $this->now->month, 'year' => $this->now->year, 'day' => $this->now->day, 'hour' => $this->now->hour, 'min' => $this->now->min + 1));
+            break;
+        }
+
+        return new Horde_Date_Span($minuteBegin, $minuteEnd);
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $span->add(array('min' => $direction * $amount));
+    }
+
+    public function width()
+    {
+        return 60;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-minute';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Month.php b/framework/Date/lib/Horde/Date/Repeater/Month.php
new file mode 100644 (file)
index 0000000..9afba94
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+class Horde_Date_Repeater_Month extends Horde_Date_Repeater
+{
+    /**
+     * 30 * 24 * 60 * 60
+     */
+    const MONTH_SECONDS = 2592000;
+
+    public $currentMonthStart;
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        if (!$this->currentMonthStart) {
+            $this->currentMonthStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => 1));
+        }
+        $direction = ($pointer == 'future') ? 1 : -1;
+        $this->currentMonthStart->month += $direction;
+
+        $end = clone $this->currentMonthStart;
+        $end->month++;
+        return new Horde_Date_Span($this->currentMonthStart, $end);
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'future':
+            $monthStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day + 1));
+            $monthEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month + 1, 'day' => 1));
+            break;
+
+        case 'past':
+            $monthStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => 1));
+            $monthEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day));
+            break;
+
+        case 'none':
+            $monthStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => 1));
+            $monthEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month + 1, 'day' => 1));
+            break;
+        }
+
+        return new Horde_Date_Span($monthStart, $monthEnd);
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $span->add(array('month' => $amount * $direction));
+    }
+
+    public function width()
+    {
+        return self::MONTH_SECONDS;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-month';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/MonthName.php b/framework/Date/lib/Horde/Date/Repeater/MonthName.php
new file mode 100644 (file)
index 0000000..d7b355e
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+class Horde_Date_Repeater_MonthName extends Horde_Date_Repeater
+{
+    public $currentMonthStart;
+    public $type;
+
+    public function __construct($type)
+    {
+        $this->type = $type;
+    }
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        if (!$this->currentMonthStart) {
+            $targetMonth = $this->_monthNumber($this->type);
+            switch ($pointer) {
+            case 'future':
+                if ($this->now->month < $targetMonth) {
+                    $this->currentMonthStart = new Horde_Date(array('year' => $this->now->year, 'month' => $targetMonth, 'day' => 1));
+                } else {
+                    $this->currentMonthStart = new Horde_Date(array('year' => $this->now->year + 1, 'month' => $targetMonth, 'day' => 1));
+                }
+                break;
+
+            case 'none':
+                if ($this->now->month <= $targetMonth) {
+                    $this->currentMonthStart = new Horde_Date(array('year' => $this->now->year, 'month' => $targetMonth, 'day' => 1));
+                } else {
+                    $this->currentMonthStart = new Horde_Date(array('year' => $this->now->year + 1, 'month' => $targetMonth, 'day' => 1));
+                }
+                break;
+
+            case 'past':
+                if ($this->now->month > $targetMonth) {
+                    $this->currentMonthStart = new Horde_Date(array('year' => $this->now->year, 'month' => $targetMonth, 'day' => 1));
+                } else {
+                    $this->currentMonthStart = new Horde_Date(array('year' => $this->now->year - 1, 'month' => $targetMonth, 'day' => 1));
+                }
+                break;
+            }
+        } else {
+            switch ($pointer) {
+            case 'future':
+                $this->currentMonthStart->year++;
+                break;
+
+            case 'past':
+                $this->currentMonthStart->year--;
+                break;
+            }
+        }
+
+        return new Horde_Date_Span($this->currentMonthStart, $this->currentMonthStart->add(array('month' => 1)));
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'past':
+            return $this->next($pointer);
+
+        case 'future':
+        case 'none':
+            return $this->next('none');
+        }
+    }
+
+    public function width()
+    {
+        return Horde_Date_Repeater_Month::MONTH_SECONDS;
+    }
+
+    public function index()
+    {
+        return $this->_monthNumber($this->type);
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-monthname-' . $this->type;
+    }
+
+    protected function _monthNumber($monthName)
+    {
+        $months = array(
+            'january' => 1,
+            'february' => 2,
+            'march' => 3,
+            'april' => 4,
+            'may' => 5,
+            'june' => 6,
+            'july' => 7,
+            'august' => 8,
+            'september' => 9,
+            'october' => 10,
+            'november' => 11,
+            'december' => 12,
+        );
+        if (!isset($months[$monthName])) {
+            throw new InvalidArgumentException('Invalid month name "' . $monthName . '"');
+        }
+        return $months[$monthName];
+    }
+
+}
\ No newline at end of file
diff --git a/framework/Date/lib/Horde/Date/Repeater/Season.php b/framework/Date/lib/Horde/Date/Repeater/Season.php
new file mode 100644 (file)
index 0000000..2ad200b
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+class Horde_Date_Repeater_Season extends Horde_Date_Repeater
+{
+    /**
+     * 91 * 24 * 60 * 60
+     */
+    const SEASON_SECONDS = 7862400;
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+        throw new Horde_Date_Repeater_Exception('Not implemented');
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+        throw new Horde_Date_Repeater_Exception('Not implemented');
+    }
+
+    public function width()
+    {
+        return self::SEASON_SECONDS;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-season';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/SeasonName.php b/framework/Date/lib/Horde/Date/Repeater/SeasonName.php
new file mode 100644 (file)
index 0000000..cffa237
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+class Horde_Date_Repeater_SeasonName extends Horde_Date_Repeater_Season
+{
+    /**
+     * 91 * 24 * 60 * 60
+     */
+    const SEASON_SECONDS = 7862400;
+
+    public $summer = array('jul 21', 'sep 22');
+    public $autumn = array('sep 23', 'dec 21');
+    public $winter = array('dec 22', 'mar 19');
+    public $spring = array('mar 20', 'jul 20');
+    public $type;
+
+    public function __construct($type)
+    {
+        $this->type = $type;
+    }
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+        throw new Horde_Date_Repeater_Exception('Not implemented');
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+        throw new Horde_Date_Repeater_Exception('Not implemented');
+    }
+
+    public function width()
+    {
+        return self::SEASON_SECONDS;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-season-' . $this->type;
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Second.php b/framework/Date/lib/Horde/Date/Repeater/Second.php
new file mode 100644 (file)
index 0000000..1e16b7e
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+class Horde_Date_Repeater_Second extends Horde_Date_Repeater
+{
+    public $secondStart;
+
+    public function next($pointer = 'future')
+    {
+        parent::next($pointer);
+
+        $direction = ($pointer == 'future') ? 1 : -1;
+
+        if (!$this->secondStart) {
+            $this->secondStart = clone $this->now;
+            $this->secondStart->sec += $direction;
+        } else {
+            $this->secondStart += $direction;
+        }
+
+        $end = clone $this->secondStart;
+        $end->sec++;
+        return new Horde_Date_Span($this->secondStart, $end);
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        $end = clone $this->now;
+        $end->sec++;
+        return new Horde_Date_Span($this->now, $end);
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $span->add($direction * $amount);
+    }
+
+    public function width()
+    {
+        return 1;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-second';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Time.php b/framework/Date/lib/Horde/Date/Repeater/Time.php
new file mode 100644 (file)
index 0000000..405cd66
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+class Horde_Date_Repeater_Time extends Horde_Date_Repeater
+{
+    public $currentTime;
+    public $type;
+    public $ambiguous;
+
+    public function __construct($time, $options = array())
+    {
+        $t = str_replace(':', '', $time);
+
+        switch (strlen($t)) {
+        case 1:
+        case 2:
+            $hours = (int)$t;
+            $this->ambiguous = true;
+            $this->type = ($hours == 12) ? 0 : $hours * 3600;
+            break;
+
+        case 3:
+            $this->ambiguous = true;
+            $this->type = $t[0] * 3600 + (int)substr($t, 1, 2) * 60;
+            break;
+
+        case 4:
+            $this->ambiguous = (strpos($time, ':') !== false) && ($t[0] != 0) && ((int)substr($t, 0, 2) <= 12);
+            $hours = (int)substr($t, 0, 2);
+            $this->type = ($hours == 12) ?
+                ((int)substr($t, 2, 2) * 60) :
+                ($hours * 60 * 60 + (int)substr($t, 2, 2) * 60);
+            break;
+
+        case 5:
+            $this->ambiguous = true;
+            $this->type = $t[0] * 3600 + (int)substr($t, 1, 2) * 60 + (int)substr($t, 3, 2);
+            break;
+
+        case 6:
+            $this->ambiguous = (strpos($time, ':') !== false) && ($t[0] != 0) && ((int)substr($t, 0, 2) <= 12);
+            $hours = (int)substr($t, 0, 2);
+            $this->type = ($hours == 12) ?
+                ((int)substr($t, 2, 2) * 60 + (int)substr($t, 4, 2)) :
+                ($hours * 60 * 60 + (int)substr($t, 2, 2) * 60 + (int)substr($t, 4, 2));
+            break;
+
+        default:
+            throw new Horde_Date_Repeater_Exception('Time cannot exceed six digits');
+        }
+    }
+
+    /**
+     * Return the next past or future Span for the time that this Repeater represents
+     *   pointer - Symbol representing which temporal direction to fetch the next day
+     *             must be either :past or :future
+     */
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        $halfDay = 3600 * 12;
+        $fullDay = 3600 * 24;
+
+        $first = false;
+
+        if (!$this->currentTime) {
+            $first = true;
+            $midnight = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day));
+            $yesterdayMidnight = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day - 1));
+            $tomorrowMidnight = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day + 1));
+
+            if ($pointer == 'future') {
+                if ($this->ambiguous) {
+                    foreach (array($midnight->add($this->type), $midnight->add($halfDay + $this->type), $tomorrowMidnight->add($this->type)) as $t) {
+                        if ($t->compareDateTime($this->now) >= 0) {
+                            $this->currentTime = $t;
+                            break;
+                        }
+                    }
+                } else {
+                    foreach (array($midnight->add($this->type), $tomorrowMidnight->add($this->type)) as $t) {
+                        if ($t->compareDateTime($this->now) >= 0) {
+                            $this->currentTime = $t;
+                            break;
+                        }
+                    }
+                }
+            } elseif ($pointer == 'past') {
+                if ($this->ambiguous) {
+                    foreach (array($midnight->add($halfDay + $this->type), $midnight->add($this->type), $yesterdayMidnight->add($this->type * 2)) as $t) {
+                        if ($t->compareDateTime($this->now) <= 0) {
+                            $this->currentTime = $t;
+                            break;
+                        }
+                    }
+                } else {
+                    foreach (array($midnight->add($this->type), $yesterdayMidnight->add($this->type)) as $t) {
+                        if ($t->compareDateTime($this->now) <= 0) {
+                            $this->currentTime = $t;
+                            break;
+                        }
+                    }
+                }
+            }
+
+            if (!$this->currentTime) {
+                throw new Horde_Date_Repeater_Exception('Current time cannot be null at this point');
+            }
+        }
+
+        if (!$first) {
+            $increment = $this->ambiguous ? $halfDay : $fullDay;
+            $this->currentTime->sec += ($pointer == 'future') ? $increment : -$increment;
+        }
+
+        return new Horde_Date_Span($this->currentTime, $this->currentTime->add(1));
+    }
+
+    public function this($context = 'future')
+    {
+        parent::this($context);
+
+        if ($context == 'none') { $context = 'future'; }
+        return $this->next($context);
+    }
+
+    public function width()
+    {
+        return 1;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-time-' . $this->type;
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Week.php b/framework/Date/lib/Horde/Date/Repeater/Week.php
new file mode 100644 (file)
index 0000000..ff16a6e
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+class Horde_Date_Repeater_Week extends Horde_Date_Repeater
+{
+    /**
+     * (7 * 24 * 60 * 60)
+     */
+    const WEEK_SECONDS = 604800;
+
+    public $currentWeekStart;
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        if (!$this->currentWeekStart) {
+            switch ($pointer) {
+            case 'future':
+                $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+                $sundayRepeater->now = $this->now;
+                $nextSundaySpan = $sundayRepeater->next('future');
+                $this->currentWeekStart = $nextSundaySpan->begin;
+                break;
+
+            case 'past':
+                $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+                $sundayRepeater->now = clone $this->now;
+                $sundayRepeater->now->day++;
+                $sundayRepeater->next('past');
+                $lastSundaySpan = $sundayRepeater->next('past');
+                $this->currentWeekStart = $lastSundaySpan->begin;
+                break;
+            }
+        } else {
+            $direction = ($pointer == 'future') ? 1 : -1;
+            $this->currentWeekStart->day += $direction * 7;
+        }
+
+        return new Horde_Date_Span($this->currentWeekStart, $this->currentWeekStart->add(array('day' => 7)));
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'future':
+            $thisWeekStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour + 1));
+            $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+            $sundayRepeater->now = $this->now;
+            $thisSundaySpan = $sundayRepeater->this('future');
+            $thisWeekEnd = $thisSundaySpan->begin;
+            return new Horde_Date_Span($thisWeekStart, $thisWeekEnd);
+
+        case 'past':
+            $thisWeekEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day, 'hour' => $this->now->hour));
+            $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+            $sundayRepeater->now = $this->now;
+            $lastSundaySpan = $sundayRepeater->next('past');
+            $thisWeekStart = $lastSundaySpan->begin;
+            return new Horde_Date_Span($thisWeekStart, $thisWeekEnd);
+
+        case 'none':
+            $sundayRepeater = new Horde_Date_Repeater_DayName('sunday');
+            $sundayRepeater->now = $this->now;
+            $lastSundaySpan = $sundayRepeater->next('past');
+            $thisWeekStart = $lastSundaySpan->begin;
+            $thisWeekEnd = clone $thisWeekStart;
+            $thisWeekEnd->day += 7;
+            return new Horde_Date_Span($thisWeekStart, $thisWeekEnd);
+        }
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $span->add(array('day' => $direction * $amount * 7));
+    }
+
+    public function width()
+    {
+        return self::WEEK_SECONDS;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-week';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Weekend.php b/framework/Date/lib/Horde/Date/Repeater/Weekend.php
new file mode 100644 (file)
index 0000000..0116f97
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+class Horde_Date_Repeater_Weekend extends Horde_Date_Repeater
+{
+    /**
+     * (2 * 24 * 60 * 60)
+     */
+    const WEEKEND_SECONDS = 172800;
+
+    public $currentWeekStart;
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        if (!$this->currentWeekStart) {
+            switch ($pointer) {
+            case 'future':
+                $saturdayRepeater = new Horde_Date_Repeater_DayName('saturday');
+                $saturdayRepeater->now = $this->now;
+                $nextSaturdaySpan = $saturdayRepeater->next('future');
+                $this->currentWeekStart = $nextSaturdaySpan->begin;
+                break;
+
+            case 'past':
+                $saturdayRepeater = new Horde_Date_Repeater_DayName('saturday');
+                $saturdayRepeater->now = $this->now;
+                $saturdayRepeater->now->day++;
+                $lastSaturdaySpan = $saturdayRepeater->next('past');
+                $this->currentWeekStart = $lastSaturdaySpan->begin;
+                break;
+            }
+        } else {
+            $direction = ($pointer == 'future') ? 1 : -1;
+            $this->currentWeekStart->day += $direction * 7;
+        }
+
+        return new Horde_Date_Span($this->currentWeekStart, $this->currentWeekStart->add(array('day' => 2)));
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'future':
+        case 'none':
+            $saturdayRepeater = new Horde_Date_Repeater_DayName('saturday');
+            $saturdayRepeater->now = $this->now;
+            $thisSaturdaySpan = $saturdayRepeater->this('future');
+            return new Horde_Date_Span($thisSaturdaySpan->begin, $thisSaturdaySpan->begin->add(array('day' => 2)));
+
+        case 'past':
+            $saturdayRepeater = new Horde_Date_Repeater_DayName('saturday');
+            $saturdayRepeater->now = $this->now;
+            $lastSaturdaySpan = $saturdayRepeater->this('past');
+            return new Horde_Date_Span($lastSaturdaySpan->begin, $lastSaturdaySpan->begin->add(array('day' => 2)));
+        }
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        $weekend = new self();
+        $weekend->now = clone $span->begin;
+        $start = $weekend->next($pointer)->begin;
+        $start->day += ($amount - 1) * $direction * 7;
+        return new Horde_Date_Span($start, $start->add($span->width()));
+    }
+
+    public function width()
+    {
+        return self::WEEKEND_SECONDS;
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-weekend';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Repeater/Year.php b/framework/Date/lib/Horde/Date/Repeater/Year.php
new file mode 100644 (file)
index 0000000..e31207d
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+class Horde_Date_Repeater_Year extends Horde_Date_Repeater
+{
+    public $currentYearStart;
+
+    public function next($pointer)
+    {
+        parent::next($pointer);
+
+        if (!$this->currentYearStart) {
+            $this->currentYearStart = new Horde_Date(array('year' => $this->now->year, 'month' => 1, 'day' => 1));
+        }
+
+        $diff = ($pointer == 'future') ? 1 : -1;
+        $this->currentYearStart->year += $diff;
+
+        return new Horde_Date_Span($this->currentYearStart, $this->currentYearStart->add(array('year' => 1)));
+    }
+
+    public function this($pointer = 'future')
+    {
+        parent::this($pointer);
+
+        switch ($pointer) {
+        case 'future':
+            $thisYearStart = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day + 1));
+            $thisYearEnd = new Horde_Date(array('year' => $this->now->year + 1, 'month' => 1, 'day' => 1));
+            break;
+
+        case 'past':
+            $thisYearStart = new Horde_Date(array('year' => $this->now->year, 'month' => 1, 'day' => 1));
+            $thisYearEnd = new Horde_Date(array('year' => $this->now->year, 'month' => $this->now->month, 'day' => $this->now->day));
+            break;
+
+        case 'none':
+            $thisYearStart = new Horde_Date(array('year' => $this->now->year, 'month' => 1, 'day' => 1));
+            $thisYearEnd = new Horde_Date(array('year' => $this->now->year + 1, 'month' => 1, 'day' => 1));
+            break;
+        }
+
+        return new Horde_Date_Span($thisYearStart, $thisYearEnd);
+    }
+
+    public function offset($span, $amount, $pointer)
+    {
+        $direction = ($pointer == 'future') ? 1 : -1;
+        return $span->add(array('year' => ($amount * $direction)));
+    }
+
+    public function width()
+    {
+        return (365 * 24 * 60 * 60);
+    }
+
+    public function __toString()
+    {
+        return parent::__toString() . '-year';
+    }
+
+}
diff --git a/framework/Date/lib/Horde/Date/Span.php b/framework/Date/lib/Horde/Date/Span.php
new file mode 100644 (file)
index 0000000..ad5900c
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+/**
+ * A Span represents a range of time.
+ *
+ * @package Horde_Date
+ */
+
+/**
+ * @package Horde_Date
+ */
+class Horde_Date_Span
+{
+    /**
+     * @var Horde_Date
+     */
+    public $begin;
+
+    /**
+     * @var Horde_Date
+     */
+    public $end;
+
+    /**
+     * Constructor
+     *
+     * @param mixed $begin  Horde_Date or other format accepted by the Horde_Date constructor
+     * @param mixed $end    Horde_Date or other format accepted by the Horde_Date constructor
+     */
+    public function __construct($begin, $end)
+    {
+        if (! $begin instanceof Horde_Date) { $begin = new Horde_Date($begin); }
+        if (! $end instanceof Horde_Date) { $end = new Horde_Date($end); }
+
+        $this->begin = $begin;
+        $this->end = $end;
+    }
+
+    /**
+     * Return the width of this span in seconds
+     */
+    public function width()
+    {
+        return abs($this->end->timestamp() - $this->begin->timestamp());
+    }
+
+    /**
+     * Is a Horde_Date within this span?
+     *
+     * @param Horde_Date $date
+     */
+    public function includes($date)
+    {
+        return ($this->begin->compareDateTime($date) <= 0) && ($this->end->compareDateTime($date) >= 0);
+    }
+
+    /**
+     * Add a number of seconds to this span, returning the new span
+     */
+    public function add($factor)
+    {
+        return new Horde_Date_Span($this->begin->add($factor), $this->end->add($factor));
+    }
+
+    /**
+     * Subtract a number of seconds from this span, returning the new span.
+     */
+    public function sub($factor)
+    {
+        return new Horde_Date_Span($this->begin->sub($factor), $this->end->sub($factor));
+    }
+
+    public function __toString()
+    {
+        return '(' . $this->begin . '..' . $this->end . ')';
+    }
+
+}
diff --git a/framework/Date/package.xml b/framework/Date/package.xml
new file mode 100644 (file)
index 0000000..29df44d
--- /dev/null
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<package packagerversion="1.4.9" version="2.0" xmlns="http://pear.php.net/dtd/package-2.0" xmlns:tasks="http://pear.php.net/dtd/tasks-1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://pear.php.net/dtd/tasks-1.0
+http://pear.php.net/dtd/tasks-1.0.xsd
+http://pear.php.net/dtd/package-2.0
+http://pear.php.net/dtd/package-2.0.xsd">
+ <name>Date</name>
+ <channel>pear.horde.org</channel>
+ <summary>Horde Date package</summary>
+ <description>Package for creating and manipulating dates.
+ </description>
+ <lead>
+  <name>Chuck Hagenbuch</name>
+  <user>chuck</user>
+  <email>chuck@horde.org</email>
+  <active>yes</active>
+ </lead>
+ <lead>
+  <name>Jan Schneider</name>
+  <user>jan</user>
+  <email>jan@horde.org</email>
+  <active>yes</active>
+ </lead>
+ <date>2009-05-29</date>
+ <version>
+  <release>0.3.0</release>
+  <api>0.3.0</api>
+ </version>
+ <stability>
+  <release>beta</release>
+  <api>beta</api>
+ </stability>
+ <license uri="http://www.gnu.org/copyleft/lesser.html">LGPL</license>
+ <notes>* Add Horde_Date_Repeater
+* Add Horde_Date_Span</notes>
+ <contents>
+  <dir name="/">
+   <dir name="lib">
+    <dir name="Horde">
+     <dir name="Date">
+      <dir name="Repeater">
+       <file name="Day.php" role="php" />
+       <file name="DayName.php" role="php" />
+       <file name="DayPortion.php" role="php" />
+       <file name="Exception.php" role="php" />
+       <file name="Fortnight.php" role="php" />
+       <file name="Hour.php" role="php" />
+       <file name="Minute.php" role="php" />
+       <file name="Month.php" role="php" />
+       <file name="MonthName.php" role="php" />
+       <file name="Season.php" role="php" />
+       <file name="SeasonName.php" role="php" />
+       <file name="Second.php" role="php" />
+       <file name="Time.php" role="php" />
+       <file name="Week.php" role="php" />
+       <file name="Weekend.php" role="php" />
+       <file name="Year.php" role="php" />
+      </dir> <!-- /lib/Horde/Date/Repeater -->
+      <file name="Recurrence.php" role="php" />
+      <file name="Repeater.php" role="php" />
+      <file name="Span.php" role="php" />
+     </dir> <!-- /lib/Horde/Date -->
+     <file name="Date.php" role="php" />
+    </dir> <!-- /lib/Horde -->
+   </dir> <!-- /lib -->
+  </dir> <!-- / -->
+ </contents>
+ <dependencies>
+  <required>
+   <php>
+    <min>5.2.0</min>
+   </php>
+   <pearinstaller>
+    <min>1.7.0</min>
+   </pearinstaller>
+  </required>
+ </dependencies>
+ <phprelease>
+  <filelist>
+   <install name="lib/Horde/Date.php" as="Horde/Date.php" />
+   <install name="lib/Horde/Date/Recurrence.php" as="Horde/Date/Recurrence.php" />
+   <install name="lib/Horde/Date/Repeater.php" as="Horde/Date/Repeater.php" />
+   <install name="lib/Horde/Date/Repeater/Day.php" as="Horde/Date/Repeater/Day.php" />
+   <install name="lib/Horde/Date/Repeater/DayName.php" as="Horde/Date/Repeater/DayName.php" />
+   <install name="lib/Horde/Date/Repeater/DayPortion.php" as="Horde/Date/Repeater/DayPortion.php" />
+   <install name="lib/Horde/Date/Repeater/Exception.php" as="Horde/Date/Repeater/Exception.php" />
+   <install name="lib/Horde/Date/Repeater/Fortnight.php" as="Horde/Date/Repeater/Fortnight.php" />
+   <install name="lib/Horde/Date/Repeater/Hour.php" as="Horde/Date/Repeater/Hour.php" />
+   <install name="lib/Horde/Date/Repeater/Minute.php" as="Horde/Date/Repeater/Minute.php" />
+   <install name="lib/Horde/Date/Repeater/Month.php" as="Horde/Date/Repeater/Month.php" />
+   <install name="lib/Horde/Date/Repeater/MonthName.php" as="Horde/Date/Repeater/MonthName.php" />
+   <install name="lib/Horde/Date/Repeater/Season.php" as="Horde/Date/Repeater/Season.php" />
+   <install name="lib/Horde/Date/Repeater/SeasonName.php" as="Horde/Date/Repeater/SeasonName.php" />
+   <install name="lib/Horde/Date/Repeater/Second.php" as="Horde/Date/Repeater/Second.php" />
+   <install name="lib/Horde/Date/Repeater/Time.php" as="Horde/Date/Repeater/Time.php" />
+   <install name="lib/Horde/Date/Repeater/Week.php" as="Horde/Date/Repeater/Week.php" />
+   <install name="lib/Horde/Date/Repeater/Weekend.php" as="Horde/Date/Repeater/Weekend.php" />
+   <install name="lib/Horde/Date/Repeater/Year.php" as="Horde/Date/Repeater/Year.php" />
+   <install name="lib/Horde/Date/Span.php" as="Horde/Date/Span.php" />
+  </filelist>
+ </phprelease>
+</package>
diff --git a/framework/Date/test/Horde/Date/AllTests.php b/framework/Date/test/Horde/Date/AllTests.php
new file mode 100644 (file)
index 0000000..c68efa6
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Date
+ * @subpackage UnitTests
+ * @copyright  2008-2009 The Horde Project (http://www.horde.org/)
+ * @license    http://opensource.org/licenses/bsd-license.php
+ */
+
+if (!defined('PHPUnit_MAIN_METHOD')) {
+    define('PHPUnit_MAIN_METHOD', 'Horde_Date_AllTests::main');
+}
+
+require_once 'PHPUnit/Framework/TestSuite.php';
+require_once 'PHPUnit/TextUI/TestRunner.php';
+
+class Horde_Date_AllTests {
+
+    public static function main()
+    {
+        PHPUnit_TextUI_TestRunner::run(self::suite());
+    }
+
+    public static function suite()
+    {
+        set_include_path(dirname(__FILE__) . '/../../../lib' . PATH_SEPARATOR . get_include_path());
+        if (!spl_autoload_functions()) {
+            spl_autoload_register(create_function('$class', '$filename = str_replace(array(\'::\', \'_\'), \'/\', $class); include "$filename.php";'));
+        }
+
+        $suite = new PHPUnit_Framework_TestSuite('Horde Framework - Horde_Date');
+
+        $basedir = dirname(__FILE__);
+        $baseregexp = preg_quote($basedir . DIRECTORY_SEPARATOR, '/');
+
+        foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($basedir)) as $file) {
+            if ($file->isFile() && preg_match('/Test.php$/', $file->getFilename())) {
+                $pathname = $file->getPathname();
+                require $pathname;
+
+                $class = str_replace(DIRECTORY_SEPARATOR, '_',
+                                     preg_replace("/^$baseregexp(.*)\.php/", '\\1', $pathname));
+                $suite->addTestSuite('Horde_Date_' . $class);
+            }
+        }
+
+        return $suite;
+    }
+
+}
+
+if (PHPUnit_MAIN_METHOD == 'Horde_Date_AllTests::main') {
+    Horde_Date_AllTests::main();
+}
diff --git a/framework/Date/test/Horde/Date/DateTest.php b/framework/Date/test/Horde/Date/DateTest.php
new file mode 100644 (file)
index 0000000..2f5b0b4
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_DateTest extends PHPUnit_Framework_TestCase
+{
+    public function testDateCorrection()
+    {
+        $d = new Horde_Date('2008-01-01 00:00:00');
+        $d->month -= 2;
+        $this->assertEquals(2007, $d->year);
+
+        $d = new Horde_Date('2008-01-01 00:00:00');
+        $d->day -= 1;
+        $this->assertEquals(2007, $d->year);
+        $this->assertEquals(12, $d->month);
+
+        $d = new Horde_Date('2008-01-01 00:00:00');
+        $d->day += 370;
+        $this->assertEquals(2009, $d->year);
+        $this->assertEquals(1, $d->month);
+
+        $d = new Horde_Date('2008-01-01 00:00:00');
+        $d->sec += 14400;
+        $this->assertEquals(0, $d->sec);
+        $this->assertEquals(0, $d->min);
+        $this->assertEquals(4, $d->hour);
+    }
+
+    public function testDateMath()
+    {
+        $d = new Horde_Date('2008-01-01 00:00:00');
+
+        $this->assertEquals('2007-12-31 00:00:00', (string)$d->sub(array('day' => 1)));
+        $this->assertEquals('2009-01-01 00:00:00', (string)$d->add(array('year' => 1)));
+        $this->assertEquals('2008-01-01 04:00:00', (string)$d->add(14400));
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/RecurrenceTest.php b/framework/Date/test/Horde/Date/RecurrenceTest.php
new file mode 100644 (file)
index 0000000..bba5aaa
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+/**
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+require_once 'PHPUnit/Framework.php';
+
+require_once 'Horde/Date/Recurrence.php';
+
+class Horde_Date_RecurrenceTest extends PHPUnit_Framework_TestCase
+{
+    /**
+     */
+    public function testHash()
+    {
+        $r = &new Horde_Date_Recurrence(0);
+        $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY);
+        $r->addException(1970, 1, 1);
+        $r->addException(1970, 1, 3);
+        $r->addException(1970, 1, 4);
+
+        $r->setRecurEnd(new Horde_Date(86400*3));
+
+        $s = &new Horde_Date_Recurrence(0);
+        $s->fromHash($r->toHash());
+
+        $this->assertTrue($s->hasRecurEnd());
+
+        $next = $s->nextRecurrence(new Horde_Date($s->start));
+        $this->assertEquals(1, $next->mday);
+        $this->assertTrue($s->hasException($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $s->nextRecurrence($next);
+        $this->assertFalse($s->hasException($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $s->nextRecurrence($next);
+        $this->assertTrue($s->hasException($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $s->nextRecurrence($next);
+        $this->assertTrue($s->hasException($next->year, $next->month, $next->mday));
+
+        $this->assertEquals(3, count($s->getExceptions()));
+        $this->assertTrue($s->hasActiveRecurrence());
+        $s->addException(1970, 1, 2);
+        $this->assertFalse($s->hasActiveRecurrence());
+    }
+
+    /**
+     */
+    public function testCompletions()
+    {
+        $r = &new Horde_Date_Recurrence(0);
+        $r->setRecurType(Horde_Date_Recurrence::RECUR_DAILY);
+        $r->addCompletion(1970, 1, 2);
+        $this->assertTrue($r->hasCompletion(1970, 1, 2));
+        $this->assertEquals(1, count($r->getCompletions()));
+        $r->addCompletion(1970, 1, 4);
+        $this->assertEquals(2, count($r->getCompletions()));
+        $r->deleteCompletion(1970, 1, 2);
+        $this->assertEquals(1, count($r->getCompletions()));
+        $this->assertFalse($r->hasCompletion(1970, 1, 2));
+        $r->addCompletion(1970, 1, 2);
+        $r->addException(1970, 1, 1);
+        $r->addException(1970, 1, 3);
+
+        $next = $r->nextRecurrence(new Horde_Date($r->start));
+        $this->assertEquals(1, $next->mday);
+        $this->assertTrue($r->hasException($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $r->nextRecurrence($next);
+        $this->assertTrue($r->hasCompletion($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $r->nextRecurrence($next);
+        $this->assertTrue($r->hasException($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $r->nextRecurrence($next);
+        $this->assertTrue($r->hasCompletion($next->year, $next->month, $next->mday));
+
+        $r->setRecurEnd(new Horde_Date(86400*3));
+        $this->assertTrue($r->hasRecurEnd());
+
+        $this->assertFalse($r->hasActiveRecurrence());
+
+        $s = &new Horde_Date_Recurrence(0);
+        $s->fromHash($r->toHash());
+
+        $this->assertTrue($s->hasRecurEnd());
+
+        $next = $s->nextRecurrence(new Horde_Date($s->start));
+        $this->assertEquals(1, $next->mday);
+        $this->assertTrue($s->hasException($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $s->nextRecurrence($next);
+        $this->assertTrue($s->hasCompletion($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $s->nextRecurrence($next);
+        $this->assertTrue($s->hasException($next->year, $next->month, $next->mday));
+        $next->mday++;
+        $next = $s->nextRecurrence($next);
+        $this->assertTrue($s->hasCompletion($next->year, $next->month, $next->mday));
+
+        $this->assertEquals(2, count($s->getCompletions()));
+        $this->assertEquals(2, count($s->getExceptions()));
+        $this->assertFalse($s->hasActiveRecurrence());
+
+        $this->assertEquals(2, count($s->getCompletions()));
+        $s->deleteCompletion(1970, 1, 2);
+        $this->assertEquals(1, count($s->getCompletions()));
+        $s->deleteCompletion(1970, 1, 4);
+        $this->assertEquals(0, count($s->getCompletions()));
+    }
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/DayNameTest.php b/framework/Date/test/Horde/Date/Repeater/DayNameTest.php
new file mode 100644 (file)
index 0000000..21231ea
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_DayNameTest extends PHPUnit_Framework_TestCase
+{
+    public function setUp()
+    {
+        $this->now = new Horde_Date('2006-08-16 14:00:00');
+    }
+
+    public function testNextFuture()
+    {
+        $mondays = new Horde_Date_Repeater_DayName('monday');
+        $mondays->now = $this->now;
+
+        $span = $mondays->next('future');
+        $this->assertEquals('2006-08-21', $span->begin->format('Y-m-d'));
+        $this->assertEquals('2006-08-22', $span->end->format('Y-m-d'));
+
+        $span = $mondays->next('future');
+        $this->assertEquals('2006-08-28', $span->begin->format('Y-m-d'));
+        $this->assertEquals('2006-08-29', $span->end->format('Y-m-d'));
+    }
+
+    public function testNextPast()
+    {
+        $mondays = new Horde_Date_Repeater_DayName('monday');
+        $mondays->now = $this->now;
+
+        $span = $mondays->next('past');
+        $this->assertEquals('2006-08-14', $span->begin->format('Y-m-d'));
+        $this->assertEquals('2006-08-15', $span->end->format('Y-m-d'));
+
+        $span = $mondays->next('past');
+        $this->assertEquals('2006-08-07', $span->begin->format('Y-m-d'));
+        $this->assertEquals('2006-08-08', $span->end->format('Y-m-d'));
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/DayTest.php b/framework/Date/test/Horde/Date/Repeater/DayTest.php
new file mode 100644 (file)
index 0000000..ffbcede
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_DayTest extends PHPUnit_Framework_TestCase
+{
+    public function testNextFuture()
+    {
+        $repeater = new Horde_Date_Repeater_Day();
+        $repeater->now = new Horde_Date('2009-01-01');
+        $this->assertEquals('(2009-01-02 00:00:00..2009-01-03 00:00:00)', (string)$repeater->next('future'));
+    }
+
+    public function testNextPast()
+    {
+        $repeater = new Horde_Date_Repeater_Day();
+        $repeater->now = new Horde_Date('2009-01-01');
+        $this->assertEquals('(2008-12-31 00:00:00..2009-01-01 00:00:00)', (string)$repeater->next('past'));
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/HourTest.php b/framework/Date/test/Horde/Date/Repeater/HourTest.php
new file mode 100644 (file)
index 0000000..aed7910
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_HourTest extends PHPUnit_Framework_TestCase
+{
+    public function setUp()
+    {
+        $this->now = new Horde_Date('2006-08-16 14:00:00');
+    }
+
+    public function testNextFuture()
+    {
+        $hours = new Horde_Date_Repeater_Hour();
+        $hours->now = $this->now;
+
+        $nextHour = $hours->next('future');
+        $this->assertEquals('2006-08-16 15:00:00', (string)$nextHour->begin);
+        $this->assertEquals('2006-08-16 16:00:00', (string)$nextHour->end);
+
+        $nextNextHour = $hours->next('future');
+        $this->assertEquals('2006-08-16 16:00:00', (string)$nextNextHour->begin);
+        $this->assertEquals('2006-08-16 17:00:00', (string)$nextNextHour->end);
+    }
+
+    public function testNextPast()
+    {
+        $hours = new Horde_Date_Repeater_Hour();
+        $hours->now = $this->now;
+
+        $pastHour = $hours->next('past');
+        $this->assertEquals('2006-08-16 13:00:00', (string)$pastHour->begin);
+        $this->assertEquals('2006-08-16 14:00:00', (string)$pastHour->end);
+
+        $pastPastHour = $hours->next('past');
+        $this->assertEquals('2006-08-16 12:00:00', (string)$pastPastHour->begin);
+        $this->assertEquals('2006-08-16 13:00:00', (string)$pastPastHour->end);
+    }
+
+    public function testThis()
+    {
+        $hours = new Horde_Date_Repeater_Hour();
+        $hours->now = new Horde_Date('2006-08-16 14:30:00');
+
+        $thisHour = $hours->this('future');
+        $this->assertEquals('2006-08-16 14:31:00', (string)$thisHour->begin);
+        $this->assertEquals('2006-08-16 15:00:00', (string)$thisHour->end);
+
+        $thisHour = $hours->this('past');
+        $this->assertEquals('2006-08-16 14:00:00', (string)$thisHour->begin);
+        $this->assertEquals('2006-08-16 14:30:00', (string)$thisHour->end);
+    }
+
+    public function testOffset()
+    {
+        $span = new Horde_Date_Span($this->now, $this->now->add(1));
+        $hours = new Horde_Date_Repeater_Hour();
+
+        $offsetSpan = $hours->offset($span, 3, 'future');
+        $this->assertEquals('2006-08-16 17:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2006-08-16 17:00:01', (string)$offsetSpan->end);
+
+        $offsetSpan = $hours->offset($span, 24, 'past');
+        $this->assertEquals('2006-08-15 14:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2006-08-15 14:00:01', (string)$offsetSpan->end);
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/MonthNameTest.php b/framework/Date/test/Horde/Date/Repeater/MonthNameTest.php
new file mode 100644 (file)
index 0000000..2e7d005
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_MonthNameTest extends PHPUnit_Framework_TestCase
+{
+    public function setUp()
+    {
+        $this->now = new Horde_Date('2006-08-16 14:00:00');
+    }
+
+    public function testNextFuture()
+    {
+        $mays = new Horde_Date_Repeater_MonthName('may');
+        $mays->now = $this->now;
+
+        $nextMay = $mays->next('future');
+        $this->assertEquals('2007-05-01 00:00:00', (string)$nextMay->begin);
+        $this->assertEquals('2007-06-01 00:00:00', (string)$nextMay->end);
+
+        $nextNextMay = $mays->next('future');
+        $this->assertEquals('2008-05-01 00:00:00', (string)$nextNextMay->begin);
+        $this->assertEquals('2008-06-01 00:00:00', (string)$nextNextMay->end);
+
+        $decembers = new Horde_Date_Repeater_MonthName('december');
+        $decembers->now = $this->now;
+
+        $nextDecember = $decembers->next('future');
+        $this->assertEquals('2006-12-01 00:00:00', (string)$nextDecember->begin);
+        $this->assertEquals('2007-01-01 00:00:00', (string)$nextDecember->end);
+    }
+
+    public function testNextPast()
+    {
+        $mays = new Horde_Date_Repeater_MonthName('may');
+        $mays->now = $this->now;
+
+        $this->assertEquals('2006-05-01 00:00:00', (string)$mays->next('past')->begin);
+        $this->assertEquals('2005-05-01 00:00:00', (string)$mays->next('past')->begin);
+    }
+
+    public function testThis()
+    {
+        $octobers = new Horde_Date_Repeater_MonthName('october');
+        $octobers->now = $this->now;
+
+        $thisOctober = $octobers->this('future');
+        $this->assertEquals('2006-10-01 00:00:00', (string)$thisOctober->begin);
+        $this->assertEquals('2006-11-01 00:00:00', (string)$thisOctober->end);
+
+        $aprils = new Horde_Date_Repeater_MonthName('april');
+        $aprils->now = $this->now;
+
+        $thisApril = $aprils->this('past');
+        $this->assertEquals('2006-04-01 00:00:00', (string)$thisApril->begin);
+        $this->assertEquals('2006-05-01 00:00:00', (string)$thisApril->end);
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/MonthTest.php b/framework/Date/test/Horde/Date/Repeater/MonthTest.php
new file mode 100644 (file)
index 0000000..61395f2
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_MonthTest extends PHPUnit_Framework_TestCase
+{
+    public function setUp()
+    {
+        $this->now = new Horde_Date('2006-08-16 14:00:00');
+    }
+
+    public function testOffsetFuture()
+    {
+        $span = new Horde_Date_Span($this->now, $this->now->add(60));
+        $repeater = new Horde_Date_Repeater_Month();
+        $offsetSpan = $repeater->offset($span, 1, 'future');
+
+        $this->assertEquals('2006-09-16 14:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2006-09-16 14:01:00', (string)$offsetSpan->end);
+    }
+
+    public function testOffsetPast()
+    {
+        $span = new Horde_Date_Span($this->now, $this->now->add(60));
+        $repeater = new Horde_Date_Repeater_Month();
+        $offsetSpan = $repeater->offset($span, 1, 'past');
+
+        $this->assertEquals('2006-07-16 14:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2006-07-16 14:01:00', (string)$offsetSpan->end);
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/TimeTest.php b/framework/Date/test/Horde/Date/Repeater/TimeTest.php
new file mode 100644 (file)
index 0000000..e345455
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_TimeTest extends PHPUnit_Framework_TestCase
+{
+    public function setUp()
+    {
+        $this->now = new Horde_Date('2006-08-16 14:00:00');
+    }
+
+    public function testNextFuture()
+    {
+        $t = new Horde_Date_Repeater_Time('4:00');
+        $t->now = $this->now;
+
+        $this->assertEquals('2006-08-16 16:00:00', (string)$t->next('future')->begin);
+        $this->assertEquals('2006-08-17 04:00:00', (string)$t->next('future')->begin);
+
+        $t = new Horde_Date_Repeater_Time('13:00');
+        $t->now = $this->now;
+
+        $this->assertEquals('2006-08-17 13:00:00', (string)$t->next('future')->begin);
+        $this->assertEquals('2006-08-18 13:00:00', (string)$t->next('future')->begin);
+
+        $t = new Horde_Date_Repeater_Time('0400');
+        $t->now = $this->now;
+
+        $this->assertEquals('2006-08-17 04:00:00', (string)$t->next('future')->begin);
+        $this->assertEquals('2006-08-18 04:00:00', (string)$t->next('future')->begin);
+    }
+
+    public function testNextPast()
+    {
+        $t = new Horde_Date_Repeater_Time('4:00');
+        $t->now = $this->now;
+        $this->assertEquals('2006-08-16 04:00:00', (string)$t->next('past')->begin);
+        $this->assertEquals('2006-08-15 16:00:00', (string)$t->next('past')->begin);
+
+        $t = new Horde_Date_Repeater_Time('13:00');
+        $t->now = $this->now;
+        $this->assertEquals('2006-08-16 13:00:00', (string)$t->next('past')->begin);
+        $this->assertEquals('2006-08-15 13:00:00', (string)$t->next('past')->begin);
+    }
+
+    public function testType()
+    {
+        $t = new Horde_Date_Repeater_Time('4');
+        $this->assertEquals(14400, $t->type);
+
+        $t = new Horde_Date_Repeater_Time('14');
+        $this->assertEquals(50400, $t->type);
+
+        $t = new Horde_Date_Repeater_Time('4:00');
+        $this->assertEquals(14400, $t->type);
+
+        $t = new Horde_Date_Repeater_Time('4:30');
+        $this->assertEquals(16200, $t->type);
+
+        $t = new Horde_Date_Repeater_Time('1400');
+        $this->assertEquals(50400, $t->type);
+
+        $t = new Horde_Date_Repeater_Time('0400');
+        $this->assertEquals(14400, $t->type);
+
+        $t = new Horde_Date_Repeater_Time('04');
+        $this->assertEquals(14400, $t->type);
+
+        $t = new Horde_Date_Repeater_Time('400');
+        $this->assertEquals(14400, $t->type);
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/WeekTest.php b/framework/Date/test/Horde/Date/Repeater/WeekTest.php
new file mode 100644 (file)
index 0000000..8d7865a
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_WeekTest extends PHPUnit_Framework_TestCase
+{
+    public function setUp()
+    {
+        $this->now = new Horde_Date('2006-08-16 14:00:00');
+    }
+
+    public function testNextFuture()
+    {
+        $weeks = new Horde_Date_Repeater_Week();
+        $weeks->now = $this->now;
+
+        $nextWeek = $weeks->next('future');
+        $this->assertEquals('2006-08-20 00:00:00', (string)$nextWeek->begin);
+        $this->assertEquals('2006-08-27 00:00:00', (string)$nextWeek->end);
+
+        $nextNextWeek = $weeks->next('future');
+        $this->assertEquals('2006-08-27 00:00:00', (string)$nextNextWeek->begin);
+        $this->assertEquals('2006-09-03 00:00:00', (string)$nextNextWeek->end);
+    }
+
+    public function testNextPast()
+    {
+        $weeks = new Horde_Date_Repeater_Week();
+        $weeks->now = $this->now;
+
+        $lastWeek = $weeks->next('past');
+        $this->assertEquals('2006-08-06 00:00:00', (string)$lastWeek->begin);
+        $this->assertEquals('2006-08-13 00:00:00', (string)$lastWeek->end);
+
+        $lastLastWeek = $weeks->next('past');
+        $this->assertEquals('2006-07-30 00:00:00', (string)$lastLastWeek->begin);
+        $this->assertEquals('2006-08-06 00:00:00', (string)$lastLastWeek->end);
+    }
+
+    public function testThisFuture()
+    {
+        $weeks = new Horde_Date_Repeater_Week();
+        $weeks->now = $this->now;
+
+        $thisWeek = $weeks->this('future');
+        $this->assertEquals('2006-08-16 15:00:00', (string)$thisWeek->begin);
+        $this->assertEquals('2006-08-20 00:00:00', (string)$thisWeek->end);
+    }
+
+    public function testThisPast()
+    {
+        $weeks = new Horde_Date_Repeater_Week();
+        $weeks->now = $this->now;
+
+        $thisWeek = $weeks->this('past');
+        $this->assertEquals('2006-08-13 00:00:00', (string)$thisWeek->begin);
+        $this->assertEquals('2006-08-16 14:00:00', (string)$thisWeek->end);
+    }
+
+    public function testOffset()
+    {
+        $weeks = new Horde_Date_Repeater_Week();
+        $span = new Horde_Date_Span($this->now, $this->now->add(1));
+
+        $offsetSpan = $weeks->offset($span, 3, 'future');
+        $this->assertEquals('2006-09-06 14:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2006-09-06 14:00:01', (string)$offsetSpan->end);
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/WeekendTest.php b/framework/Date/test/Horde/Date/Repeater/WeekendTest.php
new file mode 100644 (file)
index 0000000..ff05374
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_WeekendTest extends PHPUnit_Framework_TestCase
+{
+    public function setUp()
+    {
+        $this->now = new Horde_Date('2006-08-16 14:00:00');
+    }
+
+    public function testNextFuture()
+    {
+        $weekend = new Horde_Date_Repeater_Weekend();
+        $weekend->now = $this->now;
+
+        $nextWeekend = $weekend->next('future');
+        $this->assertEquals('2006-08-19 00:00:00', (string)$nextWeekend->begin);
+        $this->assertEquals('2006-08-21 00:00:00', (string)$nextWeekend->end);
+    }
+
+    public function testNextPast()
+    {
+        $weekend = new Horde_Date_Repeater_Weekend();
+        $weekend->now = $this->now;
+
+        $lastWeekend = $weekend->next('past');
+        $this->assertEquals('2006-08-12 00:00:00', (string)$lastWeekend->begin);
+        $this->assertEquals('2006-08-14 00:00:00', (string)$lastWeekend->end);
+    }
+
+    public function testThisFuture()
+    {
+        $weekend = new Horde_Date_Repeater_Weekend();
+        $weekend->now = $this->now;
+
+        $thisWeekend = $weekend->this('future');
+        $this->assertEquals('2006-08-19 00:00:00', (string)$thisWeekend->begin);
+        $this->assertEquals('2006-08-21 00:00:00', (string)$thisWeekend->end);
+    }
+
+    public function testThisPast()
+    {
+        $weekend = new Horde_Date_Repeater_Weekend();
+        $weekend->now = $this->now;
+
+        $thisWeekend = $weekend->this('past');
+        $this->assertEquals('2006-08-12 00:00:00', (string)$thisWeekend->begin);
+        $this->assertEquals('2006-08-14 00:00:00', (string)$thisWeekend->end);
+    }
+
+    public function testThisNone()
+    {
+        $weekend = new Horde_Date_Repeater_Weekend();
+        $weekend->now = $this->now;
+
+        $thisWeekend = $weekend->this('none');
+        $this->assertEquals('2006-08-19 00:00:00', (string)$thisWeekend->begin);
+        $this->assertEquals('2006-08-21 00:00:00', (string)$thisWeekend->end);
+    }
+
+    public function testOffset()
+    {
+        $weekend = new Horde_Date_Repeater_Weekend();
+        $span = new Horde_Date_Span($this->now, $this->now->add(1));
+
+        $offsetSpan = $weekend->offset($span, 3, 'future');
+        $this->assertEquals('2006-09-02 00:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2006-09-02 00:00:01', (string)$offsetSpan->end);
+
+        $offsetSpan = $weekend->offset($span, 1, 'past');
+        $this->assertEquals('2006-08-12 00:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2006-08-12 00:00:01', (string)$offsetSpan->end);
+
+        $offsetSpan = $weekend->offset($span, 0, 'future');
+        $this->assertEquals('2006-08-12 00:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2006-08-12 00:00:01', (string)$offsetSpan->end);
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/Repeater/YearTest.php b/framework/Date/test/Horde/Date/Repeater/YearTest.php
new file mode 100644 (file)
index 0000000..0cedf78
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_Repeater_YearTest extends PHPUnit_Framework_TestCase
+{
+    public function setUp()
+    {
+        $this->now = new Horde_Date('2006-08-16 14:00:00');
+    }
+
+    public function testNextFuture()
+    {
+        $years = new Horde_Date_Repeater_Year();
+        $years->now = $this->now;
+
+        $nextYear = $years->next('future');
+        $this->assertEquals('2007-01-01', $nextYear->begin->format('Y-m-d'));
+        $this->assertEquals('2008-01-01', $nextYear->end->format('Y-m-d'));
+
+        $nextNextYear = $years->next('future');
+        $this->assertEquals('2008-01-01', $nextNextYear->begin->format('Y-m-d'));
+        $this->assertEquals('2009-01-01', $nextNextYear->end->format('Y-m-d'));
+    }
+
+    public function testNextPast()
+    {
+        $years = new Horde_Date_Repeater_Year();
+        $years->now = $this->now;
+
+        $lastYear = $years->next('past');
+        $this->assertEquals('2005-01-01', $lastYear->begin->format('Y-m-d'));
+        $this->assertEquals('2006-01-01', $lastYear->end->format('Y-m-d'));
+
+        $lastLastYear = $years->next('past');
+        $this->assertEquals('2004-01-01', $lastLastYear->begin->format('Y-m-d'));
+        $this->assertEquals('2005-01-01', $lastLastYear->end->format('Y-m-d'));
+    }
+
+    public function testThis()
+    {
+        $years = new Horde_Date_Repeater_Year();
+        $years->now = $this->now;
+
+        $thisYear = $years->this('future');
+        $this->assertEquals('2006-08-17', $thisYear->begin->format('Y-m-d'));
+        $this->assertEquals('2007-01-01', $thisYear->end->format('Y-m-d'));
+
+        $thisYear = $years->this('past');
+        $this->assertEquals('2006-01-01', $thisYear->begin->format('Y-m-d'));
+        $this->assertEquals('2006-08-16', $thisYear->end->format('Y-m-d'));
+    }
+
+    public function testOffset()
+    {
+        $span = new Horde_Date_Span($this->now, $this->now->add(1));
+        $years = new Horde_Date_Repeater_Year();
+
+        $offsetSpan = $years->offset($span, 3, 'future');
+        $this->assertEquals('2009-08-16 14:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('2009-08-16 14:00:01', (string)$offsetSpan->end);
+
+        $offsetSpan = $years->offset($span, 10, 'past');
+        $this->assertEquals('1996-08-16 14:00:00', (string)$offsetSpan->begin);
+        $this->assertEquals('1996-08-16 14:00:01', (string)$offsetSpan->end);
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/SpanTest.php b/framework/Date/test/Horde/Date/SpanTest.php
new file mode 100644 (file)
index 0000000..c6424bb
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+
+/**
+ * @category   Horde
+ * @package    Horde_Date
+ * @subpackage UnitTests
+ */
+class Horde_Date_SpanTest extends PHPUnit_Framework_TestCase
+{
+    public function testWidth()
+    {
+        $s = new Horde_Date_Span(new Horde_Date('2006-08-16 00:00:00'), new Horde_Date('2006-08-17 00:00:00'));
+        $this->assertEquals(60 * 60 * 24, $s->width());
+    }
+
+    public function testIncludes()
+    {
+        $s = new Horde_Date_Span(new Horde_Date('2006-08-16 00:00:00'), new Horde_Date('2006-08-17 00:00:00'));
+        $this->assertTrue($s->includes(new Horde_Date('2006-08-16 12:00:00')));
+        $this->assertFalse($s->includes(new Horde_Date('2006-08-15 00:00:00')));
+        $this->assertFalse($s->includes(new Horde_Date('2006-08-18 00:00:00')));
+    }
+
+    public function testSpanMath()
+    {
+        $s = new Horde_Date_Span(new Horde_Date(1), new Horde_Date(2));
+        $this->assertEquals(2, $s->add(1)->begin->timestamp());
+        $this->assertEquals(3, $s->add(1)->end->timestamp());
+        $this->assertEquals(0, $s->sub(1)->begin->timestamp());
+        $this->assertEquals(1, $s->sub(1)->end->timestamp());
+    }
+
+}
diff --git a/framework/Date/test/Horde/Date/fixtures/bug2813.ics b/framework/Date/test/Horde/Date/fixtures/bug2813.ics
new file mode 100644 (file)
index 0000000..2f6371f
--- /dev/null
@@ -0,0 +1,34 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+X-WR-CALNAME:Kronolith Bug
+PRODID:-//Apple Computer\, Inc//iCal 2.0//EN
+X-WR-RELCALID:6478A81B-4979-4F9D-B9DD-E3F8DD360B96
+X-WR-TIMEZONE:US/Eastern
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+BEGIN:VTIMEZONE
+TZID:US/Eastern
+LAST-MODIFIED:20060616T214021Z
+BEGIN:DAYLIGHT
+DTSTART:20060402T070000
+TZOFFSETTO:-0400
+TZOFFSETFROM:+0000
+TZNAME:EDT
+END:DAYLIGHT
+BEGIN:STANDARD
+DTSTART:20061029T020000
+TZOFFSETTO:-0500
+TZOFFSETFROM:-0400
+TZNAME:EST
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTART;TZID=US/Eastern:20060616T180000
+DTEND;TZID=US/Eastern:20060616T190000
+SUMMARY:Test 2
+UID:3496A948-E0AB-49FF-B76F-45B700B86AF2
+SEQUENCE:8
+DTSTAMP:20060616T212821Z
+RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20060618T035959Z
+END:VEVENT
+END:VCALENDAR