1<?php
2
3/**
4 * Recurrence computation class for xcal-based Kolab format objects
5 *
6 * Utility class to compute instances of recurring events.
7 * It requires the libcalendaring PHP module to be installed and loaded.
8 *
9 * @version @package_version@
10 * @author Thomas Bruederli <bruederli@kolabsys.com>
11 *
12 * Copyright (C) 2012-2016, Kolab Systems AG <contact@kolabsys.com>
13 *
14 * This program is free software: you can redistribute it and/or modify
15 * it under the terms of the GNU Affero General Public License as
16 * published by the Free Software Foundation, either version 3 of the
17 * License, or (at your option) any later version.
18 *
19 * This program is distributed in the hope that it will be useful,
20 * but WITHOUT ANY WARRANTY; without even the implied warranty of
21 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22 * GNU Affero General Public License for more details.
23 *
24 * You should have received a copy of the GNU Affero General Public License
25 * along with this program. If not, see <http://www.gnu.org/licenses/>.
26 */
27class kolab_date_recurrence
28{
29    private /* EventCal */ $engine;
30    private /* kolab_format_xcal */ $object;
31    private /* DateTime */ $start;
32    private /* DateTime */ $next;
33    private /* cDateTime */ $cnext;
34    private /* DateInterval */ $duration;
35    private /* bool */ $allday;
36
37
38    /**
39     * Default constructor
40     *
41     * @param kolab_format_xcal The Kolab object to operate on
42     */
43    function __construct($object)
44    {
45        $data = $object->to_array();
46
47        $this->object = $object;
48        $this->engine = $object->to_libcal();
49        $this->start  = $this->next = $data['start'];
50        $this->allday = !empty($data['allday']);
51        $this->cnext  = kolab_format::get_datetime($this->next);
52
53        if (is_object($data['start']) && is_object($data['end'])) {
54            $this->duration = $data['start']->diff($data['end']);
55        }
56        else {
57            // Prevent from errors when end date is not set (#5307) RFC5545 3.6.1
58            $seconds = !empty($data['end']) ? ($data['end'] - $data['start']) : 0;
59            $this->duration = new DateInterval('PT' . $seconds . 'S');
60        }
61    }
62
63    /**
64     * Get date/time of the next occurence of this event
65     *
66     * @param boolean Return a Unix timestamp instead of a DateTime object
67     *
68     * @return mixed  DateTime object/unix timestamp or False if recurrence ended
69     */
70    public function next_start($timestamp = false)
71    {
72        $time = false;
73
74        if ($this->engine && $this->next) {
75            if (($cnext = new cDateTime($this->engine->getNextOccurence($this->cnext))) && $cnext->isValid()) {
76                $next = kolab_format::php_datetime($cnext, $this->start->getTimezone());
77                $time = $timestamp ? $next->format('U') : $next;
78
79                if ($this->allday) {
80                    // it looks that for allday events the occurrence time
81                    // is reset to 00:00:00, this is causing various issues
82                    $next->setTime($this->start->format('G'), $this->start->format('i'), $this->start->format('s'));
83                    $next->_dateonly = true;
84                }
85
86                $this->cnext = $cnext;
87                $this->next  = $next;
88            }
89        }
90
91        return $time;
92    }
93
94    /**
95     * Get the next recurring instance of this event
96     *
97     * @return mixed Array with event properties or False if recurrence ended
98     */
99    public function next_instance()
100    {
101        if ($next_start = $this->next_start()) {
102            $next_end = clone $next_start;
103            $next_end->add($this->duration);
104
105            $next                    = $this->object->to_array();
106            $recurrence_id_format    = libkolab::recurrence_id_format($next);
107            $next['start']           = $next_start;
108            $next['end']             = $next_end;
109            $next['recurrence_date'] = clone $next_start;
110            $next['_instance']       = $next_start->format($recurrence_id_format);
111
112            unset($next['_formatobj']);
113
114            return $next;
115        }
116
117        return false;
118    }
119
120    /**
121     * Get the end date of the occurence of this recurrence cycle
122     *
123     * @return DateTime|bool End datetime of the last event or False if recurrence exceeds limit
124     */
125    public function end()
126    {
127        $event = $this->object->to_array();
128
129        // recurrence end date is given
130        if ($event['recurrence']['UNTIL'] instanceof DateTime) {
131            return $event['recurrence']['UNTIL'];
132        }
133
134        // let libkolab do the work
135        if ($this->engine && ($cend = $this->engine->getLastOccurrence())
136            && ($end_dt = kolab_format::php_datetime(new cDateTime($cend)))
137        ) {
138            return $end_dt;
139        }
140
141        // determine a reasonable end date if none given
142        if (!$event['recurrence']['COUNT'] && $event['end'] instanceof DateTime) {
143            $end_dt = clone $event['end'];
144            $end_dt->add(new DateInterval('P100Y'));
145
146            return $end_dt;
147        }
148
149        return false;
150    }
151
152    /**
153     * Find date/time of the first occurrence
154     */
155    public function first_occurrence()
156    {
157        $event      = $this->object->to_array();
158        $start      = clone $this->start;
159        $orig_start = clone $this->start;
160        $interval   = intval($event['recurrence']['INTERVAL'] ?: 1);
161
162        switch ($event['recurrence']['FREQ']) {
163        case 'WEEKLY':
164            if (empty($event['recurrence']['BYDAY'])) {
165                return $orig_start;
166            }
167
168            $start->sub(new DateInterval("P{$interval}W"));
169            break;
170
171        case 'MONTHLY':
172            if (empty($event['recurrence']['BYDAY']) && empty($event['recurrence']['BYMONTHDAY'])) {
173                return $orig_start;
174            }
175
176            $start->sub(new DateInterval("P{$interval}M"));
177            break;
178
179        case 'YEARLY':
180            if (empty($event['recurrence']['BYDAY']) && empty($event['recurrence']['BYMONTH'])) {
181                return $orig_start;
182            }
183
184            $start->sub(new DateInterval("P{$interval}Y"));
185            break;
186
187        case 'DAILY':
188            if (!empty($event['recurrence']['BYMONTH'])) {
189                break;
190            }
191
192        default:
193            return $orig_start;
194        }
195
196        $event['start'] = $start;
197        $event['recurrence']['INTERVAL'] = $interval;
198        if ($event['recurrence']['COUNT']) {
199            // Increase count so we do not stop the loop to early
200            $event['recurrence']['COUNT'] += 100;
201        }
202
203        // Create recurrence that starts in the past
204        $object_type = $this->object instanceof kolab_format_task ? 'task' : 'event';
205        $object      = kolab_format::factory($object_type, 3.0);
206        $object->set($event);
207        $recurrence = new self($object);
208
209        $orig_date = $orig_start->format('Y-m-d');
210        $found     = false;
211
212        // find the first occurrence
213        while ($next = $recurrence->next_start()) {
214            $start = $next;
215            if ($next->format('Y-m-d') >= $orig_date) {
216                $found = true;
217                break;
218            }
219        }
220
221        if (!$found) {
222            rcube::raise_error(array(
223                'file' => __FILE__,
224                'line' => __LINE__,
225                'message' => sprintf("Failed to find a first occurrence. Start: %s, Recurrence: %s",
226                    $orig_start->format(DateTime::ISO8601), json_encode($event['recurrence'])),
227            ), true);
228
229            return null;
230        }
231
232        if ($this->allday) {
233            $start->_dateonly = true;
234        }
235
236        return $start;
237    }
238}
239