Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/applications/calendar/parser/ics/PhutilICSParser.php
12262 views
1
<?php
2
3
final class PhutilICSParser extends Phobject {
4
5
private $stack;
6
private $node;
7
private $document;
8
private $lines;
9
private $cursor;
10
11
private $warnings;
12
13
const PARSE_MISSING_END = 'missing-end';
14
const PARSE_INITIAL_UNFOLD = 'initial-unfold';
15
const PARSE_UNEXPECTED_CHILD = 'unexpected-child';
16
const PARSE_EXTRA_END = 'extra-end';
17
const PARSE_MISMATCHED_SECTIONS = 'mismatched-sections';
18
const PARSE_ROOT_PROPERTY = 'root-property';
19
const PARSE_BAD_BASE64 = 'bad-base64';
20
const PARSE_BAD_BOOLEAN = 'bad-boolean';
21
const PARSE_UNEXPECTED_TEXT = 'unexpected-text';
22
const PARSE_MALFORMED_DOUBLE_QUOTE = 'malformed-double-quote';
23
const PARSE_MALFORMED_PARAMETER_NAME = 'malformed-parameter';
24
const PARSE_MALFORMED_PROPERTY = 'malformed-property';
25
const PARSE_MISSING_VALUE = 'missing-value';
26
const PARSE_UNESCAPED_BACKSLASH = 'unescaped-backslash';
27
const PARSE_MULTIPLE_PARAMETERS = 'multiple-parameters';
28
const PARSE_EMPTY_DATETIME = 'empty-datetime';
29
const PARSE_MANY_DATETIME = 'many-datetime';
30
const PARSE_BAD_DATETIME = 'bad-datetime';
31
const PARSE_EMPTY_DURATION = 'empty-duration';
32
const PARSE_MANY_DURATION = 'many-duration';
33
const PARSE_BAD_DURATION = 'bad-duration';
34
35
const WARN_TZID_UTC = 'warn-tzid-utc';
36
const WARN_TZID_GUESS = 'warn-tzid-guess';
37
const WARN_TZID_IGNORED = 'warn-tzid-ignored';
38
39
public function parseICSData($data) {
40
$this->stack = array();
41
$this->node = null;
42
$this->cursor = null;
43
$this->warnings = array();
44
45
$lines = $this->unfoldICSLines($data);
46
$this->lines = $lines;
47
48
$root = $this->newICSNode('<ROOT>');
49
$this->stack[] = $root;
50
$this->node = $root;
51
52
foreach ($lines as $key => $line) {
53
$this->cursor = $key;
54
$matches = null;
55
if (preg_match('(^BEGIN:(.*)\z)', $line, $matches)) {
56
$this->beginParsingNode($matches[1]);
57
} else if (preg_match('(^END:(.*)\z)', $line, $matches)) {
58
$this->endParsingNode($matches[1]);
59
} else {
60
if (count($this->stack) < 2) {
61
$this->raiseParseFailure(
62
self::PARSE_ROOT_PROPERTY,
63
pht(
64
'Found unexpected property at ICS document root.'));
65
}
66
$this->parseICSProperty($line);
67
}
68
}
69
70
if (count($this->stack) > 1) {
71
$this->raiseParseFailure(
72
self::PARSE_MISSING_END,
73
pht(
74
'Expected all "BEGIN:" sections in ICS document to have '.
75
'corresponding "END:" sections.'));
76
}
77
78
$this->node = null;
79
$this->lines = null;
80
$this->cursor = null;
81
82
return $root;
83
}
84
85
private function getNode() {
86
return $this->node;
87
}
88
89
private function unfoldICSLines($data) {
90
$lines = phutil_split_lines($data, $retain_endings = false);
91
$this->lines = $lines;
92
93
// ICS files are wrapped at 75 characters, with overlong lines continued
94
// on the following line with an initial space or tab. Unwrap all of the
95
// lines in the file.
96
97
// This unwrapping is specifically byte-oriented, not character oriented,
98
// and RFC5545 anticipates that simple implementations may even split UTF8
99
// characters in the middle.
100
101
$last = null;
102
foreach ($lines as $idx => $line) {
103
$this->cursor = $idx;
104
if (!preg_match('/^[ \t]/', $line)) {
105
$last = $idx;
106
continue;
107
}
108
109
if ($last === null) {
110
$this->raiseParseFailure(
111
self::PARSE_INITIAL_UNFOLD,
112
pht(
113
'First line of ICS file begins with a space or tab, but this '.
114
'marks a line which should be unfolded.'));
115
}
116
117
$lines[$last] = $lines[$last].substr($line, 1);
118
unset($lines[$idx]);
119
}
120
121
return $lines;
122
}
123
124
private function beginParsingNode($type) {
125
$node = $this->getNode();
126
$new_node = $this->newICSNode($type);
127
128
if ($node instanceof PhutilCalendarContainerNode) {
129
$node->appendChild($new_node);
130
} else {
131
$this->raiseParseFailure(
132
self::PARSE_UNEXPECTED_CHILD,
133
pht(
134
'Found unexpected node "%s" inside node "%s".',
135
$new_node->getAttribute('ics.type'),
136
$node->getAttribute('ics.type')));
137
}
138
139
$this->stack[] = $new_node;
140
$this->node = $new_node;
141
142
return $this;
143
}
144
145
private function newICSNode($type) {
146
switch ($type) {
147
case '<ROOT>':
148
$node = new PhutilCalendarRootNode();
149
break;
150
case 'VCALENDAR':
151
$node = new PhutilCalendarDocumentNode();
152
break;
153
case 'VEVENT':
154
$node = new PhutilCalendarEventNode();
155
break;
156
default:
157
$node = new PhutilCalendarRawNode();
158
break;
159
}
160
161
$node->setAttribute('ics.type', $type);
162
163
return $node;
164
}
165
166
private function endParsingNode($type) {
167
$node = $this->getNode();
168
if ($node instanceof PhutilCalendarRootNode) {
169
$this->raiseParseFailure(
170
self::PARSE_EXTRA_END,
171
pht(
172
'Found unexpected "END" without a "BEGIN".'));
173
}
174
175
$old_type = $node->getAttribute('ics.type');
176
if ($old_type != $type) {
177
$this->raiseParseFailure(
178
self::PARSE_MISMATCHED_SECTIONS,
179
pht(
180
'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.',
181
$old_type,
182
$type));
183
}
184
185
array_pop($this->stack);
186
$this->node = last($this->stack);
187
188
return $this;
189
}
190
191
private function parseICSProperty($line) {
192
$matches = null;
193
194
// Properties begin with an alphanumeric name with no escaping, followed
195
// by either a ";" (to begin a list of parameters) or a ":" (to begin
196
// the actual field body).
197
198
$ok = preg_match('(^([A-Za-z0-9-]+)([;:])(.*)\z)', $line, $matches);
199
if (!$ok) {
200
$this->raiseParseFailure(
201
self::PARSE_MALFORMED_PROPERTY,
202
pht(
203
'Found malformed property in ICS document.'));
204
}
205
206
$name = $matches[1];
207
$body = $matches[3];
208
$has_parameters = ($matches[2] == ';');
209
210
$parameters = array();
211
if ($has_parameters) {
212
// Parameters are a sensible name, a literal "=", a pile of magic,
213
// and then maybe a comma and another parameter.
214
215
while (true) {
216
// We're going to get the first couple of parts first.
217
$ok = preg_match('(^([^=]+)=)', $body, $matches);
218
if (!$ok) {
219
$this->raiseParseFailure(
220
self::PARSE_MALFORMED_PARAMETER_NAME,
221
pht(
222
'Found malformed property in ICS document: %s',
223
$body));
224
}
225
226
$param_name = $matches[1];
227
$body = substr($body, strlen($matches[0]));
228
229
// Now we're going to match zero or more values.
230
$param_values = array();
231
while (true) {
232
// The value can either be a double-quoted string or an unquoted
233
// string, with some characters forbidden.
234
if (strlen($body) && $body[0] == '"') {
235
$is_quoted = true;
236
$ok = preg_match(
237
'(^"([^\x00-\x08\x10-\x19"]*)")',
238
$body,
239
$matches);
240
if (!$ok) {
241
$this->raiseParseFailure(
242
self::PARSE_MALFORMED_DOUBLE_QUOTE,
243
pht(
244
'Found malformed double-quoted string in ICS document '.
245
'parameter value.'));
246
}
247
} else {
248
$is_quoted = false;
249
250
// It's impossible for this not to match since it can match
251
// nothing, and it's valid for it to match nothing.
252
preg_match('(^([^\x00-\x08\x10-\x19";:,]*))', $body, $matches);
253
}
254
255
// NOTE: RFC5545 says "Property parameter values that are not in
256
// quoted-strings are case-insensitive." -- that is, the quoted and
257
// unquoted representations are not equivalent. Thus, preserve the
258
// original formatting in case we ever need to respect this.
259
260
$param_values[] = array(
261
'value' => $this->unescapeParameterValue($matches[1]),
262
'quoted' => $is_quoted,
263
);
264
265
$body = substr($body, strlen($matches[0]));
266
if (!strlen($body)) {
267
$this->raiseParseFailure(
268
self::PARSE_MISSING_VALUE,
269
pht(
270
'Expected ":" after parameters in ICS document property.'));
271
}
272
273
// If we have a comma now, we're going to read another value. Strip
274
// it off and keep going.
275
if ($body[0] == ',') {
276
$body = substr($body, 1);
277
continue;
278
}
279
280
// If we have a semicolon, we're going to read another parameter.
281
if ($body[0] == ';') {
282
break;
283
}
284
285
// If we have a colon, this is the last value and also the last
286
// property. Break, then handle the colon below.
287
if ($body[0] == ':') {
288
break;
289
}
290
291
$short_body = id(new PhutilUTF8StringTruncator())
292
->setMaximumGlyphs(32)
293
->truncateString($body);
294
295
// We aren't expecting anything else.
296
$this->raiseParseFailure(
297
self::PARSE_UNEXPECTED_TEXT,
298
pht(
299
'Found unexpected text ("%s") after reading parameter value.',
300
$short_body));
301
}
302
303
$parameters[] = array(
304
'name' => $param_name,
305
'values' => $param_values,
306
);
307
308
if ($body[0] == ';') {
309
$body = substr($body, 1);
310
continue;
311
}
312
313
if ($body[0] == ':') {
314
$body = substr($body, 1);
315
break;
316
}
317
}
318
}
319
320
$value = $this->unescapeFieldValue($name, $parameters, $body);
321
322
$node = $this->getNode();
323
324
325
$raw = $node->getAttribute('ics.properties', array());
326
$raw[] = array(
327
'name' => $name,
328
'parameters' => $parameters,
329
'value' => $value,
330
);
331
$node->setAttribute('ics.properties', $raw);
332
333
switch ($node->getAttribute('ics.type')) {
334
case 'VEVENT':
335
$this->didParseEventProperty($node, $name, $parameters, $value);
336
break;
337
}
338
}
339
340
private function unescapeParameterValue($data) {
341
// The parameter grammar is adjusted by RFC6868 to permit escaping with
342
// carets. Remove that escaping.
343
344
// This escaping is a bit weird because it's trying to be backwards
345
// compatible and the original spec didn't think about this and didn't
346
// provide much room to fix things.
347
348
$out = '';
349
$esc = false;
350
foreach (phutil_utf8v($data) as $c) {
351
if (!$esc) {
352
if ($c != '^') {
353
$out .= $c;
354
} else {
355
$esc = true;
356
}
357
} else {
358
switch ($c) {
359
case 'n':
360
$out .= "\n";
361
break;
362
case '^':
363
$out .= '^';
364
break;
365
case "'":
366
// NOTE: This is "<caret> <single quote>" being decoded into a
367
// double quote!
368
$out .= '"';
369
break;
370
default:
371
// NOTE: The caret is NOT an escape for any other characters.
372
// This is a "MUST" requirement of RFC6868.
373
$out .= '^'.$c;
374
break;
375
}
376
}
377
}
378
379
// NOTE: Because caret on its own just means "caret" for backward
380
// compatibility, we don't warn if we're still in escaped mode once we
381
// reach the end of the string.
382
383
return $out;
384
}
385
386
private function unescapeFieldValue($name, array $parameters, $data) {
387
// NOTE: The encoding of the field value data is dependent on the field
388
// name (which defines a default encoding) and the parameters (which may
389
// include "VALUE", specifying a type of the data.
390
391
$default_types = array(
392
'CALSCALE' => 'TEXT',
393
'METHOD' => 'TEXT',
394
'PRODID' => 'TEXT',
395
'VERSION' => 'TEXT',
396
397
'ATTACH' => 'URI',
398
'CATEGORIES' => 'TEXT',
399
'CLASS' => 'TEXT',
400
'COMMENT' => 'TEXT',
401
'DESCRIPTION' => 'TEXT',
402
403
// TODO: The spec appears to contradict itself: it says that the value
404
// type is FLOAT, but it also says that this property value is actually
405
// two semicolon-separated values, which is not what FLOAT is defined as.
406
'GEO' => 'TEXT',
407
408
'LOCATION' => 'TEXT',
409
'PERCENT-COMPLETE' => 'INTEGER',
410
'PRIORITY' => 'INTEGER',
411
'RESOURCES' => 'TEXT',
412
'STATUS' => 'TEXT',
413
'SUMMARY' => 'TEXT',
414
415
'COMPLETED' => 'DATE-TIME',
416
'DTEND' => 'DATE-TIME',
417
'DUE' => 'DATE-TIME',
418
'DTSTART' => 'DATE-TIME',
419
'DURATION' => 'DURATION',
420
'FREEBUSY' => 'PERIOD',
421
'TRANSP' => 'TEXT',
422
423
'TZID' => 'TEXT',
424
'TZNAME' => 'TEXT',
425
'TZOFFSETFROM' => 'UTC-OFFSET',
426
'TZOFFSETTO' => 'UTC-OFFSET',
427
'TZURL' => 'URI',
428
429
'ATTENDEE' => 'CAL-ADDRESS',
430
'CONTACT' => 'TEXT',
431
'ORGANIZER' => 'CAL-ADDRESS',
432
'RECURRENCE-ID' => 'DATE-TIME',
433
'RELATED-TO' => 'TEXT',
434
'URL' => 'URI',
435
'UID' => 'TEXT',
436
'EXDATE' => 'DATE-TIME',
437
'RDATE' => 'DATE-TIME',
438
'RRULE' => 'RECUR',
439
440
'ACTION' => 'TEXT',
441
'REPEAT' => 'INTEGER',
442
'TRIGGER' => 'DURATION',
443
444
'CREATED' => 'DATE-TIME',
445
'DTSTAMP' => 'DATE-TIME',
446
'LAST-MODIFIED' => 'DATE-TIME',
447
'SEQUENCE' => 'INTEGER',
448
449
'REQUEST-STATUS' => 'TEXT',
450
);
451
452
$value_type = idx($default_types, $name, 'TEXT');
453
454
foreach ($parameters as $parameter) {
455
if ($parameter['name'] == 'VALUE') {
456
$value_type = idx(head($parameter['values']), 'value');
457
}
458
}
459
460
switch ($value_type) {
461
case 'BINARY':
462
$result = base64_decode($data, true);
463
if ($result === false) {
464
$this->raiseParseFailure(
465
self::PARSE_BAD_BASE64,
466
pht(
467
'Unable to decode base64 data: %s',
468
$data));
469
}
470
break;
471
case 'BOOLEAN':
472
$map = array(
473
'true' => true,
474
'false' => false,
475
);
476
$result = phutil_utf8_strtolower($data);
477
if (!isset($map[$result])) {
478
$this->raiseParseFailure(
479
self::PARSE_BAD_BOOLEAN,
480
pht(
481
'Unexpected BOOLEAN value "%s".',
482
$data));
483
}
484
$result = $map[$result];
485
break;
486
case 'CAL-ADDRESS':
487
$result = $data;
488
break;
489
case 'DATE':
490
// This is a comma-separated list of "YYYYMMDD" values.
491
$result = explode(',', $data);
492
break;
493
case 'DATE-TIME':
494
if (!strlen($data)) {
495
$result = array();
496
} else {
497
$result = explode(',', $data);
498
}
499
break;
500
case 'DURATION':
501
if (!strlen($data)) {
502
$result = array();
503
} else {
504
$result = explode(',', $data);
505
}
506
break;
507
case 'FLOAT':
508
$result = explode(',', $data);
509
foreach ($result as $k => $v) {
510
$result[$k] = (float)$v;
511
}
512
break;
513
case 'INTEGER':
514
$result = explode(',', $data);
515
foreach ($result as $k => $v) {
516
$result[$k] = (int)$v;
517
}
518
break;
519
case 'PERIOD':
520
$result = explode(',', $data);
521
break;
522
case 'RECUR':
523
$result = $data;
524
break;
525
case 'TEXT':
526
$result = $this->unescapeTextValue($data);
527
break;
528
case 'TIME':
529
$result = explode(',', $data);
530
break;
531
case 'URI':
532
$result = $data;
533
break;
534
case 'UTC-OFFSET':
535
$result = $data;
536
break;
537
default:
538
// RFC5545 says we MUST preserve the data for any types we don't
539
// recognize.
540
$result = $data;
541
break;
542
}
543
544
return array(
545
'type' => $value_type,
546
'value' => $result,
547
'raw' => $data,
548
);
549
}
550
551
private function unescapeTextValue($data) {
552
$result = array();
553
554
$buf = '';
555
$esc = false;
556
foreach (phutil_utf8v($data) as $c) {
557
if (!$esc) {
558
if ($c == '\\') {
559
$esc = true;
560
} else if ($c == ',') {
561
$result[] = $buf;
562
$buf = '';
563
} else {
564
$buf .= $c;
565
}
566
} else {
567
switch ($c) {
568
case 'n':
569
case 'N':
570
$buf .= "\n";
571
break;
572
default:
573
$buf .= $c;
574
break;
575
}
576
$esc = false;
577
}
578
}
579
580
if ($esc) {
581
$this->raiseParseFailure(
582
self::PARSE_UNESCAPED_BACKSLASH,
583
pht(
584
'ICS document contains TEXT value ending with unescaped '.
585
'backslash.'));
586
}
587
588
$result[] = $buf;
589
590
return $result;
591
}
592
593
private function raiseParseFailure($code, $message) {
594
if ($this->lines && isset($this->lines[$this->cursor])) {
595
$message = pht(
596
"ICS Parse Error near line %s:\n\n>>> %s\n\n%s",
597
$this->cursor + 1,
598
$this->lines[$this->cursor],
599
$message);
600
} else {
601
$message = pht(
602
'ICS Parse Error: %s',
603
$message);
604
}
605
606
throw id(new PhutilICSParserException($message))
607
->setParserFailureCode($code);
608
}
609
610
private function raiseWarning($code, $message) {
611
$this->warnings[] = array(
612
'code' => $code,
613
'line' => $this->cursor,
614
'text' => $this->lines[$this->cursor],
615
'message' => $message,
616
);
617
618
return $this;
619
}
620
621
public function getWarnings() {
622
return $this->warnings;
623
}
624
625
private function didParseEventProperty(
626
PhutilCalendarEventNode $node,
627
$name,
628
array $parameters,
629
array $value) {
630
631
switch ($name) {
632
case 'UID':
633
$text = $this->newTextFromProperty($parameters, $value);
634
$node->setUID($text);
635
break;
636
case 'CREATED':
637
$datetime = $this->newDateTimeFromProperty($parameters, $value);
638
$node->setCreatedDateTime($datetime);
639
break;
640
case 'DTSTAMP':
641
$datetime = $this->newDateTimeFromProperty($parameters, $value);
642
$node->setModifiedDateTime($datetime);
643
break;
644
case 'SUMMARY':
645
$text = $this->newTextFromProperty($parameters, $value);
646
$node->setName($text);
647
break;
648
case 'DESCRIPTION':
649
$text = $this->newTextFromProperty($parameters, $value);
650
$node->setDescription($text);
651
break;
652
case 'DTSTART':
653
$datetime = $this->newDateTimeFromProperty($parameters, $value);
654
$node->setStartDateTime($datetime);
655
break;
656
case 'DTEND':
657
$datetime = $this->newDateTimeFromProperty($parameters, $value);
658
$node->setEndDateTime($datetime);
659
break;
660
case 'DURATION':
661
$duration = $this->newDurationFromProperty($parameters, $value);
662
$node->setDuration($duration);
663
break;
664
case 'RRULE':
665
$rrule = $this->newRecurrenceRuleFromProperty($parameters, $value);
666
$node->setRecurrenceRule($rrule);
667
break;
668
case 'RECURRENCE-ID':
669
$text = $this->newTextFromProperty($parameters, $value);
670
$node->setRecurrenceID($text);
671
break;
672
case 'ATTENDEE':
673
$attendee = $this->newAttendeeFromProperty($parameters, $value);
674
$node->addAttendee($attendee);
675
break;
676
}
677
678
}
679
680
private function newTextFromProperty(array $parameters, array $value) {
681
$value = $value['value'];
682
return implode("\n\n", $value);
683
}
684
685
private function newAttendeeFromProperty(array $parameters, array $value) {
686
$uri = $value['value'];
687
688
switch (idx($parameters, 'PARTSTAT')) {
689
case 'ACCEPTED':
690
$status = PhutilCalendarUserNode::STATUS_ACCEPTED;
691
break;
692
case 'DECLINED':
693
$status = PhutilCalendarUserNode::STATUS_DECLINED;
694
break;
695
case 'NEEDS-ACTION':
696
default:
697
$status = PhutilCalendarUserNode::STATUS_INVITED;
698
break;
699
}
700
701
$name = $this->getScalarParameterValue($parameters, 'CN');
702
703
return id(new PhutilCalendarUserNode())
704
->setURI($uri)
705
->setName($name)
706
->setStatus($status);
707
}
708
709
private function newDateTimeFromProperty(array $parameters, array $value) {
710
$value = $value['value'];
711
712
if (!$value) {
713
$this->raiseParseFailure(
714
self::PARSE_EMPTY_DATETIME,
715
pht(
716
'Expected DATE-TIME to have exactly one value, found none.'));
717
718
}
719
720
if (count($value) > 1) {
721
$this->raiseParseFailure(
722
self::PARSE_MANY_DATETIME,
723
pht(
724
'Expected DATE-TIME to have exactly one value, found more than '.
725
'one.'));
726
}
727
728
$value = head($value);
729
$tzid = $this->getScalarParameterValue($parameters, 'TZID');
730
731
if (preg_match('/Z\z/', $value)) {
732
if ($tzid) {
733
$this->raiseWarning(
734
self::WARN_TZID_UTC,
735
pht(
736
'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '.
737
'parameter with value "%s". This violates RFC5545. The TZID '.
738
'will be ignored, and the value will be interpreted as UTC.',
739
$value,
740
$tzid));
741
}
742
$tzid = 'UTC';
743
} else if ($tzid !== null) {
744
$tzid = $this->guessTimezone($tzid);
745
}
746
747
try {
748
$datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601(
749
$value,
750
$tzid);
751
} catch (Exception $ex) {
752
$this->raiseParseFailure(
753
self::PARSE_BAD_DATETIME,
754
pht(
755
'Error parsing DATE-TIME: %s',
756
$ex->getMessage()));
757
}
758
759
return $datetime;
760
}
761
762
private function newDurationFromProperty(array $parameters, array $value) {
763
$value = $value['value'];
764
765
if (!$value) {
766
$this->raiseParseFailure(
767
self::PARSE_EMPTY_DURATION,
768
pht(
769
'Expected DURATION to have exactly one value, found none.'));
770
771
}
772
773
if (count($value) > 1) {
774
$this->raiseParseFailure(
775
self::PARSE_MANY_DURATION,
776
pht(
777
'Expected DURATION to have exactly one value, found more than '.
778
'one.'));
779
}
780
781
$value = head($value);
782
783
try {
784
$duration = PhutilCalendarDuration::newFromISO8601($value);
785
} catch (Exception $ex) {
786
$this->raiseParseFailure(
787
self::PARSE_BAD_DURATION,
788
pht(
789
'Invalid DURATION: %s',
790
$ex->getMessage()));
791
}
792
793
return $duration;
794
}
795
796
private function newRecurrenceRuleFromProperty(array $parameters, $value) {
797
return PhutilCalendarRecurrenceRule::newFromRRULE($value['value']);
798
}
799
800
private function getScalarParameterValue(
801
array $parameters,
802
$name,
803
$default = null) {
804
805
$match = null;
806
foreach ($parameters as $parameter) {
807
if ($parameter['name'] == $name) {
808
$match = $parameter;
809
}
810
}
811
812
if ($match === null) {
813
return $default;
814
}
815
816
$value = $match['values'];
817
if (!$value) {
818
// Parameter is specified, but with no value, like "KEY=". Just return
819
// the default, as though the parameter was not specified.
820
return $default;
821
}
822
823
if (count($value) > 1) {
824
$this->raiseParseFailure(
825
self::PARSE_MULTIPLE_PARAMETERS,
826
pht(
827
'Expected parameter "%s" to have at most one value, but found '.
828
'more than one.',
829
$name));
830
}
831
832
return idx(head($value), 'value');
833
}
834
835
private function guessTimezone($tzid) {
836
$map = DateTimeZone::listIdentifiers();
837
$map = array_fuse($map);
838
if (isset($map[$tzid])) {
839
// This is a real timezone we recognize, so just use it as provided.
840
return $tzid;
841
}
842
843
// These are alternate names for timezones.
844
static $aliases;
845
846
if ($aliases === null) {
847
$aliases = array(
848
'Etc/GMT' => 'UTC',
849
);
850
851
// Load the map of Windows timezones.
852
$root_path = dirname(phutil_get_library_root('phabricator'));
853
$windows_path = $root_path.'/resources/timezones/windows-timezones.json';
854
$windows_data = Filesystem::readFile($windows_path);
855
$windows_zones = phutil_json_decode($windows_data);
856
857
$aliases = $aliases + $windows_zones;
858
}
859
860
if (isset($aliases[$tzid])) {
861
return $aliases[$tzid];
862
}
863
864
// Look for something that looks like "UTC+3" or "GMT -05.00". If we find
865
// anything, pick a timezone with that offset.
866
$offset_pattern =
867
'/'.
868
'(?:UTC|GMT)'.
869
'\s*'.
870
'(?P<sign>[+-])'.
871
'\s*'.
872
'(?P<h>\d+)'.
873
'(?:'.
874
'[:.](?P<m>\d+)'.
875
')?'.
876
'/i';
877
878
$matches = null;
879
if (preg_match($offset_pattern, $tzid, $matches)) {
880
$hours = (int)$matches['h'];
881
$minutes = (int)idx($matches, 'm');
882
$offset = ($hours * 60 * 60) + ($minutes * 60);
883
884
if (idx($matches, 'sign') == '-') {
885
$offset = -$offset;
886
}
887
888
// NOTE: We could possibly do better than this, by using the event start
889
// time to guess a timezone. However, that won't work for recurring
890
// events and would require us to do this work after finishing initial
891
// parsing. Since these unusual offset-based timezones appear to be rare,
892
// the benefit may not be worth the complexity.
893
$now = new DateTime('@'.time());
894
895
foreach ($map as $identifier) {
896
$zone = new DateTimeZone($identifier);
897
if ($zone->getOffset($now) == $offset) {
898
$this->raiseWarning(
899
self::WARN_TZID_GUESS,
900
pht(
901
'TZID "%s" is unknown, guessing "%s" based on pattern "%s".',
902
$tzid,
903
$identifier,
904
$matches[0]));
905
return $identifier;
906
}
907
}
908
}
909
910
$this->raiseWarning(
911
self::WARN_TZID_IGNORED,
912
pht(
913
'TZID "%s" is unknown, using UTC instead.',
914
$tzid));
915
916
return 'UTC';
917
}
918
919
}
920
921