1: <?php
2: declare(strict_types=1);
3:
4: namespace om\ICal\Parser;
5:
6: use DateTimeZone;
7: use Generator;
8: use om\ICal\Calendar;
9: use om\ICal\Component;
10: use om\ICal\Exception\TimezoneResolutionException;
11: use om\ICal\Item;
12: use om\ICal\Property;
13: use om\ICal\Timezone\CompositeTimezoneResolver;
14: use om\ICal\Timezone\TimezoneResolver;
15: use om\ICal\Value\ValueParser;
16: use om\RRule\RecurrenceLimits;
17:
18: /**
19: * Configurable iCalendar parser, see ICal::parser().
20: *
21: * $result = ICal::parser()->mode(ParserMode::Strict)->limits(new ParseLimits(maxFileSize: 1_000_000))->parse($ics);
22: *
23: * The configuration is immutable: every setter returns a new parser.
24: */
25: final readonly class Parser {
26: private TimezoneResolver $timezoneResolver;
27:
28: public function __construct(
29: private ParserMode $mode = ParserMode::Permissive,
30: private ParseLimits $limits = new ParseLimits(),
31: private RecurrenceLimits $recurrenceLimits = new RecurrenceLimits(),
32: ?TimezoneResolver $timezoneResolver = null,
33: private ?DateTimeZone $floatingTimezone = null,
34: private bool $checkValues = true,
35: ) {
36: $this->timezoneResolver = $timezoneResolver ?? CompositeTimezoneResolver::default();
37: }
38:
39: public function mode(ParserMode $mode): self {
40: return new self($mode, $this->limits, $this->recurrenceLimits, $this->timezoneResolver, $this->floatingTimezone, $this->checkValues);
41: }
42:
43: public function limits(ParseLimits $limits): self {
44: return new self($this->mode, $limits, $this->recurrenceLimits, $this->timezoneResolver, $this->floatingTimezone, $this->checkValues);
45: }
46:
47: public function recurrenceLimits(RecurrenceLimits $limits): self {
48: return new self($this->mode, $this->limits, $limits, $this->timezoneResolver, $this->floatingTimezone, $this->checkValues);
49: }
50:
51: public function timezoneResolver(TimezoneResolver $resolver): self {
52: return new self($this->mode, $this->limits, $this->recurrenceLimits, $resolver, $this->floatingTimezone, $this->checkValues);
53: }
54:
55: /**
56: * Timezone of dates and floating times (instead of X-WR-TIMEZONE).
57: */
58: public function floatingTimezone(?DateTimeZone $timezone): self {
59: return new self($this->mode, $this->limits, $this->recurrenceLimits, $this->timezoneResolver, $timezone, $this->checkValues);
60: }
61:
62: /**
63: * Convert every value of a known type during parsing and report invalid and nonstandard
64: * values as warnings (default). Turn it off to parse large files faster; values are then
65: * checked only when they are read (and by the Validator).
66: */
67: public function checkValues(bool $check = true): self {
68: return new self($this->mode, $this->limits, $this->recurrenceLimits, $this->timezoneResolver, $this->floatingTimezone, $check);
69: }
70:
71: public function parse(string $content): ParseResult {
72: return $this->parseLines(fn(callable $warn): Generator => LineReader::fromString($content, $this->limits->maxLineLength, $this->limits->maxFileSize, $warn));
73: }
74:
75: /**
76: * @throws \RuntimeException when the file cannot be read
77: */
78: public function parseFile(string $file): ParseResult {
79: return $this->parseLines(fn(callable $warn): Generator => LineReader::fromFile($file, $this->limits->maxLineLength, $this->limits->maxFileSize, $warn));
80: }
81:
82: /**
83: * @param resource $stream
84: */
85: public function parseStream($stream): ParseResult {
86: return $this->parseLines(fn(callable $warn): Generator => LineReader::fromStream($stream, $this->limits->maxLineLength, $this->limits->maxFileSize, $warn));
87: }
88:
89: /**
90: * Read events, tasks, journal entries and free/busy components one by one with constant
91: * memory. VTIMEZONE components and calendar properties seen so far are used for their values;
92: * overrides (RECURRENCE-ID) are returned as separate items.
93: *
94: * @param string|resource $input a file name or a stream
95: * @param ?callable(ParseWarning): void $onWarning
96: * @return Generator<int, Item>
97: */
98: public function stream($input, ?callable $onWarning = null): Generator {
99: $builder = new TreeBuilder($this->mode, $this->limits);
100: $warn = static fn(string $code, string $message, ?int $line = null) => $builder->recover($code, $message, $line);
101: $lines = is_string($input)
102: ? LineReader::fromFile($input, $this->limits->maxLineLength, $this->limits->maxFileSize, $warn)
103: : LineReader::fromStream($input, $this->limits->maxLineLength, $this->limits->maxFileSize, $warn);
104:
105: $reported = 0;
106: $reportedTimezones = [];
107: $shell = null; // properties and VTIMEZONE components of the current calendar
108: $calendar = null;
109: foreach ($builder->build(Tokenizer::rows($lines, $builder->invalidLine(...))) as $kind => $component) {
110: foreach (array_slice($builder->warnings(), $reported) as $warning) {
111: $onWarning !== null && $onWarning($warning);
112: $reported++;
113: }
114: if ($kind === 'calendar') {
115: [$shell, $calendar] = [null, null];
116: continue;
117: }
118: if ($component->name === 'VTIMEZONE') {
119: $shell = ($shell ?? new Component('VCALENDAR', $builder->calendarProperties()))->withComponent($component);
120: $calendar = null;
121: continue;
122: }
123: $properties = $builder->calendarProperties();
124: if ($shell === null || $shell->properties !== $properties) {
125: $shell = new Component('VCALENDAR', $properties, $shell->components ?? []);
126: $calendar = null;
127: }
128: $calendar ??= $this->calendar($shell);
129: $item = $calendar->itemOf($component);
130: if ($item !== null) {
131: $this->checkProperties($calendar, self::properties($component), $builder, $reportedTimezones);
132: foreach (array_slice($builder->warnings(), $reported) as $warning) {
133: $onWarning !== null && $onWarning($warning);
134: $reported++;
135: }
136: yield $item;
137: }
138: }
139: foreach (array_slice($builder->warnings(), $reported) as $warning) {
140: $onWarning !== null && $onWarning($warning);
141: }
142: }
143:
144: /**
145: * @param callable(callable(string, string, int): void): iterable<int, string> $lines
146: */
147: private function parseLines(callable $lines): ParseResult {
148: $builder = new TreeBuilder($this->mode, $this->limits);
149: $warn = static fn(string $code, string $message, ?int $line = null) => $builder->recover($code, $message, $line);
150:
151: $calendars = [];
152: $children = [];
153: foreach ($builder->build(Tokenizer::rows($lines($warn), $builder->invalidLine(...))) as $kind => $component) {
154: if ($kind === 'component') {
155: $children[] = $component;
156: continue;
157: }
158: $calendar = $this->calendar($component->withComponents($children));
159: $children = [];
160: $this->check($calendar, $builder);
161: $calendars[] = $calendar;
162: }
163: return new ParseResult($calendars, $builder->warnings());
164: }
165:
166: private function calendar(Component $component): Calendar {
167: return new Calendar($component, $this->timezoneResolver, $this->floatingTimezone, $this->recurrenceLimits, $this->mode === ParserMode::Strict);
168: }
169:
170: /**
171: * Unresolved TZIDs are warnings (errors in strict mode). Values of known types are converted:
172: * invalid and nonstandard values are warnings, strict mode throws InvalidValueException.
173: */
174: private function check(Calendar $calendar, TreeBuilder $builder): void {
175: $reported = [];
176: $this->checkProperties($calendar, self::properties($calendar->component), $builder, $reported);
177: }
178:
179: /**
180: * @param iterable<Property> $properties
181: * @param array<string, true> $reported TZIDs reported already
182: */
183: private function checkProperties(Calendar $calendar, iterable $properties, TreeBuilder $builder, array &$reported): void {
184: $values = $calendar->values();
185: $strict = $this->mode === ParserMode::Strict;
186: foreach ($properties as $property) {
187: $tzid = $property->parameter('TZID');
188: if ($tzid !== null && !isset($reported[$tzid]) && $values->timezone($tzid) === null) {
189: $reported[$tzid] = true;
190: if ($strict) {
191: throw TimezoneResolutionException::create('timezone.unresolved', "Unknown timezone \"$tzid\"", $property->line, $property->name, $property->value);
192: }
193: $builder->warn('timezone.unresolved', "Unknown timezone \"$tzid\", its times are floating.", $property->line, $property->name);
194: }
195: if (($strict || $this->checkValues) && (isset(ValueParser::TYPES[$property->name]) || $property->parameter('VALUE') !== null || $property->parameter('ENCODING') !== null)) {
196: // throws InvalidValueException in strict mode
197: foreach ($values->diagnose($property) as [$code, $message]) {
198: $builder->warn($code, $message, $property->line, $property->name);
199: }
200: }
201: }
202: }
203:
204: /**
205: * @return Generator<int, Property>
206: */
207: private static function properties(Component $component): Generator {
208: yield from $component->properties;
209: foreach ($component->components as $child) {
210: yield from self::properties($child);
211: }
212: }
213: }
214: