1: <?php
2: declare(strict_types=1);
3:
4: namespace om;
5:
6: use ArrayObject;
7: use DateInterval;
8: use DateTime;
9: use DateTimeInterface;
10: use DateTimeZone;
11: use Exception;
12: use InvalidArgumentException;
13: use om\ICal\ContentLine;
14: use om\ICal\Value\Duration;
15: use om\RRule\RecurrenceSet;
16: use om\RRule\Rule;
17: use RuntimeException;
18:
19: /**
20: * iCalendar (RFC 5545) parser producing PHP arrays.
21: *
22: * Copyright (c) Roman Ožana (https://ozana.cz)
23: *
24: * @license BSD-3-Clause
25: * @author Roman Ožana <roman@ozana.cz>
26: *
27: * @deprecated 5.0, removed in 5.5 at the latest; use om\ICal::parse() or om\ICal::parser(), see UPGRADING.md
28: */
29: class IcalParser {
30: private const array DATE_PROPERTIES = ['DTSTAMP' => true, 'LAST-MODIFIED' => true, 'CREATED' => true, 'DTSTART' => true, 'DTEND' => true, 'DUE' => true, 'COMPLETED' => true];
31: private const array MULTIPLE_KEYS = ['ATTACH' => 'ATTACHMENTS', 'EXDATE' => 'EXDATES', 'RDATE' => 'RDATES', 'ATTENDEE' => 'ATTENDEES'];
32: private const array COMMA_SEPARATED_KEYS = ['X-CATEGORIES' => 'X-CATEGORIES', 'CATEGORIES' => 'CATEGORIES'];
33: private const array META_KEYS = ['DTSTART' => true, 'RRULE' => true, 'EXDATE' => true, 'RECURRENCE-ID' => true];
34:
35: /** Properties of the TEXT value type (RFC 5545, section 3.3.11) that are unescaped. */
36: private const array TEXT_PROPERTIES = [
37: 'CALSCALE' => true, 'METHOD' => true, 'PRODID' => true, 'VERSION' => true, 'CATEGORIES' => true,
38: 'CLASS' => true, 'COMMENT' => true, 'DESCRIPTION' => true, 'LOCATION' => true, 'RESOURCES' => true,
39: 'STATUS' => true, 'SUMMARY' => true, 'TRANSP' => true, 'TZID' => true, 'TZNAME' => true, 'CONTACT' => true,
40: 'RELATED-TO' => true, 'UID' => true, 'ACTION' => true, 'REQUEST-STATUS' => true, 'URL' => true,
41: ];
42:
43: private const array TEXT_ESCAPES = ['\\\\' => '\\', '\\N' => "\n", '\\n' => "\n", '\\;' => ';', '\\,' => ','];
44:
45: /** Timezone of floating dates: the last X-WR-TIMEZONE or TZID property seen. */
46: public ?DateTimeZone $timezone = null;
47: /** @var array<string, mixed>|null */
48: public ?array $data = null;
49: /** @var array<string, int> */
50: protected array $counters = [];
51:
52: private readonly ParserOptions $options;
53: private readonly TimezoneResolver $timezones;
54:
55: /**
56: * Parser details of components that are not part of the public data:
57: * date-only flags, raw RRULE and RECURRENCE-ID timezone.
58: *
59: * @var array<string, array<int, array<string, mixed>>>
60: */
61: private array $meta = [];
62:
63: /**
64: * Overridden instances (VEVENT with RECURRENCE-ID) by UID.
65: *
66: * @var array<string, list<array{value: string, timezone: ?DateTimeZone}>>
67: */
68: private array $overrides = [];
69:
70: public function __construct(?ParserOptions $options = null) {
71: $this->options = $options ?? new ParserOptions();
72: $this->timezones = new TimezoneResolver($this->options->windowsTimezones ?? []);
73: }
74:
75: /**
76: * Parse a file or any stream wrapper URL.
77: *
78: * @throws RuntimeException when the file cannot be read
79: * @throws InvalidArgumentException when the content is not iCalendar data
80: */
81: /**
82: * @return array<string, mixed>|null
83: */
84: public function parseFile(string $file, ?callable $callback = null): ?array {
85: // the content is passed as a temporary value, so parseString() can free it while normalizing
86: return $this->parseString(self::readFile($file), $callback);
87: }
88:
89: private static function readFile(string $file): string {
90: $content = @file_get_contents($file);
91: if ($content === false) {
92: throw new RuntimeException(sprintf('Cannot read iCalendar file "%s".', $file));
93: }
94: return $content;
95: }
96:
97: /**
98: * Parse iCalendar data.
99: *
100: * With a callback, rows are not stored; the callback receives every property row as
101: * ($row, $key, $middle, $value, $section, $counter) and the method returns null.
102: *
103: * @param bool $add if true the parsed string is added to existing data
104: * @return array<string, mixed>|null
105: * @throws InvalidArgumentException when the content is not iCalendar data
106: */
107: public function parseString(string $string, ?callable $callback = null, bool $add = false): ?array {
108: if (stripos($string, 'BEGIN:VCALENDAR') === false) {
109: throw new InvalidArgumentException('Invalid ICAL data format');
110: }
111:
112: if ($add === false || $this->data === null) {
113: $this->data = [];
114: $this->counters = [];
115: $this->meta = [];
116: $this->overrides = [];
117: $this->timezone = null;
118: }
119:
120: // Normalize line breaks and unfold lines (RFC 5545, section 3.1). Each replacement
121: // copies the whole string, so rare patterns are replaced only when present.
122: $string = str_replace("\r\n", "\n", $string);
123: if (str_contains($string, "\r")) {
124: $string = str_replace("\r", "\n", $string);
125: }
126: $string = str_replace("\n ", '', $string);
127: if (str_contains($string, "\n\t")) {
128: $string = str_replace("\n\t", '', $string);
129: }
130: if (str_starts_with($string, "\u{FEFF}")) {
131: $string = substr($string, 3);
132: }
133:
134: $section = 'VCALENDAR';
135: $parents = [];
136:
137: foreach (explode("\n", $string) as $row) {
138: if ($row === '') {
139: continue;
140: }
141:
142: if (strncasecmp($row, 'BEGIN:', 6) === 0) {
143: $component = strtoupper(trim(substr($row, 6)));
144: if ($component !== 'VCALENDAR') {
145: $parents[] = $section;
146: $section = $component;
147: $this->counters[$section] = isset($this->counters[$section]) ? $this->counters[$section] + 1 : 0;
148: if ($callback === null) {
149: $this->data[$section][$this->counters[$section]] = [];
150: }
151: }
152: continue;
153: }
154:
155: if (strncasecmp($row, 'END:', 4) === 0) {
156: $component = strtoupper(trim(substr($row, 4)));
157: if ($component !== 'VCALENDAR') {
158: if ($component === 'VEVENT' && $callback === null && isset($this->data['VEVENT'][$this->counters['VEVENT'] ?? -1]['RECURRENCE-ID'])) {
159: $this->registerOverride($this->counters['VEVENT']);
160: }
161: $section = array_pop($parents) ?? 'VCALENDAR';
162: }
163: continue;
164: }
165:
166: $row = $this->parseRow($row);
167: if ($row === null) {
168: continue;
169: }
170: [$key, $middle, $value, $raw, $line] = $row;
171:
172: if ($callback) {
173: $callback($line, $key, $middle, $value, $section, $this->counters[$section] ?? 0);
174: } elseif ($section === 'VCALENDAR') {
175: $this->data[$key] = $value;
176: } else {
177: $this->store($section, $this->counters[$section], $key, $middle, $value, $raw);
178: }
179: }
180:
181: if ($callback) {
182: return null;
183: }
184:
185: $this->expandRecurringEvents();
186: return $this->data;
187: }
188:
189: /**
190: * Expand the recurrence set of an event (RRULE, RDATE and EXDATE) into DateTime objects.
191: *
192: * @param array<string, mixed> $event parsed VEVENT
193: * @return list<DateTime>
194: * @throws InvalidArgumentException for an invalid RRULE in strict mode
195: */
196: public function parseRecurrences(array $event): array {
197: // details such as a date-only UNTIL or EXDATE are known for events of the parsed data
198: $counter = array_search($event, $this->data['VEVENT'] ?? [], true);
199: return $this->recurrences($event, $counter === false ? [] : $this->meta['VEVENT'][$counter] ?? []);
200: }
201:
202: public function isMultipleKey(string $key): ?string {
203: return self::MULTIPLE_KEYS[$key] ?? null;
204: }
205:
206: public function isMultipleKeyWithCommaSeparation(string $key): ?string {
207: return self::COMMA_SEPARATED_KEYS[$key] ?? null;
208: }
209:
210: /**
211: * @return array<int, array<string, mixed>>
212: */
213: public function getAlarms(): array {
214: return $this->data['VALARM'] ?? [];
215: }
216:
217: /**
218: * @return array<int, array<string, mixed>>
219: */
220: public function getTimezone(): array {
221: return $this->getTimezones();
222: }
223:
224: /**
225: * @return array<int, array<string, mixed>>
226: */
227: public function getTimezones(): array {
228: return $this->data['VTIMEZONE'] ?? [];
229: }
230:
231: /**
232: * @return array<int, array<string, mixed>>
233: */
234: public function getTodos(): array {
235: return array_values($this->data['VTODO'] ?? []);
236: }
237:
238: /**
239: * @return array<int, array<string, mixed>>
240: */
241: public function getJournals(): array {
242: return array_values($this->data['VJOURNAL'] ?? []);
243: }
244:
245: /**
246: * Return sorted event list as ArrayObject
247: *
248: * @deprecated use IcalParser::getEvents()->sorted() instead
249: * @return ArrayObject<int, array<string, mixed>>
250: */
251: public function getSortedEvents(): ArrayObject {
252: return $this->getEvents()->sorted();
253: }
254:
255: /**
256: * @deprecated use IcalParser::getEvents()->reversed() instead
257: * @return ArrayObject<int, array<string, mixed>>
258: */
259: public function getReverseSortedEvents(): ArrayObject {
260: return $this->getEvents()->reversed();
261: }
262:
263: /**
264: * Events with recurring events expanded into single instances.
265: *
266: * Every instance has DTEND: from DTEND, from DURATION, or one day for all-day
267: * events (RFC 5545, section 3.6.1). Recurring instances also carry RECURRING
268: * and RECURRENCE_INSTANCE (zero based).
269: */
270: public function getEvents(): EventsList {
271: $events = new EventsList();
272: foreach ($this->data['VEVENT'] ?? [] as $counter => $event) {
273: $start = $event['DTSTART'] ?? null;
274: if (!isset($event['RECURRENCES']) || !$start instanceof DateTimeInterface) {
275: if (!array_key_exists('DTEND', $event) && $start instanceof DateTimeInterface
276: && ($duration = $this->duration($event, $this->meta['VEVENT'][$counter] ?? [])) !== null) {
277: $end = DateTime::createFromInterface($start);
278: $event['DTEND'] = $end->add($duration);
279: }
280: $events->append($event);
281: continue;
282: }
283:
284: $event['RECURRING'] = true;
285: $duration = $this->duration($event, $this->meta['VEVENT'][$counter] ?? []) ?? new DateInterval('PT0S');
286: $template = $event;
287: unset($template['RECURRENCES']);
288: foreach ($event['RECURRENCES'] as $index => $date) {
289: if (!$date instanceof DateTime) {
290: continue;
291: }
292: $instance = $index === 0 ? $event : $template;
293: $instance['DTSTART'] = clone $date;
294: $instance['DTEND'] = (clone $date)->add($duration);
295: $instance['RECURRENCE_INSTANCE'] = $index;
296: $events->append($instance);
297: }
298: }
299: return $events;
300: }
301:
302: /**
303: * Store a property of a component in the public data array.
304: */
305: private function store(string $section, int $counter, string $key, mixed $middle, mixed $value, string $raw): void {
306: $this->data ??= [];
307:
308: // Multiple entries are collected in an array under a separate key,
309: // the original key keeps the last value.
310: if ($newKey = self::MULTIPLE_KEYS[$key] ?? null) {
311: $this->data[$section][$counter][$newKey][] = $value;
312: }
313:
314: if (isset(self::COMMA_SEPARATED_KEYS[$key])) {
315: // split on commas not preceded by backslash, then unescape
316: foreach (preg_split('/(?<!\\\\),/', $raw) ?: [] as $item) {
317: $this->data[$section][$counter][$key][] = trim(strtr($item, self::TEXT_ESCAPES));
318: }
319: return;
320: }
321:
322: if ($key === 'ORGANIZER') {
323: foreach (is_array($middle) ? $middle : [] as $midKey => $midVal) {
324: $this->data[$section][$counter][$key . '-' . $midKey] = $midVal;
325: }
326: }
327: if ($key === 'ATTENDEE' || $key === 'ORGANIZER') {
328: $value = $value['VALUE']; // backwards compatibility (leaves ATTENDEE entry as it was)
329: }
330: $this->data[$section][$counter][$key] = $value;
331:
332: if (isset(self::META_KEYS[$key])) {
333: $this->storeMeta($section, $counter, $key, $middle, $raw);
334: }
335: }
336:
337: /**
338: * Remember parser details that the public data cannot express.
339: * Only values other than the defaults are stored, to keep large calendars small.
340: */
341: private function storeMeta(string $section, int $counter, string $key, mixed $middle, string $raw): void {
342: $params = is_array($middle) ? $middle : [];
343: $dateOnly = ($params['VALUE'] ?? null) === 'DATE';
344: switch ($key) {
345: case 'DTSTART':
346: if ($dateOnly || (strlen($raw) === 8 && ctype_digit($raw))) {
347: $this->meta[$section][$counter]['dateOnly'] = true;
348: } else {
349: unset($this->meta[$section][$counter]['dateOnly']);
350: }
351: break;
352: case 'RRULE':
353: $this->meta[$section][$counter]['rrule'] = $raw;
354: break;
355: case 'EXDATE':
356: foreach (explode(',', $raw) as $item) {
357: $item = trim($item);
358: if ($dateOnly || preg_match('/^\d{8}$/D', $item)) {
359: $this->meta[$section][$counter]['exdateDays'][] = substr($item, 0, 8);
360: }
361: }
362: break;
363: case 'RECURRENCE-ID':
364: if (($params['TZID'] ?? null) instanceof DateTimeZone) {
365: $this->meta[$section][$counter]['recurrenceIdTimezone'] = $params['TZID'];
366: }
367: break;
368: }
369: }
370:
371: private function registerOverride(int $counter): void {
372: $event = $this->data['VEVENT'][$counter] ?? [];
373: if (!isset($event['RECURRENCE-ID'], $event['UID']) || !is_string($event['RECURRENCE-ID'])) {
374: return;
375: }
376: $this->data['_RECURRENCE_IDS'][$event['UID']][$event['RECURRENCE-ID']] = $event;
377: $this->overrides[$event['UID']][] = [
378: 'value' => $event['RECURRENCE-ID'],
379: 'timezone' => $this->meta['VEVENT'][$counter]['recurrenceIdTimezone'] ?? null,
380: ];
381: }
382:
383: private function expandRecurringEvents(): void {
384: foreach ($this->data['VEVENT'] ?? [] as $counter => $event) {
385: if (empty($event['RRULE']) && empty($event['RDATE']) && empty($event['EXDATE'])) {
386: continue;
387: }
388: if (!($event['DTSTART'] ?? null) instanceof DateTimeInterface) {
389: continue;
390: }
391: $this->data['VEVENT'][$counter]['RECURRENCES'] = $this->recurrences($event, $this->meta['VEVENT'][$counter] ?? []);
392: if (!empty($event['UID'])) {
393: $this->data['_RECURRENCE_COUNTERS_BY_UID'][$event['UID']] = $counter;
394: }
395: }
396: }
397:
398: /**
399: * @param array<string, mixed> $event
400: * @param array<string, mixed> $meta
401: * @return list<DateTime>
402: */
403: private function recurrences(array $event, array $meta): array {
404: $start = $event['DTSTART'] ?? null;
405: if (!$start instanceof DateTimeInterface) {
406: throw new InvalidArgumentException('A recurring event requires a valid DTSTART.');
407: }
408: $start = DateTime::createFromInterface($start);
409: $timezone = $start->getTimezone();
410:
411: $rule = null;
412: if (!empty($event['RRULE'])) {
413: try {
414: $rule = isset($meta['rrule']) ? Rule::fromString($meta['rrule'], true) : Rule::fromArray($event['RRULE'], true);
415: $rule->assertGregorian(); // another calendar system (RFC 7529) is not expanded
416: } catch (InvalidArgumentException $e) {
417: if ($this->options->strict) {
418: throw $e;
419: }
420: $rule = null;
421: }
422: }
423:
424: // Rules without an end are expanded until the horizon, optionally skipping old occurrences
425: $until = $from = null;
426: if ($rule !== null && $rule->count === null && $rule->until === null) {
427: $now = $this->options->now();
428: $until = ($this->options->untilInterval ? $now->add($this->options->untilInterval) : $now)->getTimestamp();
429: if ($this->options->shiftEventDates) {
430: $from = $now->sub($this->options->shiftEventDates)->getTimestamp();
431: }
432: }
433:
434: $set = new RecurrenceSet(
435: $start,
436: $rule,
437: rdates: self::timestamps($event['RDATES'] ?? []),
438: exdates: self::timestamps($event['EXDATES'] ?? []),
439: exdays: $meta['exdateDays'] ?? [],
440: until: $until,
441: from: $from,
442: limit: $this->options->maxOccurrences,
443: strict: $this->options->strict,
444: );
445: [$overriddenTimestamps, $overriddenDays] = $this->overriddenInstances($event['UID'] ?? null, $timezone);
446:
447: $recurrences = [];
448: foreach ($set as $timestamp) {
449: if (isset($overriddenTimestamps[$timestamp])) {
450: continue;
451: }
452: $date = (clone $start)->setTimestamp($timestamp);
453: if ($overriddenDays !== [] && isset($overriddenDays[$date->format('Ymd')])) {
454: continue;
455: }
456: $recurrences[] = $date;
457: }
458: return $recurrences;
459: }
460:
461: /**
462: * Instances replaced by a VEVENT with the same UID and a RECURRENCE-ID.
463: *
464: * @return array{array<int, true>, array<string, true>} timestamps, and days of date-only IDs
465: */
466: private function overriddenInstances(?string $uid, DateTimeZone $timezone): array {
467: $timestamps = $days = [];
468: foreach ($this->overrides[$uid ?? ''] ?? [] as ['value' => $value, 'timezone' => $idTimezone]) {
469: $value = trim($value);
470: if (preg_match('/^\d{8}$/D', $value)) {
471: $days[$value] = true;
472: continue;
473: }
474: try {
475: $timestamps[(new DateTime($value, $idTimezone ?? $timezone))->getTimestamp()] = true;
476: } catch (Exception) {
477: // invalid RECURRENCE-ID matches nothing
478: }
479: }
480: return [$timestamps, $days];
481: }
482:
483: /**
484: * @param array<string, mixed> $event
485: * @param array<string, mixed> $meta
486: */
487: private function duration(array $event, array $meta): ?DateInterval {
488: if (($event['DTEND'] ?? null) instanceof DateTimeInterface) {
489: return $event['DTSTART']->diff($event['DTEND']);
490: }
491: if (is_string($event['DURATION'] ?? null) && ($duration = self::parseDuration($event['DURATION'])) !== null) {
492: return $duration;
493: }
494: return !empty($meta['dateOnly']) ? new DateInterval('P1D') : null;
495: }
496:
497: /**
498: * Parse a DURATION value (RFC 5545, section 3.3.6), e.g. "PT1H30M", "-P1W" or "P1DT12H".
499: */
500: public static function parseDuration(string $value): ?DateInterval {
501: return Duration::parse($value);
502: }
503:
504: /**
505: * @param array<mixed> $dates EXDATES or RDATES: dates and lists of dates
506: * @return list<int>
507: */
508: private static function timestamps(array $dates): array {
509: $result = [];
510: foreach ($dates as $date) {
511: foreach (is_array($date) ? $date : [$date] as $single) {
512: if ($single instanceof DateTimeInterface) {
513: $result[] = $single->getTimestamp();
514: }
515: }
516: }
517: return $result;
518: }
519:
520: /**
521: * Parse one content line (RFC 5545, section 3.1) into its name, parameters and value.
522: *
523: * @return array{string, mixed, mixed, string, string}|null [key, middle, value, raw value, line]
524: */
525: private function parseRow(string $row): ?array {
526: $line = ContentLine::split($row);
527: if ($line === null) {
528: return null;
529: }
530: [$key, $middle, $raw] = $line;
531: $value = $raw;
532: $timezone = null;
533:
534: if ($key === 'X-WR-TIMEZONE' || $key === 'TZID') {
535: $resolved = $this->timezones->resolve($value);
536: if ($resolved !== null) {
537: $value = $resolved->getName();
538: $this->timezone = $resolved;
539: }
540: }
541:
542: if ($middle !== '' && ($params = ContentLine::parameters($middle)) !== []) {
543: $middle = [];
544: foreach ($params as $name => $paramValue) {
545: if ($name === 'TZID') {
546: $resolved = $this->timezones->resolve($paramValue);
547: $middle[$name] = $resolved ?? $paramValue;
548: $timezone = $resolved;
549: } elseif ($name === 'ENCODING') {
550: if (strtoupper($paramValue) === 'QUOTED-PRINTABLE') {
551: $value = $raw = quoted_printable_decode($value);
552: }
553: } else {
554: $middle[$name] = $paramValue;
555: }
556: }
557: }
558:
559: if (isset(self::DATE_PROPERTIES[$key])) {
560: $value = self::createDate($value, $timezone ?? $this->timezone);
561: } elseif ($key === 'EXDATE' || $key === 'RDATE') {
562: $values = [];
563: foreach (explode(',', $value) as $singleValue) {
564: // a PERIOD value (start/end or start/duration) is represented by its start
565: $singleValue = strstr($singleValue, '/', true) ?: $singleValue;
566: if (($date = self::createDate($singleValue, $timezone ?? $this->timezone)) !== null) {
567: $values[] = $date;
568: }
569: }
570: $value = count($values) === 1 ? $values[0] : $values;
571: } elseif ($key === 'RRULE' && preg_match_all('#(?<key>[^=;]+)=(?<value>[^;]+)#', $value, $matches, PREG_SET_ORDER)) {
572: $middle = null;
573: $value = [];
574: foreach ($matches as $match) {
575: if ($match['key'] === 'UNTIL') {
576: $value[$match['key']] = self::createDate($match['value'], $timezone ?? $this->timezone) ?? $match['value'];
577: } else {
578: $value[$match['key']] = $match['value'];
579: }
580: }
581: } elseif (isset(self::TEXT_PROPERTIES[$key]) || str_starts_with($key, 'X-')) {
582: // 3.3.11 Text ESCAPED-CHAR
583: $value = strtr($value, self::TEXT_ESCAPES);
584: }
585:
586: if ($key === 'ATTENDEE' || $key === 'ORGANIZER') {
587: $value = array_merge(is_array($middle) ? $middle : ['middle' => $middle], ['VALUE' => $value]);
588: }
589:
590: return [$key, $middle, $value, $raw, $row];
591: }
592:
593: private static function createDate(string $value, ?DateTimeZone $timezone): ?DateTime {
594: try {
595: // Fast path for UTC values like 20240105T100000Z: resolving the "Z" abbreviation
596: // is slow, so the date is read in UTC and the shared "Z" timezone is attached.
597: if (strlen($value) === 16 && $value[15] === 'Z' && $value[8] === 'T') {
598: static $utc, $zulu;
599: $utc ??= new DateTimeZone('UTC');
600: $zulu ??= (new DateTime('20000101T000000Z'))->getTimezone();
601: return (new DateTime(substr($value, 0, 15), $utc))->setTimezone($zulu);
602: }
603: return new DateTime($value, $timezone);
604: } catch (Exception) {
605: return null;
606: }
607: }
608: }
609: