1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal\Value;
5:
6: use DateInterval;
7: use om\ICal\Component;
8: use om\ICal\Exception\InvalidRecurrenceRuleException;
9: use om\ICal\Exception\InvalidValueException;
10: use om\ICal\Property;
11: use om\ICal\Timezone\CompositeTimezoneResolver;
12: use om\ICal\Timezone\ResolvedTimezone;
13: use om\ICal\Timezone\TimezoneResolver;
14: use om\RRule\Rule;
15:
16: /**
17: * Converts raw property values to typed values (RFC 5545, section 3.3).
18: *
19: * In strict mode an invalid value throws InvalidValueException; otherwise the accessor
20: * returns null (or skips the invalid item of a list).
21: */
22: final class ValueParser {
23: /**
24: * Default value types of properties (RFC 5545, section 3.8, and its updates), TEXT otherwise.
25: * RFC 7986 requires the VALUE parameter for IMAGE, CONFERENCE and REFRESH-INTERVAL; it is
26: * assumed when missing.
27: */
28: public const array TYPES = [
29: 'DTSTART' => 'DATE-TIME', 'DTEND' => 'DATE-TIME', 'DUE' => 'DATE-TIME', 'DTSTAMP' => 'DATE-TIME',
30: 'CREATED' => 'DATE-TIME', 'LAST-MODIFIED' => 'DATE-TIME', 'COMPLETED' => 'DATE-TIME',
31: 'RECURRENCE-ID' => 'DATE-TIME', 'EXDATE' => 'DATE-TIME', 'RDATE' => 'DATE-TIME',
32: 'DURATION' => 'DURATION', 'TRIGGER' => 'DURATION', 'FREEBUSY' => 'PERIOD',
33: 'SEQUENCE' => 'INTEGER', 'PRIORITY' => 'INTEGER', 'PERCENT-COMPLETE' => 'INTEGER', 'REPEAT' => 'INTEGER',
34: 'RRULE' => 'RECUR', 'EXRULE' => 'RECUR', 'ATTENDEE' => 'CAL-ADDRESS', 'ORGANIZER' => 'CAL-ADDRESS',
35: 'URL' => 'URI', 'TZURL' => 'URI', 'ATTACH' => 'URI', 'GEO' => 'FLOAT',
36: 'TZOFFSETFROM' => 'UTC-OFFSET', 'TZOFFSETTO' => 'UTC-OFFSET',
37: // RFC 7986
38: 'IMAGE' => 'URI', 'CONFERENCE' => 'URI', 'SOURCE' => 'URI', 'REFRESH-INTERVAL' => 'DURATION',
39: 'ACKNOWLEDGED' => 'DATE-TIME', // RFC 9074, in UTC
40: 'LINK' => 'URI', 'CONCEPT' => 'URI', // RFC 9253
41: ];
42:
43: /** Properties with a list of values. */
44: private const array LISTS = ['EXDATE' => true, 'RDATE' => true, 'FREEBUSY' => true, 'CATEGORIES' => true, 'RESOURCES' => true];
45:
46: /** @var array<string, ?ResolvedTimezone> */
47: private array $timezones = [];
48: /** @var list<array{string, string}>|null problems found by diagnose() */
49: private ?array $problems = null;
50: private readonly TimezoneResolver $resolver;
51:
52: /**
53: * @param Component $calendar the VCALENDAR component, used to resolve TZID parameters
54: * @param ?TimezoneResolver $resolver CompositeTimezoneResolver::default() when null
55: */
56: public function __construct(
57: private readonly Component $calendar = new Component('VCALENDAR'),
58: ?TimezoneResolver $resolver = null,
59: private readonly bool $strict = false,
60: ) {
61: $this->resolver = $resolver ?? CompositeTimezoneResolver::default();
62: }
63:
64: /**
65: * Value type from the VALUE parameter or the default type of the property.
66: */
67: public static function type(Property $property): string {
68: return strtoupper($property->parameter('VALUE') ?? self::TYPES[$property->name] ?? 'TEXT');
69: }
70:
71: /**
72: * Value converted according to its type: DateTimeValue (list for EXDATE and RDATE), Period list,
73: * DateInterval, int, float, bool, Rule, CalAddress, GEO pair, list of TEXT (CATEGORIES) or string
74: * (TEXT, URI, BINARY, and UID and XML-REFERENCE of RFC 9253).
75: */
76: public function value(Property $property): mixed {
77: $list = isset(self::LISTS[$property->name]);
78: return match (self::type($property)) {
79: 'DATE-TIME', 'DATE' => $list ? $this->dateTimes($property) : $this->dateTime($property),
80: 'PERIOD' => $this->periods($property),
81: 'DURATION' => $this->duration($property),
82: 'INTEGER' => $this->integer($property),
83: 'FLOAT' => $property->name === 'GEO' ? $this->geo($property) : $this->float($property),
84: 'BOOLEAN' => $this->boolean($property),
85: 'RECUR' => $this->recur($property),
86: 'CAL-ADDRESS' => $this->calAddress($property),
87: 'UTC-OFFSET' => $this->utcOffset($property),
88: 'BINARY' => $this->binary($property),
89: 'URI', 'XML-REFERENCE' => $this->uri($property),
90: default => $list ? $this->texts($property) : $this->text($property),
91: };
92: }
93:
94: public function text(Property $property): string {
95: return Text::unescape($this->decoded($property));
96: }
97:
98: /**
99: * @return list<string>
100: */
101: public function texts(Property $property): array {
102: return Text::split($this->decoded($property));
103: }
104:
105: /**
106: * The value, decoded when it has ENCODING=QUOTED-PRINTABLE (vCalendar 1.0, not RFC 5545).
107: */
108: private function decoded(Property $property): string {
109: if (strtoupper($property->parameter('ENCODING') ?? '') !== 'QUOTED-PRINTABLE') {
110: return $property->value;
111: }
112: $this->nonstandard('The value uses ENCODING=QUOTED-PRINTABLE of vCalendar 1.0, it was decoded.', $property);
113: return quoted_printable_decode($property->value);
114: }
115:
116: public function dateTime(Property $property): ?DateTimeValue {
117: return $this->dateTimes($property)[0] ?? null;
118: }
119:
120: /**
121: * All DATE and DATE-TIME values; a PERIOD is represented by its start.
122: *
123: * @return list<DateTimeValue>
124: */
125: public function dateTimes(Property $property): array {
126: $result = [];
127: foreach (explode(',', $property->value) as $item) {
128: $this->periodSuffix($item, $property);
129: $value = $this->parseDate(explode('/', $item, 2)[0], $property);
130: if ($value !== null) {
131: $result[] = $value;
132: }
133: }
134: return $result;
135: }
136:
137: /**
138: * @return list<Period>
139: */
140: public function periods(Property $property): array {
141: $result = [];
142: foreach (explode(',', $property->value) as $item) {
143: [$start, $end] = array_pad(explode('/', $item, 2), 2, '');
144: $start = $this->parseDate($start, $property);
145: if ($start === null) {
146: continue;
147: }
148: $duration = Duration::parse($end);
149: $end = $duration !== null ? $start->add($duration) : $this->parseDate($end, $property);
150: if ($end !== null) {
151: $result[] = new Period($start, $end, $duration);
152: }
153: }
154: return $result;
155: }
156:
157: public function duration(Property $property): ?DateInterval {
158: return Duration::parse($property->value) ?? $this->invalid('DURATION', $property);
159: }
160:
161: public function integer(Property $property): ?int {
162: $value = trim($property->value);
163: return preg_match('/^[+-]?\d{1,18}$/D', $value) ? (int) $value : $this->invalid('INTEGER', $property);
164: }
165:
166: public function float(Property $property): ?float {
167: $value = trim($property->value);
168: return preg_match('/^[+-]?\d+(\.\d+)?$/D', $value) ? (float) $value : $this->invalid('FLOAT', $property);
169: }
170:
171: public function boolean(Property $property): ?bool {
172: return match (strtoupper(trim($property->value))) {
173: 'TRUE' => true,
174: 'FALSE' => false,
175: default => $this->invalid('BOOLEAN', $property),
176: };
177: }
178:
179: /**
180: * GEO value as [latitude, longitude].
181: *
182: * @return array{float, float}|null
183: */
184: public function geo(Property $property): ?array {
185: $parts = explode(';', $property->value);
186: return count($parts) === 2 && is_numeric($parts[0]) && is_numeric($parts[1])
187: ? [(float) $parts[0], (float) $parts[1]]
188: : $this->invalid('GEO', $property);
189: }
190:
191: public function uri(Property $property): string {
192: return trim($property->value);
193: }
194:
195: public function binary(Property $property): ?string {
196: $decoded = base64_decode($property->value, true);
197: return $decoded === false ? $this->invalid('BINARY', $property) : $decoded;
198: }
199:
200: public function calAddress(Property $property): CalAddress {
201: return new CalAddress(trim($property->value), $property->parameters);
202: }
203:
204: /**
205: * IMAGE (RFC 7986) with a URI or the decoded data of VALUE=BINARY; null for invalid data.
206: */
207: public function image(Property $property): ?Image {
208: if (self::type($property) !== 'BINARY') {
209: return new Image($this->uri($property), null, $property->parameters);
210: }
211: $data = $this->binary($property);
212: return $data === null ? null : new Image(null, $data, $property->parameters);
213: }
214:
215: public function utcOffset(Property $property): ?int {
216: try {
217: return UtcOffset::parse($property->value);
218: } catch (InvalidValueException $e) {
219: return $this->fail($e, $property);
220: }
221: }
222:
223: /**
224: * A rule of another calendar system than GREGORIAN (RFC 7529) is returned with a
225: * "recurrence.unsupported-rscale" problem (it is not expanded), strict mode throws.
226: */
227: public function recur(Property $property): ?Rule {
228: try {
229: $rule = Rule::fromString($property->value);
230: } catch (InvalidRecurrenceRuleException $e) {
231: if ($this->strict) {
232: throw self::recurrenceError($e, $property);
233: }
234: $rule = null;
235: if ($e->errorCode() === 'recurrence.skip-without-rscale') {
236: // the rule without SKIP is the rule of RFC 5545, which omits invalid dates
237: try {
238: $rule = Rule::fromString($property->value, true);
239: } catch (InvalidRecurrenceRuleException $e) {
240: }
241: }
242: if ($rule === null) {
243: $this->problem('value.invalid', $e->getMessage() . ' The RRULE was ignored.');
244: return null;
245: }
246: $this->problem('value.invalid', $e->getMessage() . ' SKIP was ignored.');
247: }
248: try {
249: $rule->assertGregorian();
250: } catch (InvalidRecurrenceRuleException $e) {
251: if ($this->strict) {
252: throw self::recurrenceError($e, $property);
253: }
254: $this->problem($e->errorCode(), $e->getMessage());
255: }
256: return $rule;
257: }
258:
259: /**
260: * Problems of a value as [code, message] pairs: "value.invalid" for an invalid value (null in
261: * permissive mode), "value.nonstandard" for a value accepted although it breaks the RFC,
262: * "recurrence.unsupported-rscale" for an RRULE of another calendar system than GREGORIAN.
263: *
264: * @return list<array{string, string}>
265: */
266: public function diagnose(Property $property): array {
267: $this->problems = [];
268: try {
269: $type = self::type($property);
270: if ($type === 'DATE-TIME' || $type === 'DATE') {
271: $this->checkDates($property); // the same checks as dateTimes(), without creating objects
272: } elseif ($type !== 'CAL-ADDRESS' && $type !== 'URI') {
273: $this->value($property);
274: }
275: return $this->problems ?? [];
276: } finally {
277: $this->problems = null;
278: }
279: }
280:
281: /**
282: * Timezone of a TZID parameter, see TimezoneResolver.
283: */
284: public function timezone(string $tzid): ?ResolvedTimezone {
285: if (!array_key_exists($tzid, $this->timezones)) {
286: $this->timezones[$tzid] = $this->resolver->resolve($tzid, $this->calendar);
287: }
288: return $this->timezones[$tzid];
289: }
290:
291: private function checkDates(Property $property): void {
292: $tzid = $property->parameter('TZID');
293: if ($tzid !== null) {
294: $this->timezone($tzid);
295: }
296: foreach (explode(',', $property->value) as $item) {
297: $this->periodSuffix($item, $property);
298: $value = $this->normalizeDate(explode('/', $item, 2)[0], $property, $date);
299: if (!DateTimeValue::isValid($value, $date)) {
300: $this->fail(InvalidValueException::create('value.invalid-date-time', 'Invalid DATE-TIME value: ' . $value, rawValue: $value), $property);
301: }
302: }
303: }
304:
305: /**
306: * Repairs of the permissive mode: a date with "Z", VALUE=DATE with a time. Reports leap seconds.
307: *
308: * @param-out bool $date whether the value is read as a DATE
309: */
310: private function normalizeDate(string $value, Property $property, ?bool &$date): string {
311: $repaired = false;
312: if (!$this->strict && preg_match('/^\s*\d{8}Z\s*$/Di', $value)) {
313: $value = substr(trim($value), 0, 8); // a date with "Z" (written by Google) is a date
314: $this->problem('value.nonstandard', "The date $value has a \"Z\" suffix, it was read as a date.");
315: $repaired = true;
316: }
317: $date = strtoupper($property->parameter('VALUE') ?? '') === 'DATE';
318: if ($date && !$this->strict && preg_match('/^\s*\d{8}T\d{6}Z?\s*$/Di', $value)) {
319: $date = false;
320: $this->problem('value.nonstandard', "The value $value has VALUE=DATE and a time, it was read as a DATE-TIME.");
321: }
322: $trimmed = strtoupper(trim($value));
323: if (!$date && !$repaired && preg_match('/^\d{8}$/D', $trimmed)) {
324: $this->nonstandard("The date $value has no VALUE=DATE parameter.", $property);
325: }
326: if (str_ends_with($trimmed, 'Z') && strlen($trimmed) === 16 && $property->parameter('TZID') !== null) {
327: $this->nonstandard("The UTC value $value has a TZID parameter, it was ignored.", $property);
328: }
329: if (preg_match('/T\d{4}60Z?$/D', $trimmed)) {
330: $this->problem('value.leap-second', "The leap second of $value was read as second 59.");
331: }
332: return $value;
333: }
334:
335: private function parseDate(string $value, Property $property): ?DateTimeValue {
336: $tzid = $property->parameter('TZID');
337: $value = $this->normalizeDate($value, $property, $date);
338: try {
339: return DateTimeValue::parse($value, $date, $tzid, $tzid === null ? null : $this->timezone($tzid)?->timezone);
340: } catch (InvalidValueException $e) {
341: return $this->fail($e, $property);
342: }
343: }
344:
345: private function invalid(string $type, Property $property): null {
346: return $this->fail(InvalidValueException::create('value.invalid-' . strtolower($type), "Invalid $type value", rawValue: $property->value), $property);
347: }
348:
349: private static function recurrenceError(InvalidRecurrenceRuleException $e, Property $property): InvalidRecurrenceRuleException {
350: return InvalidRecurrenceRuleException::create($e->errorCode(), $e->getMessage(), $property->line, $property->name, $property->value, $e);
351: }
352:
353: private function fail(InvalidValueException $e, Property $property): null {
354: if ($this->strict) {
355: throw InvalidValueException::create($e->errorCode(), $e->getMessage() . " in $property->name", $property->line, $property->name, $property->value, $e);
356: }
357: $this->problem('value.invalid', $e->getMessage() . " in $property->name, it was ignored.");
358: return null;
359: }
360:
361: /**
362: * A PERIOD where only RDATE;VALUE=PERIOD and FREEBUSY allow it; its start is used.
363: */
364: private function periodSuffix(string $item, Property $property): void {
365: if (str_contains($item, '/') && $property->name !== 'FREEBUSY' && !($property->name === 'RDATE' && strtoupper($property->parameter('VALUE') ?? '') === 'PERIOD')) {
366: $this->nonstandard("The period $item is not allowed in $property->name, its start was used.", $property);
367: }
368: }
369:
370: /**
371: * A value breaking the RFC that is accepted in permissive mode.
372: */
373: private function nonstandard(string $message, Property $property): void {
374: if ($this->strict) {
375: throw InvalidValueException::create('value.nonstandard', $message, $property->line, $property->name, $property->value);
376: }
377: $this->problem('value.nonstandard', $message);
378: }
379:
380: private function problem(string $code, string $message): void {
381: if ($this->problems !== null) {
382: $this->problems[] = [$code, $message];
383: }
384: }
385: }
386: