BaseRecurrence   F
last analyzed

Complexity

Total Complexity 377

Size/Duplication

Total Lines 2075
Duplicated Lines 0 %

Importance

Changes 12
Bugs 1 Features 0
Metric Value
eloc 933
c 12
b 1
f 0
dl 0
loc 2075
rs 1.667
wmc 377

26 Methods

Rating   Name   Duplication   Size   Complexity  
A monthStartOf() 0 4 1
A toGMT() 0 7 2
A GetTZOffset() 0 8 2
A fromGMT() 0 4 1
A dayStartOf() 0 4 1
A __construct() 0 19 5
A parseTimezone() 0 6 2
A getMonthInSeconds() 0 15 4
A recurDataToUnixData() 0 2 1
A getDateByYearMonthWeekDayHour() 0 23 2
A gmtime() 0 6 1
A getRecurrence() 0 2 1
A getTimezoneData() 0 2 1
A getFullRecurrenceBlob() 0 13 3
B getTimezone() 0 30 8
A isLeapYear() 0 2 3
A unixDataToRecurData() 0 2 1
A yearStartOf() 0 4 1
F parseRecurrence() 0 454 72
F saveRecurrence() 0 823 169
A sortExceptionStart() 0 2 3
A daysInMonth() 0 8 2
A sortStarttime() 0 5 3
B getExceptionProperties() 0 43 8
F getItems() 0 269 79
A monthOfYear() 0 8 1

How to fix   Complexity   

Complex Class

Complex classes like BaseRecurrence often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use BaseRecurrence, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/*
4
 * SPDX-License-Identifier: AGPL-3.0-only
5
 * SPDX-FileCopyrightText: Copyright 2005-2016 Zarafa Deutschland GmbH
6
 * SPDX-FileCopyrightText: Copyright 2020-2025 grommunio GmbH
7
 */
8
9
/**
10
 * BaseRecurrence
11
 * this class is superclass for recurrence for appointments and tasks. This class provides all
12
 * basic features of recurrence.
13
 */
14
abstract class BaseRecurrence {
15
	/**
16
	 * @var mixed Mapi Message (may be null if readonly)
17
	 */
18
	public $message;
19
20
	/**
21
	 * @var array Message Properties
22
	 */
23
	public $messageprops;
24
25
	/**
26
	 * @var array list of property tags
27
	 */
28
	public $proptags;
29
30
	/**
31
	 * @var mixed recurrence data of this calendar item
32
	 */
33
	public $recur;
34
35
	/**
36
	 * @var mixed Timezone data of this calendar item
37
	 */
38
	public $tz;
39
40
	/**
41
	 * Constructor.
42
	 *
43
	 * @param resource $store   MAPI Message Store Object
44
	 * @param mixed    $message the MAPI (appointment) message
45
	 */
46
	public function __construct(public $store, $message) {
47
		if (is_array($message)) {
48
			$this->messageprops = $message;
49
		}
50
		else {
51
			$this->message = $message;
52
			$this->messageprops = mapi_getprops($this->message, $this->proptags);
53
		}
54
55
		if (isset($this->messageprops[$this->proptags["recurring_data"]])) {
56
			// There is a possibility that recurr blob can be more than 255 bytes so get full blob through stream interface
57
			if (strlen((string) $this->messageprops[$this->proptags["recurring_data"]]) >= 255) {
58
				$this->getFullRecurrenceBlob();
59
			}
60
61
			$this->recur = $this->parseRecurrence($this->messageprops[$this->proptags["recurring_data"]]);
62
		}
63
		if (isset($this->proptags["timezone_data"], $this->messageprops[$this->proptags["timezone_data"]])) {
64
			$this->tz = $this->parseTimezone($this->messageprops[$this->proptags["timezone_data"]]);
65
		}
66
	}
67
68
	public function getRecurrence() {
69
		return $this->recur;
70
	}
71
72
	public function getFullRecurrenceBlob(): void {
73
		$message = mapi_msgstore_openentry($this->store, $this->messageprops[PR_ENTRYID]);
0 ignored issues
show
Bug introduced by
$this->store of type resource is incompatible with the type resource expected by parameter $store of mapi_msgstore_openentry(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

73
		$message = mapi_msgstore_openentry(/** @scrutinizer ignore-type */ $this->store, $this->messageprops[PR_ENTRYID]);
Loading history...
74
75
		$recurrBlob = '';
76
		$stream = mapi_openproperty($message, $this->proptags["recurring_data"], IID_IStream, 0, 0);
77
		$stat = mapi_stream_stat($stream);
78
79
		for ($i = 0; $i < $stat['cb']; $i += 1024) {
80
			$recurrBlob .= mapi_stream_read($stream, 1024);
81
		}
82
83
		if (!empty($recurrBlob)) {
84
			$this->messageprops[$this->proptags["recurring_data"]] = $recurrBlob;
85
		}
86
	}
87
88
	/**
89
	 * Function for parsing the Recurrence value of a Calendar item.
90
	 *
91
	 * Retrieve it from Named Property 0x8216 as a PT_BINARY and pass the
92
	 * data to this function
93
	 *
94
	 * Returns a structure containing the data:
95
	 *
96
	 * type  - type of recurrence: day=10, week=11, month=12, year=13
97
	 * subtype - type of day recurrence: 2=monthday (ie 21st day of month), 3=nday'th weekdays (ie. 2nd Tuesday and Wednesday)
98
	 * start - unix timestamp of first occurrence
99
	 * end  - unix timestamp of last occurrence (up to and including), so when start == end -> occurrences = 1
100
	 * numoccur     - occurrences (may be very large when there is no end data)
101
	 * first_dow    - first day-of-week for weekly recurrences (0 = Sunday, 1 = Monday, ...)
102
	 *
103
	 * then, for each type:
104
	 *
105
	 * Daily:
106
	 *  everyn - every [everyn] days in minutes
107
	 *  regen - regenerating event (like tasks)
108
	 *
109
	 * Weekly:
110
	 *  everyn - every [everyn] weeks in weeks
111
	 *  regen - regenerating event (like tasks)
112
	 *  weekdays - bitmask of week days, where each bit is one weekday (weekdays & 1 = Sunday, weekdays & 2 = Monday, etc)
113
	 *
114
	 * Monthly:
115
	 *  everyn - every [everyn] months
116
	 *  regen - regenerating event (like tasks)
117
	 *
118
	 *  subtype 2:
119
	 *   monthday - on day [monthday] of the month
120
	 *
121
	 *  subtype 3:
122
	 *   weekdays - bitmask of week days, where each bit is one weekday (weekdays & 1 = Sunday, weekdays & 2 = Monday, etc)
123
	 *   nday - on [nday]'th [weekdays] of the month
124
	 *
125
	 * Yearly:
126
	 *  everyn - every [everyn] months (12, 24, 36, ...)
127
	 *  month - in month [month] (although the month is encoded in minutes since the startning of the year ........)
128
	 *  regen - regenerating event (like tasks)
129
	 *
130
	 *  subtype 2:
131
	 *   monthday - on day [monthday] of the month
132
	 *
133
	 *  subtype 3:
134
	 *   weekdays - bitmask of week days, where each bit is one weekday (weekdays & 1 = Sunday, weekdays & 2 = Monday, etc)
135
	 *   nday - on [nday]'th [weekdays] of the month [month]
136
	 *
137
	 * @param string $rdata Binary string
138
	 *
139
	 * @return null|(((false|int|mixed|string)[]|int)[]|int|mixed)[] recurrence data
140
	 *
141
	 * @psalm-return array{changed_occurrences: array<int, array{basedate: false|int, start: int, end: int, bitmask: mixed, subject?: false|string, remind_before?: mixed, reminder_set?: mixed, location?: false|string, busystatus?: mixed, alldayevent?: mixed, label?: mixed, ex_start_datetime?: mixed, ex_end_datetime?: mixed, ex_orig_date?: mixed}>, deleted_occurrences: list<int>, type?: int|mixed, subtype?: mixed, month?: mixed, everyn?: mixed, regen?: mixed, monthday?: mixed, weekdays?: 0|mixed, nday?: mixed, term?: int|mixed, numoccur?: mixed, numexcept?: mixed, first_dow?: mixed, numexceptmod?: mixed, start?: int, end?: int, startocc?: mixed, endocc?: mixed}|null
142
	 */
143
	public function parseRecurrence($rdata) {
144
		if (strlen($rdata) < 10) {
145
			return;
146
		}
147
148
		$ret = [];
149
		$ret["changed_occurrences"] = [];
150
		$ret["deleted_occurrences"] = [];
151
152
		$data = unpack("vReaderVersion/vWriterVersion/vrtype/vrtype2/vCalendarType", $rdata);
153
154
		// Do some recurrence validity checks
155
		if ($data['ReaderVersion'] != 0x3004 || $data['WriterVersion'] != 0x3004) {
156
			return $ret;
157
		}
158
159
		if (!in_array($data["rtype"], [IDC_RCEV_PAT_ORB_DAILY, IDC_RCEV_PAT_ORB_WEEKLY, IDC_RCEV_PAT_ORB_MONTHLY, IDC_RCEV_PAT_ORB_YEARLY])) {
160
			return $ret;
161
		}
162
163
		if (!in_array($data["rtype2"], [rptDay, rptWeek, rptMonth, rptMonthNth, rptMonthEnd, rptHjMonth, rptHjMonthNth, rptHjMonthEnd])) {
164
			return $ret;
165
		}
166
167
		if (!in_array($data['CalendarType'], [MAPI_CAL_DEFAULT, MAPI_CAL_GREGORIAN])) {
168
			return $ret;
169
		}
170
171
		$ret["type"] = (int) $data["rtype"] > 0x2000 ? (int) $data["rtype"] - 0x2000 : $data["rtype"];
172
		$ret["subtype"] = $data["rtype2"];
173
		$rdata = substr($rdata, 10);
174
175
		switch ($data["rtype"]) {
176
			case IDC_RCEV_PAT_ORB_DAILY:
177
				if (strlen($rdata) < 12) {
178
					return $ret;
179
				}
180
181
				$data = unpack("Vunknown/Veveryn/Vregen", $rdata);
182
				if ($data["everyn"] > 1438560) { // minutes for 999 days
183
					return $ret;
184
				}
185
				$ret["everyn"] = $data["everyn"];
186
				$ret["regen"] = $data["regen"];
187
188
				switch ($ret["subtype"]) {
189
					case rptDay:
190
						$rdata = substr($rdata, 12);
191
						break;
192
193
					case rptWeek:
194
						$rdata = substr($rdata, 16);
195
						break;
196
				}
197
198
				break;
199
200
			case IDC_RCEV_PAT_ORB_WEEKLY:
201
				if (strlen($rdata) < 16) {
202
					return $ret;
203
				}
204
205
				$data = unpack("Vconst1/Veveryn/Vregen", $rdata);
206
				if ($data["everyn"] > 99) {
207
					return $ret;
208
				}
209
210
				$rdata = substr($rdata, 12);
211
212
				$ret["everyn"] = $data["everyn"];
213
				$ret["regen"] = $data["regen"];
214
				$ret["weekdays"] = 0;
215
216
				if ($data["regen"] == 0) {
217
					$data = unpack("Vweekdays", $rdata);
218
					$rdata = substr($rdata, 4);
219
220
					$ret["weekdays"] = $data["weekdays"];
221
				}
222
				break;
223
224
			case IDC_RCEV_PAT_ORB_MONTHLY:
225
				if (strlen($rdata) < 16) {
226
					return $ret;
227
				}
228
229
				$data = unpack("Vconst1/Veveryn/Vregen/Vmonthday", $rdata);
230
				if ($data["everyn"] > 99) {
231
					return $ret;
232
				}
233
234
				$ret["everyn"] = $data["everyn"];
235
				$ret["regen"] = $data["regen"];
236
237
				if ($ret["subtype"] == rptMonthNth) {
238
					$ret["weekdays"] = $data["monthday"];
239
				}
240
				else {
241
					$ret["monthday"] = $data["monthday"];
242
				}
243
244
				$rdata = substr($rdata, 16);
245
				if ($ret["subtype"] == rptMonthNth) {
246
					$data = unpack("Vnday", $rdata);
247
					// Sanity check for valid values (and opportunistically try to fix)
248
					if ($data["nday"] == 0xFFFFFFFF || $data["nday"] == -1) {
249
						$data["nday"] = 5;
250
					}
251
					elseif ($data["nday"] < 0 || $data["nday"] > 5) {
252
						$data["nday"] = 0;
253
					}
254
					$ret["nday"] = $data["nday"];
255
					$rdata = substr($rdata, 4);
256
				}
257
				break;
258
259
			case IDC_RCEV_PAT_ORB_YEARLY:
260
				if (strlen($rdata) < 16) {
261
					return $ret;
262
				}
263
264
				$data = unpack("Vmonth/Veveryn/Vregen/Vmonthday", $rdata);
265
				// recurring yearly tasks have a period in months multiple by 12
266
				if ($data['regen'] && $data["everyn"] % 12 != 0) {
267
					return $ret;
268
				}
269
				if (!$data['regen'] && $data["everyn"] != 12) {
270
					return $ret;
271
				}
272
273
				$ret["month"] = $data["month"];
274
				$ret["everyn"] = $data["everyn"];
275
				$ret["regen"] = $data["regen"];
276
277
				if ($ret["subtype"] == rptMonthNth) {
278
					$ret["weekdays"] = $data["monthday"];
279
				}
280
				else {
281
					$ret["monthday"] = $data["monthday"];
282
				}
283
284
				$rdata = substr($rdata, 16);
285
286
				if ($ret["subtype"] == rptMonthNth) {
287
					$data = unpack("Vnday", $rdata);
288
					// Sanity check for valid values (and opportunistically try to fix)
289
					if ($data["nday"] == 0xFFFFFFFF || $data["nday"] == -1) {
290
						$data["nday"] = 5;
291
					}
292
					elseif ($data["nday"] < 0 || $data["nday"] > 5) {
293
						$data["nday"] = 0;
294
					}
295
					$ret["nday"] = $data["nday"];
296
					$rdata = substr($rdata, 4);
297
				}
298
				break;
299
		}
300
301
		if (strlen($rdata) < 16) {
302
			return $ret;
303
		}
304
305
		$data = unpack("Vterm/Vnumoccur/Vconst2/Vnumexcept", $rdata);
306
		$rdata = substr($rdata, 16);
307
		if (!in_array($data["term"], [IDC_RCEV_PAT_ERB_END, IDC_RCEV_PAT_ERB_AFTERNOCCUR, IDC_RCEV_PAT_ERB_NOEND, 0xFFFFFFFF])) {
308
			return $ret;
309
		}
310
311
		$ret["term"] = (int) $data["term"] > 0x2000 ? (int) $data["term"] - 0x2000 : $data["term"];
312
		$ret["numoccur"] = $data["numoccur"];
313
		$ret["first_dow"] = $data["const2"];
314
		$ret["numexcept"] = $data["numexcept"];
315
316
		// exc_base_dates are *all* the base dates that have been either deleted or modified
317
		$exc_base_dates = [];
318
		for ($i = 0; $i < $ret["numexcept"]; ++$i) {
319
			if (strlen($rdata) < 4) {
320
				// We shouldn't arrive here, because that implies
321
				// numexcept does not match the amount of data
322
				// which is available for the exceptions.
323
				return $ret;
324
			}
325
			$data = unpack("Vbasedate", $rdata);
326
			$rdata = substr($rdata, 4);
327
			$exc_base_dates[] = $this->recurDataToUnixData($data["basedate"]);
328
		}
329
330
		if (strlen($rdata) < 4) {
331
			return $ret;
332
		}
333
334
		$data = unpack("Vnumexceptmod", $rdata);
335
		$rdata = substr($rdata, 4);
336
337
		$ret["numexceptmod"] = $data["numexceptmod"];
338
339
		// exc_changed are the base dates of *modified* occurrences. exactly what is modified
340
		// is in the attachments *and* in the data further down this function.
341
		$exc_changed = [];
342
		for ($i = 0; $i < $ret["numexceptmod"]; ++$i) {
343
			if (strlen($rdata) < 4) {
344
				// We shouldn't arrive here, because that implies
345
				// numexceptmod does not match the amount of data
346
				// which is available for the exceptions.
347
				return $ret;
348
			}
349
			$data = unpack("Vstartdate", $rdata);
350
			$rdata = substr($rdata, 4);
351
			$exc_changed[] = $this->recurDataToUnixData($data["startdate"]);
352
		}
353
354
		if (strlen($rdata) < 8) {
355
			return $ret;
356
		}
357
358
		$data = unpack("Vstart/Vend", $rdata);
359
		$rdata = substr($rdata, 8);
360
361
		$ret["start"] = $this->recurDataToUnixData($data["start"]);
362
		$ret["end"] = $this->recurDataToUnixData($data["end"]);
363
364
		// this is where task recurrence stop
365
		if (strlen($rdata) < 16) {
366
			return $ret;
367
		}
368
369
		$data = unpack("Vreaderversion/Vwriterversion/Vstartmin/Vendmin", $rdata);
370
		$rdata = substr($rdata, 16);
371
372
		$ret["startocc"] = $data["startmin"];
373
		$ret["endocc"] = $data["endmin"];
374
		$writerversion = $data["writerversion"];
375
376
		$data = unpack("vnumber", $rdata);
377
		$rdata = substr($rdata, 2);
378
379
		$nexceptions = $data["number"];
380
		$exc_changed_details = [];
381
382
		// Parse n modified exceptions
383
		for ($i = 0; $i < $nexceptions; ++$i) {
384
			$item = [];
385
386
			// Get exception startdate, enddate and basedate (the date at which the occurrence would have started)
387
			$data = unpack("Vstartdate/Venddate/Vbasedate", $rdata);
388
			$rdata = substr($rdata, 12);
389
390
			// Convert recurtimestamp to unix timestamp
391
			$startdate = $this->recurDataToUnixData($data["startdate"]);
392
			$enddate = $this->recurDataToUnixData($data["enddate"]);
393
			$basedate = $this->recurDataToUnixData($data["basedate"]);
394
395
			// Set the right properties
396
			$item["basedate"] = $this->dayStartOf($basedate);
397
			$item["start"] = $startdate;
398
			$item["end"] = $enddate;
399
400
			$data = unpack("vbitmask", $rdata);
401
			$rdata = substr($rdata, 2);
402
			$item["bitmask"] = $data["bitmask"]; // save bitmask for extended exceptions
403
404
			// Bitmask to verify what properties are changed
405
			$bitmask = $data["bitmask"];
406
407
			// ARO_SUBJECT: 0x0001
408
			// Look for field: SubjectLength (2b), SubjectLength2 (2b) and Subject
409
			if ($bitmask & (1 << 0)) {
410
				$data = unpack("vnull_length/vlength", $rdata);
411
				$rdata = substr($rdata, 4);
412
413
				$length = $data["length"];
414
				$item["subject"] = ""; // Normalized subject
415
				for ($j = 0; $j < $length && strlen($rdata); ++$j) {
416
					$data = unpack("Cchar", $rdata);
417
					$rdata = substr($rdata, 1);
418
419
					$item["subject"] .= chr($data["char"]);
420
				}
421
			}
422
423
			// ARO_MEETINGTYPE: 0x0002
424
			if ($bitmask & (1 << 1)) {
425
				$rdata = substr($rdata, 4);
426
				// Attendees modified: no data here (only in attachment)
427
			}
428
429
			// ARO_REMINDERDELTA: 0x0004
430
			// Look for field: ReminderDelta (4b)
431
			if ($bitmask & (1 << 2)) {
432
				$data = unpack("Vremind_before", $rdata);
433
				$rdata = substr($rdata, 4);
434
435
				$item["remind_before"] = $data["remind_before"];
436
			}
437
438
			// ARO_REMINDER: 0x0008
439
			// Look field: ReminderSet (4b)
440
			if ($bitmask & (1 << 3)) {
441
				$data = unpack("Vreminder_set", $rdata);
442
				$rdata = substr($rdata, 4);
443
444
				$item["reminder_set"] = $data["reminder_set"];
445
			}
446
447
			// ARO_LOCATION: 0x0010
448
			// Look for fields: LocationLength (2b), LocationLength2 (2b) and Location
449
			// Similar to ARO_SUBJECT above.
450
			if ($bitmask & (1 << 4)) {
451
				$data = unpack("vnull_length/vlength", $rdata);
452
				$rdata = substr($rdata, 4);
453
454
				$item["location"] = "";
455
456
				$length = $data["length"];
457
				$data = substr($rdata, 0, $length);
458
				$rdata = substr($rdata, $length);
459
460
				$item["location"] .= $data;
461
			}
462
463
			// ARO_BUSYSTATUS: 0x0020
464
			// Look for field: BusyStatus (4b)
465
			if ($bitmask & (1 << 5)) {
466
				$data = unpack("Vbusystatus", $rdata);
467
				$rdata = substr($rdata, 4);
468
469
				$item["busystatus"] = $data["busystatus"];
470
			}
471
472
			// ARO_ATTACHMENT: 0x0040
473
			if ($bitmask & (1 << 6)) {
474
				// no data: RESERVED
475
				$rdata = substr($rdata, 4);
476
			}
477
478
			// ARO_SUBTYPE: 0x0080
479
			// Look for field: SubType (4b). Determines whether it is an allday event.
480
			if ($bitmask & (1 << 7)) {
481
				$data = unpack("Vallday", $rdata);
482
				$rdata = substr($rdata, 4);
483
484
				$item["alldayevent"] = $data["allday"];
485
			}
486
487
			// ARO_APPTCOLOR: 0x0100
488
			// Look for field: AppointmentColor (4b)
489
			if ($bitmask & (1 << 8)) {
490
				$data = unpack("Vlabel", $rdata);
491
				$rdata = substr($rdata, 4);
492
493
				$item["label"] = $data["label"];
494
			}
495
496
			// ARO_EXCEPTIONAL_BODY: 0x0200
497
			if ($bitmask & (1 << 9)) {
498
				// Notes or Attachments modified: no data here (only in attachment)
499
			}
500
501
			array_push($exc_changed_details, $item);
502
		}
503
504
		/**
505
		 * We now have $exc_changed, $exc_base_dates and $exc_changed_details
506
		 * We will ignore $exc_changed, as this information is available in $exc_changed_details
507
		 * also. If an item is in $exc_base_dates and NOT in $exc_changed_details, then the item
508
		 * has been deleted.
509
		 */
510
511
		// Find deleted occurrences
512
		$deleted_occurrences = [];
513
514
		foreach ($exc_base_dates as $base_date) {
515
			$found = false;
516
517
			foreach ($exc_changed_details as $details) {
518
				if ($details["basedate"] == $base_date) {
519
					$found = true;
520
					break;
521
				}
522
			}
523
			if (!$found) {
524
				// item was not in exc_changed_details, so it must be deleted
525
				$deleted_occurrences[] = $base_date;
526
			}
527
		}
528
529
		$ret["deleted_occurrences"] = $deleted_occurrences;
530
		$ret["changed_occurrences"] = $exc_changed_details;
531
532
		// enough data for normal exception (no extended data)
533
		if (strlen($rdata) < 8) {
534
			return $ret;
535
		}
536
537
		$data = unpack("Vreservedsize", $rdata);
538
		$rdata = substr($rdata, 4 + $data["reservedsize"]);
539
540
		for ($i = 0; $i < $nexceptions; ++$i) {
541
			// subject and location in ucs-2 to utf-8
542
			if ($writerversion >= 0x3009) {
543
				$data = unpack("Vsize/Vvalue", $rdata); // size includes sizeof(value)==4
544
				$rdata = substr($rdata, 4 + $data["size"]);
545
			}
546
547
			$data = unpack("Vreservedsize", $rdata);
548
			$rdata = substr($rdata, 4 + $data["reservedsize"]);
549
550
			// ARO_SUBJECT(0x01) | ARO_LOCATION(0x10)
551
			if ($exc_changed_details[$i]["bitmask"] & 0x11) {
552
				$data = unpack("Vstart/Vend/Vorig", $rdata);
553
				$rdata = substr($rdata, 4 * 3);
554
555
				$exc_changed_details[$i]["ex_start_datetime"] = $data["start"];
556
				$exc_changed_details[$i]["ex_end_datetime"] = $data["end"];
557
				$exc_changed_details[$i]["ex_orig_date"] = $data["orig"];
558
			}
559
560
			// ARO_SUBJECT
561
			if ($exc_changed_details[$i]["bitmask"] & 0x01) {
562
				// decode ucs2 string to utf-8
563
				$data = unpack("vlength", $rdata);
564
				$rdata = substr($rdata, 2);
565
				$length = $data["length"];
566
				$data = substr($rdata, 0, $length * 2);
567
				$rdata = substr($rdata, $length * 2);
568
				$subject = iconv("UCS-2LE", "UTF-8", $data);
569
				// replace subject with unicode subject
570
				$exc_changed_details[$i]["subject"] = $subject;
571
			}
572
573
			// ARO_LOCATION
574
			if ($exc_changed_details[$i]["bitmask"] & 0x10) {
575
				// decode ucs2 string to utf-8
576
				$data = unpack("vlength", $rdata);
577
				$rdata = substr($rdata, 2);
578
				$length = $data["length"];
579
				$data = substr($rdata, 0, $length * 2);
580
				$rdata = substr($rdata, $length * 2);
581
				$location = iconv("UCS-2LE", "UTF-8", $data);
582
				// replace subject with unicode subject
583
				$exc_changed_details[$i]["location"] = $location;
584
			}
585
586
			// ARO_SUBJECT(0x01) | ARO_LOCATION(0x10)
587
			if ($exc_changed_details[$i]["bitmask"] & 0x11) {
588
				$data = unpack("Vreservedsize", $rdata);
589
				$rdata = substr($rdata, 4 + $data["reservedsize"]);
590
			}
591
		}
592
593
		// update with extended data
594
		$ret["changed_occurrences"] = $exc_changed_details;
595
596
		return $ret;
597
	}
598
599
	/**
600
	 * Saves the recurrence data to the recurrence property.
601
	 */
602
	public function saveRecurrence(): void {
603
		// Only save if a message was passed
604
		if (!isset($this->message)) {
605
			return;
606
		}
607
608
		// Abort if no recurrence was set
609
		if (!isset(
610
			$this->recur["type"],
611
			$this->recur["subtype"],
612
			$this->recur["start"],
613
			$this->recur["end"],
614
			$this->recur["startocc"],
615
			$this->recur["endocc"])
616
		) {
617
			return;
618
		}
619
620
		$rtype = 0x2000 + (int) $this->recur["type"];
621
622
		// Don't allow invalid type and subtype values
623
		if (!in_array($rtype, [IDC_RCEV_PAT_ORB_DAILY, IDC_RCEV_PAT_ORB_WEEKLY, IDC_RCEV_PAT_ORB_MONTHLY, IDC_RCEV_PAT_ORB_YEARLY])) {
624
			return;
625
		}
626
627
		if (!in_array((int) $this->recur["subtype"], [rptDay, rptWeek, rptMonth, rptMonthNth, rptMonthEnd, rptHjMonth, rptHjMonthNth, rptHjMonthEnd])) {
628
			return;
629
		}
630
631
		$rdata = pack("vvvvv", 0x3004, 0x3004, $rtype, (int) $this->recur["subtype"], MAPI_CAL_DEFAULT);
632
		$weekstart = 1; // monday
633
		$forwardcount = 0;
634
		$count = 0;
635
		$restocc = 0;
636
		$dayofweek = (int) gmdate("w", (int) $this->recur["start"]); // 0 (for Sunday) through 6 (for Saturday)
637
638
		// Terminate
639
		$term = (int) $this->recur["term"] < 0x2000 ? 0x2000 + (int) $this->recur["term"] : (int) $this->recur["term"];
640
641
		switch ($rtype) {
642
			case IDC_RCEV_PAT_ORB_DAILY:
643
				if (!isset($this->recur["everyn"]) || (int) $this->recur["everyn"] > 1438560 || (int) $this->recur["everyn"] < 0) { // minutes for 999 days
644
					return;
645
				}
646
647
				if ($this->recur["subtype"] == rptWeek) {
648
					// Daily every workday
649
					$rdata .= pack("VVVV", 6 * 24 * 60, 1, 0, 0x3E);
650
				}
651
				else {
652
					// Calc first occ
653
					$firstocc = $this->unixDataToRecurData($this->recur["start"]) % ((int) $this->recur["everyn"]);
654
655
					$rdata .= pack("VVV", $firstocc, (int) $this->recur["everyn"], $this->recur["regen"] ? 1 : 0);
656
				}
657
				break;
658
659
			case IDC_RCEV_PAT_ORB_WEEKLY:
660
				if (!isset($this->recur["everyn"]) || $this->recur["everyn"] > 99 || (int) $this->recur["everyn"] < 0) {
661
					return;
662
				}
663
664
				if (!$this->recur["regen"] && !isset($this->recur["weekdays"])) {
665
					return;
666
				}
667
668
				// No need to calculate startdate if sliding flag was set.
669
				if (!$this->recur['regen']) {
670
					// Calculate start date of recurrence
671
672
					// Find the first day that matches one of the weekdays selected
673
					$daycount = 0;
674
					$dayskip = -1;
675
					for ($j = 0; $j < 7; ++$j) {
676
						if (((int) $this->recur["weekdays"]) & (1 << (($dayofweek + $j) % 7))) {
677
							if ($dayskip == -1) {
678
								$dayskip = $j;
679
							}
680
681
							++$daycount;
682
						}
683
					}
684
685
					// $dayskip is the number of days to skip from the startdate until the first occurrence
686
					// $daycount is the number of days per week that an occurrence occurs
687
688
					$weekskip = 0;
689
					if (($dayofweek < $weekstart && $dayskip > 0) || ($dayofweek + $dayskip) > 6) {
690
						$weekskip = 1;
691
					}
692
693
					// Check if the recurrence ends after a number of occurrences, in that case we must calculate the
694
					// remaining occurrences based on the start of the recurrence.
695
					if ($term == IDC_RCEV_PAT_ERB_AFTERNOCCUR) {
696
						// $weekskip is the amount of weeks to skip from the startdate before the first occurrence
697
						// $forwardcount is the maximum number of week occurrences we can go ahead after the first occurrence that
698
						// is still inside the recurrence. We subtract one to make sure that the last week is never forwarded over
699
						// (eg when numoccur = 2, and daycount = 1)
700
						$forwardcount = floor((int) ($this->recur["numoccur"] - 1) / $daycount);
701
702
						// $restocc is the number of occurrences left after $forwardcount whole weeks of occurrences, minus one
703
						// for the occurrence on the first day
704
						$restocc = ((int) $this->recur["numoccur"]) - ($forwardcount * $daycount) - 1;
705
706
						// $forwardcount is now the number of weeks we can go forward and still be inside the recurrence
707
						$forwardcount *= (int) $this->recur["everyn"];
708
					}
709
710
					// The real start is start + dayskip + weekskip-1 (since dayskip will already bring us into the next week)
711
					$this->recur["start"] = ((int) $this->recur["start"]) + ($dayskip * 24 * 60 * 60) + ($weekskip * (((int) $this->recur["everyn"]) - 1) * 7 * 24 * 60 * 60);
712
				}
713
714
				// Calc first occ
715
				$firstocc = $this->unixDataToRecurData($this->recur["start"]) % (((int) $this->recur["everyn"]) * 7 * 24 * 60);
716
717
				$firstocc -= (((int) gmdate("w", (int) $this->recur["start"])) - 1) * 24 * 60;
718
719
				if ($this->recur["regen"]) {
720
					$rdata .= pack("VVV", $firstocc, (int) $this->recur["everyn"], 1);
721
				}
722
				else {
723
					$rdata .= pack("VVVV", $firstocc, (int) $this->recur["everyn"], 0, (int) $this->recur["weekdays"]);
724
				}
725
				break;
726
727
			case IDC_RCEV_PAT_ORB_MONTHLY:
728
			case IDC_RCEV_PAT_ORB_YEARLY:
729
				if (!isset($this->recur["everyn"])) {
730
					return;
731
				}
732
				if ($rtype == IDC_RCEV_PAT_ORB_YEARLY && !isset($this->recur["month"])) {
733
					return;
734
				}
735
736
				if ($rtype == IDC_RCEV_PAT_ORB_MONTHLY) {
737
					$everyn = (int) $this->recur["everyn"];
738
					if ($everyn > 99 || $everyn < 0) {
739
						return;
740
					}
741
				}
742
				else {
743
					$everyn = $this->recur["regen"] ? ((int) $this->recur["everyn"]) * 12 : 12;
744
				}
745
746
				// Get montday/month/year of original start
747
				$curmonthday = gmdate("j", (int) $this->recur["start"]);
748
				$curyear = gmdate("Y", (int) $this->recur["start"]);
749
				$curmonth = gmdate("n", (int) $this->recur["start"]);
750
751
				// Check if the recurrence ends after a number of occurrences, in that case we must calculate the
752
				// remaining occurrences based on the start of the recurrence.
753
				if ($term == IDC_RCEV_PAT_ERB_AFTERNOCCUR) {
754
					// $forwardcount is the number of occurrences we can skip and still be inside the recurrence range (minus
755
					// one to make sure there are always at least one occurrence left)
756
					$forwardcount = ((((int) $this->recur["numoccur"]) - 1) * $everyn);
757
				}
758
759
				// Get month for yearly on D'th day of month M
760
				if ($rtype == IDC_RCEV_PAT_ORB_YEARLY) {
761
					$selmonth = floor(((int) $this->recur["month"]) / (24 * 60 * 29)) + 1; // 1=jan, 2=feb, eg
762
				}
763
764
				switch ((int) $this->recur["subtype"]) {
765
					// on D day of every M month
766
					case rptMonth:
767
						if (!isset($this->recur["monthday"])) {
768
							return;
769
						}
770
						// Recalc startdate
771
772
						// Set on the right begin day
773
774
						// Go the beginning of the month
775
						$this->recur["start"] -= ($curmonthday - 1) * 24 * 60 * 60;
776
						// Go the the correct month day
777
						$this->recur["start"] += (((int) $this->recur["monthday"]) - 1) * 24 * 60 * 60;
778
779
						// If the previous calculation gave us a start date different than the original start date, then we need to skip to the first occurrence
780
						if (($rtype == IDC_RCEV_PAT_ORB_MONTHLY && ((int) $this->recur["monthday"]) < $curmonthday) ||
781
							($rtype == IDC_RCEV_PAT_ORB_YEARLY && ($selmonth != $curmonth || ($selmonth == $curmonth && ((int) $this->recur["monthday"]) < $curmonthday)))) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $selmonth does not seem to be defined for all execution paths leading up to this point.
Loading history...
782
							if ($rtype == IDC_RCEV_PAT_ORB_YEARLY) {
783
								if ($curmonth > $selmonth) {// go to next occurrence in 'everyn' months minus difference in first occurrence and original date
784
									$count = $everyn - ($curmonth - $selmonth);
785
								}
786
								elseif ($curmonth < $selmonth) {// go to next occurrence upto difference in first occurrence and original date
787
									$count = $selmonth - $curmonth;
788
								}
789
								else {
790
									// Go to next occurrence while recurrence start date is greater than occurrence date but within same month
791
									if (((int) $this->recur["monthday"]) < $curmonthday) {
792
										$count = $everyn;
793
									}
794
								}
795
							}
796
							else {
797
								$count = $everyn; // Monthly, go to next occurrence in 'everyn' months
798
							}
799
800
							// Forward by $count months. This is done by getting the number of days in that month and forwarding that many days
801
							for ($i = 0; $i < $count; ++$i) {
802
								$this->recur["start"] += $this->getMonthInSeconds($curyear, $curmonth);
803
804
								if ($curmonth == 12) {
805
									++$curyear;
806
									$curmonth = 0;
807
								}
808
								++$curmonth;
809
							}
810
						}
811
812
						// "start" is now pointing to the first occurrence, except that it will overshoot if the
813
						// month in which it occurs has less days than specified as the day of the month. So 31st
814
						// of each month will overshoot in february (29 days). We compensate for that by checking
815
						// if the day of the month we got is wrong, and then back up to the last day of the previous
816
						// month.
817
						if (((int) $this->recur["monthday"]) >= 28 && ((int) $this->recur["monthday"]) <= 31 &&
818
							gmdate("j", (int) $this->recur["start"]) < ((int) $this->recur["monthday"])) {
819
							$this->recur["start"] -= gmdate("j", (int) $this->recur["start"]) * 24 * 60 * 60;
820
						}
821
822
						// "start" is now the first occurrence
823
						if ($rtype == IDC_RCEV_PAT_ORB_MONTHLY) {
824
							// Calc first occ
825
							$monthIndex = ((((12 % $everyn) * ((((int) gmdate("Y", $this->recur["start"])) - 1601) % $everyn)) % $everyn) + (((int) gmdate("n", $this->recur["start"])) - 1)) % $everyn;
826
827
							$firstocc = 0;
828
							for ($i = 0; $i < $monthIndex; ++$i) {
829
								$firstocc += $this->getMonthInSeconds(1601 + floor($i / 12), ($i % 12) + 1) / 60;
830
							}
831
832
							$rdata .= pack("VVVV", $firstocc, $everyn, $this->recur["regen"], (int) $this->recur["monthday"]);
833
						}
834
						else {
835
							// Calc first occ
836
							$firstocc = 0;
837
							$monthIndex = (int) gmdate("n", $this->recur["start"]);
838
							for ($i = 1; $i < $monthIndex; ++$i) {
839
								$firstocc += $this->getMonthInSeconds(1601 + floor($i / 12), $i) / 60;
840
							}
841
842
							$rdata .= pack("VVVV", $firstocc, $everyn, $this->recur["regen"], (int) $this->recur["monthday"]);
843
						}
844
						break;
845
846
					case rptMonthNth:
847
						// monthly: on Nth weekday of every M month
848
						// yearly: on Nth weekday of M month
849
						if (!isset($this->recur["weekdays"], $this->recur["nday"])) {
850
							return;
851
						}
852
853
						$weekdays = (int) $this->recur["weekdays"];
854
						$nday = (int) $this->recur["nday"];
855
856
						// Calc startdate
857
						$monthbegindow = (int) $this->recur["start"];
858
859
						if ($nday == 5) {
860
							// Set date on the last day of the last month
861
							$monthbegindow += (gmdate("t", $monthbegindow) - gmdate("j", $monthbegindow)) * 24 * 60 * 60;
862
						}
863
						else {
864
							// Set on the first day of the month
865
							$monthbegindow -= ((gmdate("j", $monthbegindow) - 1) * 24 * 60 * 60);
866
						}
867
868
						if ($rtype == IDC_RCEV_PAT_ORB_YEARLY) {
869
							// Set on right month
870
							if ($selmonth < $curmonth) {
871
								$tmp = 12 - $curmonth + $selmonth;
872
							}
873
							else {
874
								$tmp = ($selmonth - $curmonth);
875
							}
876
877
							for ($i = 0; $i < $tmp; ++$i) {
878
								$monthbegindow += $this->getMonthInSeconds($curyear, $curmonth);
879
880
								if ($curmonth == 12) {
881
									++$curyear;
882
									$curmonth = 0;
883
								}
884
								++$curmonth;
885
							}
886
						}
887
						else {
888
							// Check or you exist in the right month
889
890
							$dayofweek = gmdate("w", $monthbegindow);
891
							for ($i = 0; $i < 7; ++$i) {
892
								if ($nday == 5 && (($dayofweek - $i) % 7 >= 0) && (1 << (($dayofweek - $i) % 7)) & $weekdays) {
893
									$day = gmdate("j", $monthbegindow) - $i;
894
									break;
895
								}
896
								if ($nday != 5 && (1 << (($dayofweek + $i) % 7)) & $weekdays) {
897
									$day = (($nday - 1) * 7) + ($i + 1);
898
									break;
899
								}
900
							}
901
902
							// Goto the next X month
903
							if (isset($day) && ($day < gmdate("j", (int) $this->recur["start"]))) {
904
								if ($nday == 5) {
905
									$monthbegindow += 24 * 60 * 60;
906
									if ($curmonth == 12) {
907
										++$curyear;
908
										$curmonth = 0;
909
									}
910
									++$curmonth;
911
								}
912
913
								for ($i = 0; $i < $everyn; ++$i) {
914
									$monthbegindow += $this->getMonthInSeconds($curyear, $curmonth);
915
916
									if ($curmonth == 12) {
917
										++$curyear;
918
										$curmonth = 0;
919
									}
920
									++$curmonth;
921
								}
922
923
								if ($nday == 5) {
924
									$monthbegindow -= 24 * 60 * 60;
925
								}
926
							}
927
						}
928
929
						// FIXME: weekstart?
930
931
						$day = 0;
932
						// Set start on the right day
933
						$dayofweek = gmdate("w", $monthbegindow);
934
						for ($i = 0; $i < 7; ++$i) {
935
							if ($nday == 5 && (($dayofweek - $i) % 7) >= 0 && (1 << (($dayofweek - $i) % 7)) & $weekdays) {
936
								$day = $i;
937
								break;
938
							}
939
							if ($nday != 5 && (1 << (($dayofweek + $i) % 7)) & $weekdays) {
940
								$day = ($nday - 1) * 7 + ($i + 1);
941
								break;
942
							}
943
						}
944
						if ($nday == 5) {
945
							$monthbegindow -= $day * 24 * 60 * 60;
946
						}
947
						else {
948
							$monthbegindow += ($day - 1) * 24 * 60 * 60;
949
						}
950
951
						$firstocc = 0;
952
						if ($rtype == IDC_RCEV_PAT_ORB_MONTHLY) {
953
							// Calc first occ
954
							$monthIndex = ((((12 % $everyn) * (((int) gmdate("Y", $this->recur["start"]) - 1601) % $everyn)) % $everyn) + (((int) gmdate("n", $this->recur["start"])) - 1)) % $everyn;
955
956
							for ($i = 0; $i < $monthIndex; ++$i) {
957
								$firstocc += $this->getMonthInSeconds(1601 + floor($i / 12), ($i % 12) + 1) / 60;
958
							}
959
960
							$rdata .= pack("VVVVV", $firstocc, $everyn, 0, $weekdays, $nday);
961
						}
962
						else {
963
							// Calc first occ
964
							$monthIndex = (int) gmdate("n", $this->recur["start"]);
965
966
							for ($i = 1; $i < $monthIndex; ++$i) {
967
								$firstocc += $this->getMonthInSeconds(1601 + floor($i / 12), $i) / 60;
968
							}
969
970
							$rdata .= pack("VVVVV", $firstocc, $everyn, 0, $weekdays, $nday);
971
						}
972
						break;
973
				}
974
				break;
975
		}
976
977
		if (!isset($this->recur["term"])) {
978
			return;
979
		}
980
981
		$rdata .= pack("V", $term);
982
983
		switch ($term) {
984
			// After the given enddate
985
			case IDC_RCEV_PAT_ERB_END:
986
				$rdata .= pack("V", 10);
987
				break;
988
989
				// After a number of times
990
			case IDC_RCEV_PAT_ERB_AFTERNOCCUR:
991
				if (!isset($this->recur["numoccur"])) {
992
					return;
993
				}
994
995
				$rdata .= pack("V", (int) $this->recur["numoccur"]);
996
				break;
997
998
				// Never ends
999
			case IDC_RCEV_PAT_ERB_NOEND:
1000
				$rdata .= pack("V", 0);
1001
				break;
1002
		}
1003
1004
		// Persist first day of week (defaults to Monday for weekly recurrences)
1005
		$firstDow = $this->recur["first_dow"] ?? ($rtype == IDC_RCEV_PAT_ORB_WEEKLY && ((int) $this->recur["subtype"]) == 1 ? 1 : 0);
1006
		$rdata .= pack("V", (int) $firstDow);
1007
1008
		// Exception data
1009
1010
		// Get all exceptions
1011
		$deleted_items = $this->recur["deleted_occurrences"];
1012
		$changed_items = $this->recur["changed_occurrences"];
1013
1014
		// Merge deleted and changed items into one list
1015
		$items = $deleted_items;
1016
1017
		foreach ($changed_items as $changed_item) {
1018
			array_push($items, $this->dayStartOf($changed_item["basedate"]));
1019
		}
1020
1021
		sort($items);
1022
1023
		// Add the merged list in to the rdata
1024
		$rdata .= pack("V", count($items));
1025
		foreach ($items as $item) {
1026
			$rdata .= pack("V", $this->unixDataToRecurData($item));
1027
		}
1028
1029
		// Loop through the changed exceptions (not deleted)
1030
		$rdata .= pack("V", count($changed_items));
1031
		$items = [];
1032
1033
		foreach ($changed_items as $changed_item) {
1034
			$items[] = $this->dayStartOf($changed_item["start"]);
1035
		}
1036
1037
		sort($items);
1038
1039
		// Add the changed items list int the rdata
1040
		foreach ($items as $item) {
1041
			$rdata .= pack("V", $this->unixDataToRecurData($item));
1042
		}
1043
1044
		// Set start date
1045
		$rdata .= pack("V", $this->unixDataToRecurData((int) $this->recur["start"]));
1046
1047
		// Set enddate
1048
		switch ($term) {
1049
			// After the given enddate
1050
			case IDC_RCEV_PAT_ERB_END:
1051
				$rdata .= pack("V", $this->unixDataToRecurData((int) $this->recur["end"]));
1052
				break;
1053
1054
				// After a number of times
1055
			case IDC_RCEV_PAT_ERB_AFTERNOCCUR:
1056
				// @todo: calculate enddate with intval($this->recur["startocc"]) + intval($this->recur["duration"]) > 24 hour
1057
				$occenddate = (int) $this->recur["start"];
1058
1059
				switch ($rtype) {
1060
					case IDC_RCEV_PAT_ORB_DAILY:
1061
						if ($this->recur["subtype"] == rptWeek) {
1062
							// Daily every workday
1063
							$restocc = (int) $this->recur["numoccur"];
1064
1065
							// Get starting weekday
1066
							$nowtime = $this->gmtime($occenddate);
1067
							$j = $nowtime["tm_wday"];
1068
1069
							while (1) {
1070
								if (($j % 7) > 0 && ($j % 7) < 6) {
1071
									--$restocc;
1072
								}
1073
1074
								++$j;
1075
1076
								if ($restocc <= 0) {
1077
									break;
1078
								}
1079
1080
								$occenddate += 24 * 60 * 60;
1081
							}
1082
						}
1083
						else {
1084
							// -1 because the first day already counts (from 1-1-1980 to 1-1-1980 is 1 occurrence)
1085
							$occenddate += (((int) $this->recur["everyn"]) * 60 * ((int) $this->recur["numoccur"] - 1));
1086
						}
1087
						break;
1088
1089
					case IDC_RCEV_PAT_ORB_WEEKLY:
1090
						// Needed values
1091
						// $forwardcount - number of weeks we can skip forward
1092
						// $restocc - number of remaining occurrences after the week skip
1093
1094
						// Add the weeks till the last item
1095
						$occenddate += ($forwardcount * 7 * 24 * 60 * 60);
1096
1097
						$dayofweek = gmdate("w", $occenddate);
0 ignored issues
show
Bug introduced by
$occenddate of type double is incompatible with the type integer|null expected by parameter $timestamp of gmdate(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1097
						$dayofweek = gmdate("w", /** @scrutinizer ignore-type */ $occenddate);
Loading history...
1098
1099
						// Loop through the last occurrences until we have had them all
1100
						for ($j = 1; $restocc > 0; ++$j) {
1101
							// Jump to the next week (which may be N weeks away) when going over the week boundary
1102
							if ((($dayofweek + $j) % 7) == $weekstart) {
1103
								$occenddate += (((int) $this->recur["everyn"]) - 1) * 7 * 24 * 60 * 60;
1104
							}
1105
1106
							// If this is a matching day, once less occurrence to process
1107
							if (((int) $this->recur["weekdays"]) & (1 << (($dayofweek + $j) % 7))) {
1108
								--$restocc;
1109
							}
1110
1111
							// Next day
1112
							$occenddate += 24 * 60 * 60;
1113
						}
1114
1115
						break;
1116
1117
					case IDC_RCEV_PAT_ORB_MONTHLY:
1118
					case IDC_RCEV_PAT_ORB_YEARLY:
1119
						$curyear = gmdate("Y", (int) $this->recur["start"]);
1120
						$curmonth = gmdate("n", (int) $this->recur["start"]);
1121
						// $forwardcount = months
1122
1123
						switch ((int) $this->recur["subtype"]) {
1124
							case rptMonth: // on D day of every M month
1125
								while ($forwardcount > 0) {
1126
									$occenddate += $this->getMonthInSeconds($curyear, $curmonth);
1127
1128
									if ($curmonth >= 12) {
1129
										$curmonth = 1;
1130
										++$curyear;
1131
									}
1132
									else {
1133
										++$curmonth;
1134
									}
1135
									--$forwardcount;
1136
								}
1137
1138
								// compensation between 28 and 31
1139
								if (((int) $this->recur["monthday"]) >= 28 && ((int) $this->recur["monthday"]) <= 31 &&
1140
									gmdate("j", $occenddate) < ((int) $this->recur["monthday"])) {
1141
									if (gmdate("j", $occenddate) < 28) {
1142
										$occenddate -= gmdate("j", $occenddate) * 24 * 60 * 60;
1143
									}
1144
									else {
1145
										$occenddate += (gmdate("t", $occenddate) - gmdate("j", $occenddate)) * 24 * 60 * 60;
1146
									}
1147
								}
1148
1149
								break;
1150
1151
							case rptMonthNth: // on Nth weekday of every M month
1152
								$nday = (int) $this->recur["nday"]; // 1 tot 5
1153
								$weekdays = (int) $this->recur["weekdays"];
1154
1155
								while ($forwardcount > 0) {
1156
									$occenddate += $this->getMonthInSeconds($curyear, $curmonth);
1157
									if ($curmonth >= 12) {
1158
										$curmonth = 1;
1159
										++$curyear;
1160
									}
1161
									else {
1162
										++$curmonth;
1163
									}
1164
1165
									--$forwardcount;
1166
								}
1167
1168
								if ($nday == 5) {
1169
									// Set date on the last day of the last month
1170
									$occenddate += (gmdate("t", $occenddate) - gmdate("j", $occenddate)) * 24 * 60 * 60;
1171
								}
1172
								else {
1173
									// Set date on the first day of the last month
1174
									$occenddate -= (gmdate("j", $occenddate) - 1) * 24 * 60 * 60;
1175
								}
1176
1177
								$dayofweek = gmdate("w", $occenddate);
1178
								for ($i = 0; $i < 7; ++$i) {
1179
									if ($nday == 5 && (($dayofweek - $i) % 7) >= 0 && (1 << (($dayofweek - $i) % 7)) & $weekdays) {
1180
										$occenddate -= $i * 24 * 60 * 60;
1181
										break;
1182
									}
1183
									if ($nday != 5 && (1 << (($dayofweek + $i) % 7)) & $weekdays) {
1184
										$occenddate += ($i + (($nday - 1) * 7)) * 24 * 60 * 60;
1185
										break;
1186
									}
1187
								}
1188
1189
								break; // case rptMonthNth
1190
						}
1191
1192
						break;
1193
				}
1194
1195
				if (defined("PHP_INT_MAX") && $occenddate > PHP_INT_MAX) {
1196
					$occenddate = PHP_INT_MAX;
1197
				}
1198
1199
				$this->recur["end"] = $occenddate;
1200
1201
				$rdata .= pack("V", $this->unixDataToRecurData((int) $this->recur["end"]));
1202
				break;
1203
1204
				// Never ends
1205
			case IDC_RCEV_PAT_ERB_NOEND:
1206
			default:
1207
				$this->recur["end"] = 0x7FFFFFFF; // max date -> 2038
1208
				$rdata .= pack("V", 0x5AE980DF);
1209
				break;
1210
		}
1211
1212
		// UTC date
1213
		$utcstart = $this->toGMT($this->tz, (int) $this->recur["start"]);
1214
		$utcend = $this->toGMT($this->tz, (int) $this->recur["end"]);
1215
1216
		// utc date+time
1217
		$utcfirstoccstartdatetime = (isset($this->recur["startocc"])) ? $utcstart + (((int) $this->recur["startocc"]) * 60) : $utcstart;
1218
		$utcfirstoccenddatetime = (isset($this->recur["endocc"])) ? $utcstart + (((int) $this->recur["endocc"]) * 60) : $utcstart;
1219
1220
		$propsToSet = [];
1221
		// update reminder time
1222
		$propsToSet[$this->proptags["reminder_time"]] = $utcfirstoccstartdatetime;
1223
1224
		// update first occurrence date
1225
		$propsToSet[$this->proptags["startdate"]] = $propsToSet[$this->proptags["commonstart"]] = $utcfirstoccstartdatetime;
1226
		$propsToSet[$this->proptags["duedate"]] = $propsToSet[$this->proptags["commonend"]] = $utcfirstoccenddatetime;
1227
1228
		// Set Outlook properties, if it is an appointment
1229
		if (isset($this->messageprops[$this->proptags["message_class"]]) && $this->messageprops[$this->proptags["message_class"]] == "IPM.Appointment") {
1230
			// update real begin and real end date
1231
			$propsToSet[$this->proptags["startdate_recurring"]] = $utcstart;
1232
			$propsToSet[$this->proptags["enddate_recurring"]] = $utcend;
1233
1234
			// recurrencetype
1235
			// Strange enough is the property recurrencetype, (type-0x9) and not the CDO recurrencetype
1236
			$propsToSet[$this->proptags["recurrencetype"]] = ((int) $this->recur["type"]) - 0x9;
1237
1238
			// set named prop 'side_effects' to 369, needed for Outlook to ask for single or total recurrence when deleting
1239
			$propsToSet[$this->proptags["side_effects"]] = 369;
1240
		}
1241
		else {
1242
			$propsToSet[$this->proptags["side_effects"]] = 3441;
1243
		}
1244
1245
		// FlagDueBy is datetime of the first reminder occurrence. Outlook gives on this time a reminder popup dialog
1246
		// Any change of the recurrence (including changing and deleting exceptions) causes the flagdueby to be reset
1247
		// to the 'next' occurrence; this makes sure that deleting the next occurrence will correctly set the reminder to
1248
		// the occurrence after that. The 'next' occurrence is defined as being the first occurrence that starts at moment X (server time)
1249
		// with the reminder flag set.
1250
		$reminderprops = mapi_getprops($this->message, [$this->proptags["reminder_minutes"], $this->proptags["flagdueby"]]);
1251
		if (isset($reminderprops[$this->proptags["reminder_minutes"]])) {
1252
			$occ = false;
1253
			$occurrences = $this->getItems(time(), 0x7FF00000, 3, true);
1254
1255
			for ($i = 0, $len = count($occurrences); $i < $len; ++$i) {
1256
				// This will actually also give us appointments that have already started, but not yet ended. Since we want the next
1257
				// reminder that occurs after time(), we may have to skip the first few entries. We get 3 entries since that is the maximum
1258
				// number that would be needed (assuming reminder for item X cannot be before the previous occurrence starts). Worst case:
1259
				// time() is currently after start but before end of item, but reminder of next item has already passed (reminder for next item
1260
				// can be DURING the previous item, eg daily allday events). In that case, the first and second items must be skipped.
1261
1262
				if (($occurrences[$i][$this->proptags["startdate"]] - $reminderprops[$this->proptags["reminder_minutes"]] * 60) > time()) {
1263
					$occ = $occurrences[$i];
1264
					break;
1265
				}
1266
			}
1267
1268
			if ($occ) {
1269
				if (isset($reminderprops[$this->proptags["flagdueby"]])) {
1270
					$propsToSet[$this->proptags["flagdueby"]] = $reminderprops[$this->proptags["flagdueby"]];
1271
				}
1272
				else {
1273
					$propsToSet[$this->proptags["flagdueby"]] = $occ[$this->proptags["startdate"]] - ($reminderprops[$this->proptags["reminder_minutes"]] * 60);
1274
				}
1275
			}
1276
			else {
1277
				// Last reminder passed, no reminders any more.
1278
				$propsToSet[$this->proptags["reminder"]] = false;
1279
				$propsToSet[$this->proptags["flagdueby"]] = 0x7FF00000;
1280
			}
1281
		}
1282
1283
		// Default data
1284
		// Second item (0x08) indicates the Outlook version (see documentation at the bottom of this file for more information)
1285
		$rdata .= pack("VV", 0x3006, 0x3009);
1286
		if (isset($this->recur["startocc"], $this->recur["endocc"])) {
1287
			// Set start and endtime in minutes
1288
			$rdata .= pack("VV", (int) $this->recur["startocc"], (int) $this->recur["endocc"]);
1289
		}
1290
1291
		// Detailed exception data
1292
1293
		$changed_items = $this->recur["changed_occurrences"];
1294
1295
		$rdata .= pack("v", count($changed_items));
1296
1297
		foreach ($changed_items as $changed_item) {
1298
			// Set start and end time of exception
1299
			$rdata .= pack("V", $this->unixDataToRecurData($changed_item["start"])); // StartDateTime
1300
			$rdata .= pack("V", $this->unixDataToRecurData($changed_item["end"])); // EndDateTime
1301
			$rdata .= pack("V", $this->unixDataToRecurData(
1302
				$this->dayStartOf($changed_item["basedate"]) + ((int) $this->recur["startocc"] ?? 0) * 60)); // OriginalStartDate
1303
1304
			// Bitmask
1305
			$bitmask = 0;
1306
1307
			// Check for changed strings
1308
			if (isset($changed_item["subject"])) {
1309
				$bitmask |= 1 << 0;
1310
			}
1311
1312
			if (isset($changed_item["remind_before"])) {
1313
				$bitmask |= 1 << 2;
1314
			}
1315
1316
			if (isset($changed_item["reminder_set"])) {
1317
				$bitmask |= 1 << 3;
1318
			}
1319
1320
			if (isset($changed_item["location"])) {
1321
				$bitmask |= 1 << 4;
1322
			}
1323
1324
			if (isset($changed_item["busystatus"])) {
1325
				$bitmask |= 1 << 5;
1326
			}
1327
1328
			if (isset($changed_item["alldayevent"])) {
1329
				$bitmask |= 1 << 7;
1330
			}
1331
1332
			if (isset($changed_item["label"])) {
1333
				$bitmask |= 1 << 8;
1334
			}
1335
1336
			$rdata .= pack("v", $bitmask);
1337
1338
			// Set "subject"
1339
			if (isset($changed_item["subject"])) {
1340
				// convert utf-8 to non-unicode blob string (us-ascii?)
1341
				$subject = iconv("UTF-8", "windows-1252//TRANSLIT", $changed_item["subject"]);
1342
				$length = strlen($subject);
1343
				$rdata .= pack("vv", $length + 1, $length);
1344
				$rdata .= pack("a" . $length, $subject);
1345
			}
1346
1347
			if (isset($changed_item["remind_before"])) {
1348
				$rdata .= pack("V", $changed_item["remind_before"]);
1349
			}
1350
1351
			if (isset($changed_item["reminder_set"])) {
1352
				$rdata .= pack("V", $changed_item["reminder_set"]);
1353
			}
1354
1355
			if (isset($changed_item["location"])) {
1356
				$location = iconv("UTF-8", "windows-1252//TRANSLIT", $changed_item["location"]);
1357
				$length = strlen($location);
1358
				$rdata .= pack("vv", $length + 1, $length);
1359
				$rdata .= pack("a" . $length, $location);
1360
			}
1361
1362
			if (isset($changed_item["busystatus"])) {
1363
				$rdata .= pack("V", $changed_item["busystatus"]);
1364
			}
1365
1366
			if (isset($changed_item["alldayevent"])) {
1367
				$rdata .= pack("V", $changed_item["alldayevent"]);
1368
			}
1369
1370
			if (isset($changed_item["label"])) {
1371
				$rdata .= pack("V", $changed_item["label"]);
1372
			}
1373
		}
1374
1375
		$rdata .= pack("V", 0);
1376
1377
		// write extended data
1378
		foreach ($changed_items as $changed_item) {
1379
			$rdata .= pack("VVV", 4, 0, 0); // ChangeHighlightSize, ChangeHighlightValue, ReservedBlockEE1Size
1380
			if (isset($changed_item["subject"]) || isset($changed_item["location"])) {
1381
				$rdata .= pack("V", $this->unixDataToRecurData($changed_item["start"]));
1382
				$rdata .= pack("V", $this->unixDataToRecurData($changed_item["end"]));
1383
				$rdata .= pack("V", $this->unixDataToRecurData($this->dayStartOf($changed_item["basedate"]) + ((int) $this->recur["startocc"] ?? 0) * 60));
1384
			}
1385
1386
			if (isset($changed_item["subject"])) {
1387
				$subject = iconv("UTF-8", "UCS-2LE", $changed_item["subject"]);
1388
				$length = iconv_strlen($subject, "UCS-2LE");
1389
				$rdata .= pack("v", $length);
1390
				$rdata .= pack("a" . $length * 2, $subject);
1391
			}
1392
1393
			if (isset($changed_item["location"])) {
1394
				$location = iconv("UTF-8", "UCS-2LE", $changed_item["location"]);
1395
				$length = iconv_strlen($location, "UCS-2LE");
1396
				$rdata .= pack("v", $length);
1397
				$rdata .= pack("a" . $length * 2, $location);
1398
			}
1399
1400
			if (isset($changed_item["subject"]) || isset($changed_item["location"])) {
1401
				$rdata .= pack("V", 0);
1402
			}
1403
		}
1404
1405
		$rdata .= pack("V", 0); // ReservedBlock2Size
1406
1407
		// Set props
1408
		$propsToSet[$this->proptags["recurring_data"]] = $rdata;
1409
		$propsToSet[$this->proptags["recurring"]] = true;
1410
		$propsToSet[$this->proptags["meetingrecurring"]] = true;
1411
		if (isset($this->tz) && $this->tz) {
1412
			$timezone = "GMT";
1413
			if ($this->tz["timezone"] != 0) {
1414
				// Create user readable timezone information
1415
				$timezone = sprintf(
1416
					"(GMT %s%02d:%02d)", -$this->tz["timezone"] > 0 ? "+" : "-",
1417
					abs($this->tz["timezone"] / 60),
1418
					abs($this->tz["timezone"] % 60)
1419
				);
1420
			}
1421
			$propsToSet[$this->proptags["timezone_data"]] = $this->getTimezoneData($this->tz);
1422
			$propsToSet[$this->proptags["timezone"]] = $timezone;
1423
		}
1424
		mapi_setprops($this->message, $propsToSet);
1425
	}
1426
1427
	/**
1428
	 * Function which converts a recurrence date timestamp to an unix date timestamp.
1429
	 *
1430
	 * @author Steve Hardy
1431
	 *
1432
	 * @param int $rdate the date which will be converted
1433
	 *
1434
	 * @return int the converted date
1435
	 */
1436
	public function recurDataToUnixData($rdate) {
1437
		return ($rdate - 194074560) * 60;
1438
	}
1439
1440
	/**
1441
	 * Function which converts an unix date timestamp to recurrence date timestamp.
1442
	 *
1443
	 * @author Johnny Biemans
1444
	 *
1445
	 * @param int $date the date which will be converted
1446
	 *
1447
	 * @return float|int the converted date in minutes
1448
	 */
1449
	public function unixDataToRecurData($date) {
1450
		return ($date / 60) + 194074560;
1451
	}
1452
1453
	/**
1454
	 * gmtime() doesn't exist in standard PHP, so we have to implement it ourselves.
1455
	 *
1456
	 * @author Steve Hardy
1457
	 *
1458
	 * @param mixed $ts
1459
	 *
1460
	 * @return float|int
1461
	 */
1462
	public function GetTZOffset($ts) {
1463
		$Offset = date("O", $ts);
1464
1465
		$Parity = $Offset < 0 ? -1 : 1;
1466
		$Offset = $Parity * $Offset;
1467
		$Offset = ($Offset - ($Offset % 100)) / 100 * 60 + $Offset % 100;
1468
1469
		return $Parity * $Offset;
1470
	}
1471
1472
	/**
1473
	 * gmtime() doesn't exist in standard PHP, so we have to implement it ourselves.
1474
	 *
1475
	 * @author Steve Hardy
1476
	 *
1477
	 * @param int $time
1478
	 *
1479
	 * @return array GMT Time
1480
	 */
1481
	public function gmtime($time) {
1482
		$TZOffset = $this->GetTZOffset($time);
1483
1484
		$t_time = $time - $TZOffset * 60; # Counter adjust for localtime()
1485
1486
		return localtime($t_time, 1);
1487
	}
1488
1489
	/**
1490
	 * @param float|string $year
1491
	 */
1492
	public function isLeapYear($year): bool {
1493
		return $year % 4 == 0 && ($year % 100 != 0 || $year % 400 == 0);
1494
	}
1495
1496
	/**
1497
	 * @param float|string $year
1498
	 * @param int|string   $month
1499
	 */
1500
	public function getMonthInSeconds($year, $month): int {
1501
		if (in_array($month, [1, 3, 5, 7, 8, 10, 12])) {
1502
			$day = 31;
1503
		}
1504
		elseif (in_array($month, [4, 6, 9, 11])) {
1505
			$day = 30;
1506
		}
1507
		else {
1508
			$day = 28;
1509
			if ($this->isLeapYear($year) == 1) {
1510
				++$day;
1511
			}
1512
		}
1513
1514
		return $day * 24 * 60 * 60;
1515
	}
1516
1517
	/**
1518
	 * Function to get a date by Year Nr, Month Nr, Week Nr, Day Nr, and hour.
1519
	 *
1520
	 * @param int $year
1521
	 * @param int $month
1522
	 * @param int $week
1523
	 * @param int $day
1524
	 * @param int $hour
1525
	 *
1526
	 * @return int the timestamp of the given date, timezone-independent
1527
	 */
1528
	public function getDateByYearMonthWeekDayHour($year, $month, $week, $day, $hour) {
1529
		// get first day of month
1530
		$date = gmmktime(0, 0, 0, $month, 0, $year + 1900);
1531
1532
		// get wday info
1533
		$gmdate = $this->gmtime($date);
1534
1535
		$date -= $gmdate["tm_wday"] * 24 * 60 * 60; // back up to start of week
1536
1537
		$date += $week * 7 * 24 * 60 * 60; // go to correct week nr
1538
		$date += $day * 24 * 60 * 60;
1539
		$date += $hour * 60 * 60;
1540
1541
		$gmdate = $this->gmtime($date);
1542
1543
		// if we are in the next month, then back up a week, because week '5' means
1544
		// 'last week of month'
1545
1546
		if ($month != $gmdate["tm_mon"] + 1) {
1547
			$date -= 7 * 24 * 60 * 60;
1548
		}
1549
1550
		return $date;
1551
	}
1552
1553
	/**
1554
	 * getTimezone gives the timezone offset (in minutes) of the given
1555
	 * local date/time according to the given TZ info.
1556
	 *
1557
	 * @param mixed $tz
1558
	 * @param mixed $date
1559
	 */
1560
	public function getTimezone($tz, $date) {
1561
		// No timezone -> GMT (+0)
1562
		if (!isset($tz["timezone"])) {
1563
			return 0;
1564
		}
1565
1566
		$dst = false;
1567
		$gmdate = $this->gmtime($date);
1568
1569
		$dststart = $this->getDateByYearMonthWeekDayHour($gmdate["tm_year"], $tz["dststartmonth"], $tz["dststartweek"], 0, $tz["dststarthour"]);
1570
		$dstend = $this->getDateByYearMonthWeekDayHour($gmdate["tm_year"], $tz["dstendmonth"], $tz["dstendweek"], 0, $tz["dstendhour"]);
1571
1572
		if ($dststart <= $dstend) {
1573
			// Northern hemisphere, eg DST is during Mar-Oct
1574
			if ($date > $dststart && $date < $dstend) {
1575
				$dst = true;
1576
			}
1577
		}
1578
		else {
1579
			// Southern hemisphere, eg DST is during Oct-Mar
1580
			if ($date < $dstend || $date > $dststart) {
1581
				$dst = true;
1582
			}
1583
		}
1584
1585
		if ($dst) {
1586
			return $tz["timezone"] + $tz["timezonedst"];
1587
		}
1588
1589
		return $tz["timezone"];
1590
	}
1591
1592
	/**
1593
	 * parseTimezone parses the timezone as specified in named property 0x8233
1594
	 * in Outlook calendar messages. Returns the timezone in minutes negative
1595
	 * offset (GMT +2:00 -> -120).
1596
	 *
1597
	 * @param mixed $data
1598
	 *
1599
	 * @return null|array|false
1600
	 */
1601
	public function parseTimezone($data) {
1602
		if (strlen((string) $data) < 48) {
1603
			return;
1604
		}
1605
1606
		return unpack("ltimezone/lunk/ltimezonedst/lunk/ldstendmonth/vdstendweek/vdstendhour/lunk/lunk/vunk/ldststartmonth/vdststartweek/vdststarthour/lunk/vunk", (string) $data);
1607
	}
1608
1609
	/**
1610
	 * @param mixed $tz
1611
	 *
1612
	 * @return false|string
1613
	 */
1614
	public function getTimezoneData($tz) {
1615
		return pack("lllllvvllvlvvlv", $tz["timezone"], 0, $tz["timezonedst"], 0, $tz["dstendmonth"], $tz["dstendweek"], $tz["dstendhour"], 0, 0, 0, $tz["dststartmonth"], $tz["dststartweek"], $tz["dststarthour"], 0, 0);
1616
	}
1617
1618
	/**
1619
	 * toGMT returns a timestamp in GMT time for the time and timezone given.
1620
	 *
1621
	 * @param mixed $tz
1622
	 * @param mixed $date
1623
	 */
1624
	public function toGMT($tz, $date) {
1625
		if (!isset($tz['timezone'])) {
1626
			return $date;
1627
		}
1628
		$offset = $this->getTimezone($tz, $date);
1629
1630
		return $date + $offset * 60;
1631
	}
1632
1633
	/**
1634
	 * fromGMT returns a timestamp in the local timezone given from the GMT time given.
1635
	 *
1636
	 * @param mixed $tz
1637
	 * @param mixed $date
1638
	 */
1639
	public function fromGMT($tz, $date) {
1640
		$offset = $this->getTimezone($tz, $date);
1641
1642
		return $date - $offset * 60;
1643
	}
1644
1645
	/**
1646
	 * Function to get timestamp of the beginning of the day of the timestamp given.
1647
	 *
1648
	 * @param mixed $date
1649
	 *
1650
	 * @return false|int timestamp referring to same day but at 00:00:00
1651
	 */
1652
	public function dayStartOf($date) {
1653
		$time1 = $this->gmtime($date);
1654
1655
		return gmmktime(0, 0, 0, $time1["tm_mon"] + 1, $time1["tm_mday"], $time1["tm_year"] + 1900);
1656
	}
1657
1658
	/**
1659
	 * Function to get timestamp of the beginning of the month of the timestamp given.
1660
	 *
1661
	 * @param mixed $date
1662
	 *
1663
	 * @return false|int Timestamp referring to same month but on the first day, and at 00:00:00
1664
	 */
1665
	public function monthStartOf($date) {
1666
		$time1 = $this->gmtime($date);
1667
1668
		return gmmktime(0, 0, 0, $time1["tm_mon"] + 1, 1, $time1["tm_year"] + 1900);
1669
	}
1670
1671
	/**
1672
	 * Function to get timestamp of the beginning of the year of the timestamp given.
1673
	 *
1674
	 * @param mixed $date
1675
	 *
1676
	 * @return false|int Timestamp referring to the same year but on Jan 01, at 00:00:00
1677
	 */
1678
	public function yearStartOf($date) {
1679
		$time1 = $this->gmtime($date);
1680
1681
		return gmmktime(0, 0, 0, 1, 1, $time1["tm_year"] + 1900);
1682
	}
1683
1684
	/**
1685
	 * Function which returns the items in a given interval. This included expansion of the recurrence and
1686
	 * processing of exceptions (modified and deleted).
1687
	 *
1688
	 * @param int   $start         start time of the interval (GMT)
1689
	 * @param int   $end           end time of the interval (GMT)
1690
	 * @param mixed $limit
1691
	 * @param mixed $remindersonly
1692
	 *
1693
	 * @return (array|mixed)[]
1694
	 *
1695
	 * @psalm-return array<int, T|array>
1696
	 */
1697
	public function getItems($start, $end, $limit = 0, $remindersonly = false): array {
1698
		$items = [];
1699
		$firstday = 0;
1700
1701
		if (!isset($this->recur)) {
1702
			return $items;
1703
		}
1704
1705
		// Optimization: remindersonly and default reminder is off; since only exceptions with reminder set will match, just look which
1706
		// exceptions are in range and have a reminder set
1707
		if ($remindersonly && (!isset($this->messageprops[$this->proptags["reminder"]]) || $this->messageprops[$this->proptags["reminder"]] == false)) {
1708
			// Sort exceptions by start time
1709
			uasort($this->recur["changed_occurrences"], $this->sortExceptionStart(...));
1710
1711
			// Loop through all changed exceptions
1712
			foreach ($this->recur["changed_occurrences"] as $exception) {
1713
				// Check reminder set
1714
				if (!isset($exception["reminder"]) || $exception["reminder"] == false) {
1715
					continue;
1716
				}
1717
1718
				// Convert to GMT
1719
				$occstart = $this->toGMT($this->tz, $exception["start"]);
1720
				$occend = $this->toGMT($this->tz, $exception["end"]);
1721
1722
				// Check range criterium
1723
				if ($occstart > $end || $occend < $start) {
1724
					continue;
1725
				}
1726
1727
				// OK, add to items.
1728
				array_push($items, $this->getExceptionProperties($exception));
1729
				if ($limit && (count($items) == $limit)) {
1730
					break;
1731
				}
1732
			}
1733
1734
			uasort($items, $this->sortStarttime(...));
1735
1736
			return $items;
1737
		}
1738
1739
		// From here on, the dates of the occurrences are calculated in local time, so the days we're looking
1740
		// at are calculated from the local time dates of $start and $end
1741
1742
		if (isset($this->recur['regen'], $this->action['datecompleted']) && $this->recur['regen']) {
1743
			$daystart = $this->dayStartOf($this->action['datecompleted']);
1744
		}
1745
		else {
1746
			$daystart = $this->dayStartOf($this->recur["start"]); // start on first day of occurrence
1747
		}
1748
1749
		// Calculate the last day on which we want to be looking at a recurrence; this is either the end of the view
1750
		// or the end of the recurrence, whichever comes first
1751
		if ($end > $this->toGMT($this->tz, $this->recur["end"])) {
1752
			$rangeend = $this->toGMT($this->tz, $this->recur["end"]);
1753
		}
1754
		else {
1755
			$rangeend = $end;
1756
		}
1757
1758
		$dayend = $this->dayStartOf($this->fromGMT($this->tz, $rangeend));
1759
1760
		// Loop through the entire recurrence range of dates, and check for each occurrence whether it is in the view range.
1761
		$recurType = (int) $this->recur["type"] < 0x2000 ? (int) $this->recur["type"] + 0x2000 : (int) $this->recur["type"];
1762
1763
		switch ($recurType) {
1764
			case IDC_RCEV_PAT_ORB_DAILY:
1765
				if ($this->recur["everyn"] <= 0) {
1766
					$this->recur["everyn"] = 1440;
1767
				}
1768
1769
				if ($this->recur["subtype"] == rptDay) {
1770
					// Every Nth day
1771
					for ($now = $daystart; $now <= $dayend && ($limit == 0 || count($items) < $limit); $now += 60 * $this->recur["everyn"]) {
1772
						$this->processOccurrenceItem($items, $start, $end, $now, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1773
					}
1774
					break;
1775
				}
1776
				// Every workday
1777
				for ($now = $daystart; $now <= $dayend && ($limit == 0 || count($items) < $limit); $now += 60 * 1440) {
1778
					$nowtime = $this->gmtime($now);
1779
					if ($nowtime["tm_wday"] > 0 && $nowtime["tm_wday"] < 6) { // only add items in the given timespace
1780
						$this->processOccurrenceItem($items, $start, $end, $now, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1781
					}
1782
				}
1783
				break;
1784
1785
			case IDC_RCEV_PAT_ORB_WEEKLY:
1786
				if ($this->recur["everyn"] <= 0) {
1787
					$this->recur["everyn"] = 1;
1788
				}
1789
1790
				// If sliding flag is set then move to 'n' weeks
1791
				$weekSeconds = 60 * 60 * 24 * 7;
1792
				if ($this->recur['regen']) {
1793
					$daystart += ($weekSeconds * $this->recur["everyn"]);
1794
				}
1795
1796
				$loopStart = $daystart;
1797
				if (!$this->recur['regen']) {
1798
					$weekStartDow = isset($this->recur["first_dow"]) ? (int) $this->recur["first_dow"] : 1;
1799
					$weekStartDow = ($weekStartDow % 7 + 7) % 7;
1800
					$currentDow = (int) $this->gmtime($loopStart)["tm_wday"];
1801
					$offset = ($currentDow - $weekStartDow + 7) % 7;
1802
					$loopStart -= $offset * 24 * 60 * 60;
1803
				}
1804
1805
				for ($now = $loopStart; $now <= $dayend && ($limit == 0 || count($items) < $limit); $now += ($weekSeconds * $this->recur["everyn"])) {
1806
					if ($this->recur['regen']) {
1807
						$this->processOccurrenceItem($items, $start, $end, $now, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1808
						break;
1809
					}
1810
					// Loop through the whole following week to the first occurrence of the week, add each day that is specified
1811
					for ($wday = 0; $wday < 7 && ($limit == 0 || count($items) < $limit); ++$wday) {
1812
						$daynow = $now + $wday * 60 * 60 * 24;
1813
						if ($daynow < $daystart) {
1814
							continue;
1815
						}
1816
						// checks whether the next coming day in recurring pattern is less than or equal to end day of the recurring item
1817
						if ($daynow > $dayend) {
1818
							break;
1819
						}
1820
						$nowtime = $this->gmtime($daynow); // Get the weekday of the current day
1821
						if ($this->recur["weekdays"] & (1 << $nowtime["tm_wday"])) { // Selected ?
1822
							$this->processOccurrenceItem($items, $start, $end, $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1823
						}
1824
					}
1825
				}
1826
				break;
1827
1828
			case IDC_RCEV_PAT_ORB_MONTHLY:
1829
				if ($this->recur["everyn"] <= 0) {
1830
					$this->recur["everyn"] = 1;
1831
				}
1832
1833
				// Loop through all months from start to end of occurrence, starting at beginning of first month
1834
				for ($now = $this->monthStartOf($daystart); $now <= $dayend && ($limit == 0 || count($items) < $limit); $now += $this->daysInMonth($now, $this->recur["everyn"]) * 24 * 60 * 60) {
0 ignored issues
show
Bug introduced by
$now of type double is incompatible with the type integer expected by parameter $date of BaseRecurrence::daysInMonth(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1834
				for ($now = $this->monthStartOf($daystart); $now <= $dayend && ($limit == 0 || count($items) < $limit); $now += $this->daysInMonth(/** @scrutinizer ignore-type */ $now, $this->recur["everyn"]) * 24 * 60 * 60) {
Loading history...
1835
					if (isset($this->recur["monthday"]) && ($this->recur['monthday'] != "undefined") && !$this->recur['regen']) { // Day M of every N months
1836
						$difference = 1;
1837
						if ($this->daysInMonth($now, $this->recur["everyn"]) < $this->recur["monthday"]) {
1838
							$difference = $this->recur["monthday"] - $this->daysInMonth($now, $this->recur["everyn"]) + 1;
1839
						}
1840
						$daynow = $now + (($this->recur["monthday"] - $difference) * 24 * 60 * 60);
1841
						// checks weather the next coming day in recurrence pattern is less than or equal to end day of the recurring item
1842
						if ($daynow <= $dayend) {
1843
							$this->processOccurrenceItem($items, $start, $end, $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
0 ignored issues
show
Bug introduced by
$daynow of type double is incompatible with the type false|integer expected by parameter $basedate of BaseRecurrence::processOccurrenceItem(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1843
							$this->processOccurrenceItem($items, $start, $end, /** @scrutinizer ignore-type */ $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
Loading history...
1844
						}
1845
					}
1846
					elseif (isset($this->recur["nday"], $this->recur["weekdays"])) { // Nth [weekday] of every N months
1847
						// Sanitize input
1848
						if ($this->recur["weekdays"] == 0) {
1849
							$this->recur["weekdays"] = 1;
1850
						}
1851
1852
						// If nday is not set to the last day in the month
1853
						if ($this->recur["nday"] < 5) {
1854
							// keep the track of no. of time correct selection pattern (like 2nd weekday, 4th friday, etc.) is matched
1855
							$ndaycounter = 0;
1856
							// Find matching weekday in this month
1857
							for ($day = 0, $total = $this->daysInMonth($now, 1); $day < $total; ++$day) {
1858
								$daynow = $now + $day * 60 * 60 * 24;
1859
								$nowtime = $this->gmtime($daynow); // Get the weekday of the current day
1860
1861
								if ($this->recur["weekdays"] & (1 << $nowtime["tm_wday"])) { // Selected ?
1862
									++$ndaycounter;
1863
								}
1864
								// check the selected pattern is same as asked Nth weekday,If so set the firstday
1865
								if ($this->recur["nday"] == $ndaycounter) {
1866
									$firstday = $day;
1867
									break;
1868
								}
1869
							}
1870
							// $firstday is the day of the month on which the asked pattern of nth weekday matches
1871
							$daynow = $now + $firstday * 60 * 60 * 24;
1872
						}
1873
						else {
1874
							// Find last day in the month ($now is the firstday of the month)
1875
							$NumDaysInMonth = $this->daysInMonth($now, 1);
1876
							$daynow = $now + (($NumDaysInMonth - 1) * 24 * 60 * 60);
1877
1878
							$nowtime = $this->gmtime($daynow);
0 ignored issues
show
Bug introduced by
$daynow of type double is incompatible with the type integer expected by parameter $time of BaseRecurrence::gmtime(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1878
							$nowtime = $this->gmtime(/** @scrutinizer ignore-type */ $daynow);
Loading history...
1879
							while (($this->recur["weekdays"] & (1 << $nowtime["tm_wday"])) == 0) {
1880
								$daynow -= 86400;
1881
								$nowtime = $this->gmtime($daynow);
1882
							}
1883
						}
1884
1885
						/*
1886
						* checks weather the next coming day in recurrence pattern is less than or equal to end day of the			* recurring item.Also check weather the coming day in recurrence pattern is greater than or equal to start * of recurring pattern, so that appointment that fall under the recurrence range are only displayed.
1887
						*/
1888
						if ($daynow <= $dayend && $daynow >= $daystart) {
1889
							$this->processOccurrenceItem($items, $start, $end, $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1890
						}
1891
					}
1892
					elseif ($this->recur['regen']) {
1893
						$next_month_start = $now + ($this->daysInMonth($now, 1) * 24 * 60 * 60);
1894
						$now = $daystart + ($this->daysInMonth($next_month_start, $this->recur['everyn']) * 24 * 60 * 60);
1895
1896
						if ($now <= $dayend) {
1897
							$this->processOccurrenceItem($items, $daystart, $end, $now, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1898
						}
1899
					}
1900
				}
1901
				break;
1902
1903
			case IDC_RCEV_PAT_ORB_YEARLY:
1904
				if ($this->recur["everyn"] <= 0) {
1905
					$this->recur["everyn"] = 12;
1906
				}
1907
1908
				for ($now = $this->yearStartOf($daystart); $now <= $dayend && ($limit == 0 || count($items) < $limit); $now += $this->daysInMonth($now, $this->recur["everyn"]) * 24 * 60 * 60) {
1909
					if (isset($this->recur["monthday"]) && !$this->recur['regen']) { // same as monthly, but in a specific month
1910
						// recur["month"] is in minutes since the beginning of the year
1911
						$month = $this->monthOfYear($this->recur["month"]); // $month is now month of year [0..11]
1912
						$monthday = $this->recur["monthday"]; // $monthday is day of the month [1..31]
1913
						$monthstart = $now + $this->daysInMonth($now, $month) * 24 * 60 * 60; // $monthstart is the timestamp of the beginning of the month
1914
						if ($monthday > $this->daysInMonth($monthstart, 1)) {
1915
							$monthday = $this->daysInMonth($monthstart, 1);
1916
						}	// Cap $monthday on month length (eg 28 feb instead of 29 feb)
1917
						$daynow = $monthstart + ($monthday - 1) * 24 * 60 * 60;
1918
						$this->processOccurrenceItem($items, $start, $end, $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1919
					}
1920
					elseif (isset($this->recur["nday"], $this->recur["weekdays"])) { // Nth [weekday] in month X of every N years
1921
						// Go the correct month
1922
						$monthnow = $now + $this->daysInMonth($now, $this->monthOfYear($this->recur["month"])) * 24 * 60 * 60;
1923
1924
						// Find first matching weekday in this month
1925
						for ($wday = 0; $wday < 7; ++$wday) {
1926
							$daynow = $monthnow + $wday * 60 * 60 * 24;
1927
							$nowtime = $this->gmtime($daynow); // Get the weekday of the current day
1928
1929
							if ($this->recur["weekdays"] & (1 << $nowtime["tm_wday"])) { // Selected ?
1930
								$firstday = $wday;
1931
								break;
1932
							}
1933
						}
1934
1935
						// Same as above (monthly)
1936
						$daynow = $monthnow + ($firstday + ($this->recur["nday"] - 1) * 7) * 60 * 60 * 24;
1937
1938
						while ($this->monthStartOf($daynow) != $this->monthStartOf($monthnow)) {
1939
							$daynow -= 7 * 60 * 60 * 24;
1940
						}
1941
1942
						$this->processOccurrenceItem($items, $start, $end, $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1943
					}
1944
					elseif ($this->recur['regen']) {
1945
						$year_starttime = $this->gmtime($now);
1946
						$is_next_leapyear = $this->isLeapYear($year_starttime['tm_year'] + 1900 + 1);	// +1 next year
1947
						$now = $daystart + ($is_next_leapyear ? 31622400 /* Leap year in seconds */ : 31536000 /* year in seconds */);
1948
1949
						if ($now <= $dayend) {
1950
							$this->processOccurrenceItem($items, $daystart, $end, $now, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1951
						}
1952
					}
1953
				}
1954
				break;
1955
		}
1956
		// to get all exception items
1957
		if (!empty($this->recur['changed_occurrences'])) {
1958
			$this->processExceptionItems($items, $start, $end);
0 ignored issues
show
Bug introduced by
The method processExceptionItems() does not exist on BaseRecurrence. It seems like you code against a sub-type of BaseRecurrence such as Recurrence. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1958
			$this->/** @scrutinizer ignore-call */ 
1959
          processExceptionItems($items, $start, $end);
Loading history...
1959
		}
1960
1961
		// sort items on starttime
1962
		usort($items, $this->sortStarttime(...));
1963
1964
		// Return the MAPI-compatible list of items for this object
1965
		return $items;
1966
	}
1967
1968
	/**
1969
	 * @psalm-return -1|0|1
1970
	 *
1971
	 * @param mixed $a
1972
	 * @param mixed $b
1973
	 */
1974
	public function sortStarttime($a, $b): int {
1975
		$aTime = $a[$this->proptags["startdate"]];
1976
		$bTime = $b[$this->proptags["startdate"]];
1977
1978
		return $aTime == $bTime ? 0 : ($aTime > $bTime ? 1 : -1);
1979
	}
1980
1981
	/**
1982
	 * daysInMonth.
1983
	 *
1984
	 * Returns the number of days in the upcoming number of months. If you specify 1 month as
1985
	 * $months it will give you the number of days in the month of $date. If you specify more it
1986
	 * will also count the days in the upcoming months and add that to the number of days. So
1987
	 * if you have a date in march and you specify $months as 2 it will return 61.
1988
	 *
1989
	 * @param int $date   specified date as timestamp from which you want to know the number
1990
	 *                    of days in the month
1991
	 * @param int $months number of months you want to know the number of days in
1992
	 *
1993
	 * @return float|int number of days in the specified amount of months
1994
	 */
1995
	public function daysInMonth($date, $months) {
1996
		$days = 0;
1997
1998
		for ($i = 0; $i < $months; ++$i) {
1999
			$days += date("t", $date + $days * 24 * 60 * 60);
2000
		}
2001
2002
		return $days;
2003
	}
2004
2005
	// Converts MAPI-style 'minutes' into the month of the year [0..11]
2006
	public function monthOfYear($minutes) {
2007
		$d = gmmktime(0, 0, 0, 1, 1, 2001); // The year 2001 was a non-leap year, and the minutes provided are always in non-leap-year-minutes
2008
2009
		$d += $minutes * 60;
2010
2011
		$dtime = $this->gmtime($d);
2012
2013
		return $dtime["tm_mon"];
2014
	}
2015
2016
	/**
2017
	 * @psalm-return -1|0|1
2018
	 *
2019
	 * @param mixed $a
2020
	 * @param mixed $b
2021
	 */
2022
	public function sortExceptionStart($a, $b): int {
2023
		return $a["start"] == $b["start"] ? 0 : ($a["start"] > $b["start"] ? 1 : -1);
2024
	}
2025
2026
	/**
2027
	 * Function to get all properties of a single changed exception.
2028
	 *
2029
	 * @param mixed $exception
2030
	 *
2031
	 * @return (mixed|true)[] associative array of properties for the exception
2032
	 *
2033
	 * @psalm-return array<mixed|true>
2034
	 */
2035
	public function getExceptionProperties($exception): array {
2036
		// Exception has same properties as main object, with some properties overridden:
2037
		$item = $this->messageprops;
2038
2039
		// Special properties
2040
		$item["exception"] = true;
2041
		$item["basedate"] = $exception["basedate"]; // note that the basedate is always in local time !
2042
2043
		// MAPI-compatible properties (you can handle an exception as a normal calendar item like this)
2044
		$item[$this->proptags["startdate"]] = $this->toGMT($this->tz, $exception["start"]);
2045
		$item[$this->proptags["duedate"]] = $this->toGMT($this->tz, $exception["end"]);
2046
		$item[$this->proptags["commonstart"]] = $item[$this->proptags["startdate"]];
2047
		$item[$this->proptags["commonend"]] = $item[$this->proptags["duedate"]];
2048
2049
		if (isset($exception["subject"])) {
2050
			$item[$this->proptags["subject"]] = $exception["subject"];
2051
		}
2052
2053
		if (isset($exception["label"])) {
2054
			$item[$this->proptags["label"]] = $exception["label"];
2055
		}
2056
2057
		if (isset($exception["alldayevent"])) {
2058
			$item[$this->proptags["alldayevent"]] = $exception["alldayevent"];
2059
		}
2060
2061
		if (isset($exception["location"])) {
2062
			$item[$this->proptags["location"]] = $exception["location"];
2063
		}
2064
2065
		if (isset($exception["remind_before"])) {
2066
			$item[$this->proptags["reminder_minutes"]] = $exception["remind_before"];
2067
		}
2068
2069
		if (isset($exception["reminder_set"])) {
2070
			$item[$this->proptags["reminder"]] = $exception["reminder_set"];
2071
		}
2072
2073
		if (isset($exception["busystatus"])) {
2074
			$item[$this->proptags["busystatus"]] = $exception["busystatus"];
2075
		}
2076
2077
		return $item;
2078
	}
2079
2080
	/**
2081
	 * @param false|int $start
2082
	 * @param false|int $basedate
2083
	 * @param mixed     $startocc
2084
	 * @param mixed     $endocc
2085
	 * @param mixed     $tz
2086
	 * @param mixed     $reminderonly
2087
	 */
2088
	abstract public function processOccurrenceItem(array &$items, $start, int $end, $basedate, $startocc, $endocc, $tz, $reminderonly);
2089
}
2090