BaseRecurrence::isLeapYear()   A
last analyzed

Complexity

Conditions 3

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

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

1785
				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...
1786
					if (isset($this->recur["monthday"]) && ($this->recur['monthday'] != "undefined") && !$this->recur['regen']) { // Day M of every N months
1787
						$difference = 1;
1788
						if ($this->daysInMonth($now, $this->recur["everyn"]) < $this->recur["monthday"]) {
1789
							$difference = $this->recur["monthday"] - $this->daysInMonth($now, $this->recur["everyn"]) + 1;
1790
						}
1791
						$daynow = $now + (($this->recur["monthday"] - $difference) * 24 * 60 * 60);
1792
						// checks weather the next coming day in recurrence pattern is less than or equal to end day of the recurring item
1793
						if ($daynow <= $dayend) {
1794
							$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

1794
							$this->processOccurrenceItem($items, $start, $end, /** @scrutinizer ignore-type */ $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
Loading history...
1795
						}
1796
					}
1797
					elseif (isset($this->recur["nday"], $this->recur["weekdays"])) { // Nth [weekday] of every N months
1798
						// Sanitize input
1799
						if ($this->recur["weekdays"] == 0) {
1800
							$this->recur["weekdays"] = 1;
1801
						}
1802
1803
						// If nday is not set to the last day in the month
1804
						if ($this->recur["nday"] < 5) {
1805
							// keep the track of no. of time correct selection pattern (like 2nd weekday, 4th friday, etc.) is matched
1806
							$ndaycounter = 0;
1807
							// Find matching weekday in this month
1808
							for ($day = 0, $total = $this->daysInMonth($now, 1); $day < $total; ++$day) {
1809
								$daynow = $now + $day * 60 * 60 * 24;
1810
								$nowtime = $this->gmtime($daynow); // Get the weekday of the current day
1811
1812
								if ($this->recur["weekdays"] & (1 << $nowtime["tm_wday"])) { // Selected ?
1813
									++$ndaycounter;
1814
								}
1815
								// check the selected pattern is same as asked Nth weekday,If so set the firstday
1816
								if ($this->recur["nday"] == $ndaycounter) {
1817
									$firstday = $day;
1818
									break;
1819
								}
1820
							}
1821
							// $firstday is the day of the month on which the asked pattern of nth weekday matches
1822
							$daynow = $now + $firstday * 60 * 60 * 24;
1823
						}
1824
						else {
1825
							// Find last day in the month ($now is the firstday of the month)
1826
							$NumDaysInMonth = $this->daysInMonth($now, 1);
1827
							$daynow = $now + (($NumDaysInMonth - 1) * 24 * 60 * 60);
1828
1829
							$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

1829
							$nowtime = $this->gmtime(/** @scrutinizer ignore-type */ $daynow);
Loading history...
1830
							while (($this->recur["weekdays"] & (1 << $nowtime["tm_wday"])) == 0) {
1831
								$daynow -= SECONDS_PER_DAY;
1832
								$nowtime = $this->gmtime($daynow);
1833
							}
1834
						}
1835
1836
						/*
1837
						* 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.
1838
						*/
1839
						if ($daynow <= $dayend && $daynow >= $daystart) {
1840
							$this->processOccurrenceItem($items, $start, $end, $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1841
						}
1842
					}
1843
					elseif ($this->recur['regen']) {
1844
						$next_month_start = $now + ($this->daysInMonth($now, 1) * 24 * 60 * 60);
1845
						$now = $daystart + ($this->daysInMonth($next_month_start, $this->recur['everyn']) * 24 * 60 * 60);
1846
1847
						if ($now <= $dayend) {
1848
							$this->processOccurrenceItem($items, $daystart, $end, $now, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1849
						}
1850
					}
1851
				}
1852
				break;
1853
1854
			case IDC_RCEV_PAT_ORB_YEARLY:
1855
				if ($this->recur["everyn"] <= 0) {
1856
					$this->recur["everyn"] = 12;
1857
				}
1858
1859
				for ($now = $this->yearStartOf($daystart); $now <= $dayend && ($limit == 0 || count($items) < $limit); $now += $this->daysInMonth($now, $this->recur["everyn"]) * 24 * 60 * 60) {
1860
					if (isset($this->recur["monthday"]) && !$this->recur['regen']) { // same as monthly, but in a specific month
1861
						// recur["month"] is in minutes since the beginning of the year
1862
						$month = $this->monthOfYear($this->recur["month"]); // $month is now month of year [0..11]
1863
						$monthday = $this->recur["monthday"]; // $monthday is day of the month [1..31]
1864
						$monthstart = $now + $this->daysInMonth($now, $month) * 24 * 60 * 60; // $monthstart is the timestamp of the beginning of the month
1865
						if ($monthday > $this->daysInMonth($monthstart, 1)) {
1866
							$monthday = $this->daysInMonth($monthstart, 1);
1867
						}	// Cap $monthday on month length (eg 28 feb instead of 29 feb)
1868
						$daynow = $monthstart + ($monthday - 1) * 24 * 60 * 60;
1869
						$this->processOccurrenceItem($items, $start, $end, $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1870
					}
1871
					elseif (isset($this->recur["nday"], $this->recur["weekdays"])) { // Nth [weekday] in month X of every N years
1872
						// Go the correct month
1873
						$monthnow = $now + $this->daysInMonth($now, $this->monthOfYear($this->recur["month"])) * 24 * 60 * 60;
1874
1875
						// Find first matching weekday in this month
1876
						for ($wday = 0; $wday < 7; ++$wday) {
1877
							$daynow = $monthnow + $wday * 60 * 60 * 24;
1878
							$nowtime = $this->gmtime($daynow); // Get the weekday of the current day
1879
1880
							if ($this->recur["weekdays"] & (1 << $nowtime["tm_wday"])) { // Selected ?
1881
								$firstday = $wday;
1882
								break;
1883
							}
1884
						}
1885
1886
						// Same as above (monthly)
1887
						$daynow = $monthnow + ($firstday + ($this->recur["nday"] - 1) * 7) * 60 * 60 * 24;
1888
1889
						while ($this->monthStartOf($daynow) != $this->monthStartOf($monthnow)) {
0 ignored issues
show
Bug introduced by
$daynow of type double is incompatible with the type integer expected by parameter $date of BaseRecurrence::monthStartOf(). ( Ignorable by Annotation )

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

1889
						while ($this->monthStartOf(/** @scrutinizer ignore-type */ $daynow) != $this->monthStartOf($monthnow)) {
Loading history...
1890
							$daynow -= 7 * 60 * 60 * 24;
1891
						}
1892
1893
						$this->processOccurrenceItem($items, $start, $end, $daynow, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1894
					}
1895
					elseif ($this->recur['regen']) {
1896
						$year_starttime = $this->gmtime($now);
1897
						$is_next_leapyear = $this->isLeapYear($year_starttime['tm_year'] + 1900 + 1);	// +1 next year
1898
						$now = $daystart + ($is_next_leapyear ? 31622400 /* Leap year in seconds */ : 31536000 /* year in seconds */);
1899
1900
						if ($now <= $dayend) {
1901
							$this->processOccurrenceItem($items, $daystart, $end, $now, $this->recur["startocc"], $this->recur["endocc"], $this->tz, $remindersonly);
1902
						}
1903
					}
1904
				}
1905
				break;
1906
		}
1907
		// to get all exception items
1908
		if (!empty($this->recur['changed_occurrences'])) {
1909
			$this->processExceptionItems($items, $start, $end);
1910
		}
1911
1912
		// sort items on starttime
1913
		usort($items, $this->sortStarttime(...));
1914
1915
		// Return the MAPI-compatible list of items for this object
1916
		return $items;
1917
	}
1918
1919
	/**
1920
	 * @psalm-return -1|0|1
1921
	 */
1922
	public function sortStarttime(mixed $a, mixed $b): int {
1923
		$aTime = $a[$this->proptags["startdate"]];
1924
		$bTime = $b[$this->proptags["startdate"]];
1925
1926
		return $aTime == $bTime ? 0 : ($aTime > $bTime ? 1 : -1);
1927
	}
1928
1929
	/**
1930
	 * daysInMonth.
1931
	 *
1932
	 * Returns the number of days in the upcoming number of months. If you specify 1 month as
1933
	 * $months it will give you the number of days in the month of $date. If you specify more it
1934
	 * will also count the days in the upcoming months and add that to the number of days. So
1935
	 * if you have a date in march and you specify $months as 2 it will return 61.
1936
	 *
1937
	 * @param int $date   specified date as timestamp from which you want to know the number
1938
	 *                    of days in the month
1939
	 * @param int $months number of months you want to know the number of days in
1940
	 *
1941
	 * @return float|int number of days in the specified amount of months
1942
	 */
1943
	public function daysInMonth(int $date, int $months): float|int {
1944
		$days = 0;
1945
1946
		for ($i = 0; $i < $months; ++$i) {
1947
			$days += date("t", $date + $days * 24 * 60 * 60);
1948
		}
1949
1950
		return $days;
1951
	}
1952
1953
	// Converts MAPI-style 'minutes' into the month of the year [0..11]
1954
	public function monthOfYear(int $minutes): int {
1955
		$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
1956
1957
		$d += $minutes * 60;
1958
1959
		$dtime = $this->gmtime($d);
1960
1961
		return $dtime["tm_mon"];
1962
	}
1963
1964
	/**
1965
	 * @psalm-return -1|0|1
1966
	 */
1967
	public function sortExceptionStart(mixed $a, mixed $b): int {
1968
		return $a["start"] == $b["start"] ? 0 : ($a["start"] > $b["start"] ? 1 : -1);
1969
	}
1970
1971
	/**
1972
	 * Function to get all exception items in the given range.
1973
	 *
1974
	 * @param array $items reference to the array to be added to
1975
	 * @param int   $start start of timeframe in GMT TIME
1976
	 * @param int   $end   end of timeframe in GMT TIME
1977
	 */
1978
	public function processExceptionItems(&$items, $start, $end): void {
1979
		$limit = 0;
1980
		foreach ($this->recur["changed_occurrences"] as $exception) {
1981
			// Convert to GMT
1982
			$occstart = $this->toGMT($this->tz, $exception["start"]);
1983
			$occend = $this->toGMT($this->tz, $exception["end"]);
1984
1985
			// Check range criterium. Exact matches (eg when $occstart == $end), do NOT match since you cannot
1986
			// see any part of the appointment. Partial overlaps DO match.
1987
			if ($occstart >= $end || $occend <= $start) {
1988
				continue;
1989
			}
1990
1991
			$items[] = $this->getExceptionProperties($exception);
1992
			if (count($items) == $limit) {
1993
				break;
1994
			}
1995
		}
1996
	}
1997
1998
	/**
1999
	 * Function to get all properties of a single changed exception.
2000
	 *
2001
	 * @return (mixed|true)[] associative array of properties for the exception
2002
	 *
2003
	 * @psalm-return array<mixed|true>
2004
	 */
2005
	public function getExceptionProperties(mixed $exception): array {
2006
		// Exception has same properties as main object, with some properties overridden:
2007
		$item = $this->messageprops;
2008
2009
		// Special properties
2010
		$item["exception"] = true;
2011
		$item["basedate"] = $exception["basedate"]; // note that the basedate is always in local time !
2012
2013
		// MAPI-compatible properties (you can handle an exception as a normal calendar item like this)
2014
		$item[$this->proptags["startdate"]] = $this->toGMT($this->tz, $exception["start"]);
2015
		$item[$this->proptags["duedate"]] = $this->toGMT($this->tz, $exception["end"]);
2016
		$item[$this->proptags["commonstart"]] = $item[$this->proptags["startdate"]];
2017
		$item[$this->proptags["commonend"]] = $item[$this->proptags["duedate"]];
2018
2019
		if (isset($exception["subject"])) {
2020
			$item[$this->proptags["subject"]] = $exception["subject"];
2021
		}
2022
2023
		if (isset($exception["label"])) {
2024
			$item[$this->proptags["label"]] = $exception["label"];
2025
		}
2026
2027
		if (isset($exception["alldayevent"])) {
2028
			$item[$this->proptags["alldayevent"]] = $exception["alldayevent"];
2029
		}
2030
2031
		if (isset($exception["location"])) {
2032
			$item[$this->proptags["location"]] = $exception["location"];
2033
		}
2034
2035
		if (isset($exception["remind_before"])) {
2036
			$item[$this->proptags["reminder_minutes"]] = $exception["remind_before"];
2037
		}
2038
2039
		if (isset($exception["reminder_set"])) {
2040
			$item[$this->proptags["reminder"]] = $exception["reminder_set"];
2041
		}
2042
2043
		if (isset($exception["busystatus"])) {
2044
			$item[$this->proptags["busystatus"]] = $exception["busystatus"];
2045
		}
2046
2047
		return $item;
2048
	}
2049
2050
	abstract public function processOccurrenceItem(array &$items, false|int $start, int $end, false|int $basedate, mixed $startocc, mixed $endocc, mixed $tz, mixed $reminderonly): ?false;
2051
}
2052