Page Menu
Home
c4science
Search
Configure Global Search
Log In
Files
F92880264
PhutilICSParser.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Subscribers
None
File Metadata
Details
File Info
Storage
Attached
Created
Sun, Nov 24, 11:21
Size
24 KB
Mime Type
text/x-php
Expires
Tue, Nov 26, 11:21 (1 d, 21 h)
Engine
blob
Format
Raw Data
Handle
22533448
Attached To
rPHU libphutil
PhutilICSParser.php
View Options
<?php
final
class
PhutilICSParser
extends
Phobject
{
private
$stack
;
private
$node
;
private
$document
;
private
$lines
;
private
$cursor
;
private
$warnings
;
const
PARSE_MISSING_END
=
'missing-end'
;
const
PARSE_INITIAL_UNFOLD
=
'initial-unfold'
;
const
PARSE_UNEXPECTED_CHILD
=
'unexpected-child'
;
const
PARSE_EXTRA_END
=
'extra-end'
;
const
PARSE_MISMATCHED_SECTIONS
=
'mismatched-sections'
;
const
PARSE_ROOT_PROPERTY
=
'root-property'
;
const
PARSE_BAD_BASE64
=
'bad-base64'
;
const
PARSE_BAD_BOOLEAN
=
'bad-boolean'
;
const
PARSE_UNEXPECTED_TEXT
=
'unexpected-text'
;
const
PARSE_MALFORMED_DOUBLE_QUOTE
=
'malformed-double-quote'
;
const
PARSE_MALFORMED_PARAMETER_NAME
=
'malformed-parameter'
;
const
PARSE_MALFORMED_PROPERTY
=
'malformed-property'
;
const
PARSE_MISSING_VALUE
=
'missing-value'
;
const
PARSE_UNESCAPED_BACKSLASH
=
'unescaped-backslash'
;
const
PARSE_MULTIPLE_PARAMETERS
=
'multiple-parameters'
;
const
PARSE_EMPTY_DATETIME
=
'empty-datetime'
;
const
PARSE_MANY_DATETIME
=
'many-datetime'
;
const
PARSE_BAD_DATETIME
=
'bad-datetime'
;
const
PARSE_EMPTY_DURATION
=
'empty-duration'
;
const
PARSE_MANY_DURATION
=
'many-duration'
;
const
PARSE_BAD_DURATION
=
'bad-duration'
;
const
WARN_TZID_UTC
=
'warn-tzid-utc'
;
const
WARN_TZID_GUESS
=
'warn-tzid-guess'
;
const
WARN_TZID_IGNORED
=
'warn-tzid-ignored'
;
public
function
parseICSData
(
$data
)
{
$this
->
stack
=
array
();
$this
->
node
=
null
;
$this
->
cursor
=
null
;
$this
->
warnings
=
array
();
$lines
=
$this
->
unfoldICSLines
(
$data
);
$this
->
lines
=
$lines
;
$root
=
$this
->
newICSNode
(
'<ROOT>'
);
$this
->
stack
[]
=
$root
;
$this
->
node
=
$root
;
foreach
(
$lines
as
$key
=>
$line
)
{
$this
->
cursor
=
$key
;
$matches
=
null
;
if
(
preg_match
(
'(^BEGIN:(.*)
\z
)'
,
$line
,
$matches
))
{
$this
->
beginParsingNode
(
$matches
[
1
]);
}
else
if
(
preg_match
(
'(^END:(.*)
\z
)'
,
$line
,
$matches
))
{
$this
->
endParsingNode
(
$matches
[
1
]);
}
else
{
if
(
count
(
$this
->
stack
)
<
2
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_ROOT_PROPERTY
,
pht
(
'Found unexpected property at ICS document root.'
));
}
$this
->
parseICSProperty
(
$line
);
}
}
if
(
count
(
$this
->
stack
)
>
1
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_MISSING_END
,
pht
(
'Expected all "BEGIN:" sections in ICS document to have '
.
'corresponding "END:" sections.'
));
}
$this
->
node
=
null
;
$this
->
lines
=
null
;
$this
->
cursor
=
null
;
return
$root
;
}
private
function
getNode
()
{
return
$this
->
node
;
}
private
function
unfoldICSLines
(
$data
)
{
$lines
=
phutil_split_lines
(
$data
,
$retain_endings
=
false
);
$this
->
lines
=
$lines
;
// ICS files are wrapped at 75 characters, with overlong lines continued
// on the following line with an initial space or tab. Unwrap all of the
// lines in the file.
// This unwrapping is specifically byte-oriented, not character oriented,
// and RFC5545 anticipates that simple implementations may even split UTF8
// characters in the middle.
$last
=
null
;
foreach
(
$lines
as
$idx
=>
$line
)
{
$this
->
cursor
=
$idx
;
if
(!
preg_match
(
'/^[
\t
]/'
,
$line
))
{
$last
=
$idx
;
continue
;
}
if
(
$last
===
null
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_INITIAL_UNFOLD
,
pht
(
'First line of ICS file begins with a space or tab, but this '
.
'marks a line which should be unfolded.'
));
}
$lines
[
$last
]
=
$lines
[
$last
].
substr
(
$line
,
1
);
unset
(
$lines
[
$idx
]);
}
return
$lines
;
}
private
function
beginParsingNode
(
$type
)
{
$node
=
$this
->
getNode
();
$new_node
=
$this
->
newICSNode
(
$type
);
if
(
$node
instanceof
PhutilCalendarContainerNode
)
{
$node
->
appendChild
(
$new_node
);
}
else
{
$this
->
raiseParseFailure
(
self
::
PARSE_UNEXPECTED_CHILD
,
pht
(
'Found unexpected node "%s" inside node "%s".'
,
$new_node
->
getAttribute
(
'ics.type'
),
$node
->
getAttribute
(
'ics.type'
)));
}
$this
->
stack
[]
=
$new_node
;
$this
->
node
=
$new_node
;
return
$this
;
}
private
function
newICSNode
(
$type
)
{
switch
(
$type
)
{
case
'<ROOT>'
:
$node
=
new
PhutilCalendarRootNode
();
break
;
case
'VCALENDAR'
:
$node
=
new
PhutilCalendarDocumentNode
();
break
;
case
'VEVENT'
:
$node
=
new
PhutilCalendarEventNode
();
break
;
default
:
$node
=
new
PhutilCalendarRawNode
();
break
;
}
$node
->
setAttribute
(
'ics.type'
,
$type
);
return
$node
;
}
private
function
endParsingNode
(
$type
)
{
$node
=
$this
->
getNode
();
if
(
$node
instanceof
PhutilCalendarRootNode
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_EXTRA_END
,
pht
(
'Found unexpected "END" without a "BEGIN".'
));
}
$old_type
=
$node
->
getAttribute
(
'ics.type'
);
if
(
$old_type
!=
$type
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_MISMATCHED_SECTIONS
,
pht
(
'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.'
,
$old_type
,
$type
));
}
array_pop
(
$this
->
stack
);
$this
->
node
=
last
(
$this
->
stack
);
return
$this
;
}
private
function
parseICSProperty
(
$line
)
{
$matches
=
null
;
// Properties begin with an alphanumeric name with no escaping, followed
// by either a ";" (to begin a list of parameters) or a ":" (to begin
// the actual field body).
$ok
=
preg_match
(
'(^([A-Za-z0-9-]+)([;:])(.*)
\z
)'
,
$line
,
$matches
);
if
(!
$ok
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_MALFORMED_PROPERTY
,
pht
(
'Found malformed property in ICS document.'
));
}
$name
=
$matches
[
1
];
$body
=
$matches
[
3
];
$has_parameters
=
(
$matches
[
2
]
==
';'
);
$parameters
=
array
();
if
(
$has_parameters
)
{
// Parameters are a sensible name, a literal "=", a pile of magic,
// and then maybe a comma and another parameter.
while
(
true
)
{
// We're going to get the first couple of parts first.
$ok
=
preg_match
(
'(^([^=]+)=)'
,
$body
,
$matches
);
if
(!
$ok
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_MALFORMED_PARAMETER_NAME
,
pht
(
'Found malformed property in ICS document: %s'
,
$body
));
}
$param_name
=
$matches
[
1
];
$body
=
substr
(
$body
,
strlen
(
$matches
[
0
]));
// Now we're going to match zero or more values.
$param_values
=
array
();
while
(
true
)
{
// The value can either be a double-quoted string or an unquoted
// string, with some characters forbidden.
if
(
strlen
(
$body
)
&&
$body
[
0
]
==
'"'
)
{
$is_quoted
=
true
;
$ok
=
preg_match
(
'(^"([^
\x
00-
\x
08
\x
10-
\x
19"]*)")'
,
$body
,
$matches
);
if
(!
$ok
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_MALFORMED_DOUBLE_QUOTE
,
pht
(
'Found malformed double-quoted string in ICS document '
.
'parameter value.'
));
}
}
else
{
$is_quoted
=
false
;
// It's impossible for this not to match since it can match
// nothing, and it's valid for it to match nothing.
preg_match
(
'(^([^
\x
00-
\x
08
\x
10-
\x
19";:,]*))'
,
$body
,
$matches
);
}
// NOTE: RFC5545 says "Property parameter values that are not in
// quoted-strings are case-insensitive." -- that is, the quoted and
// unquoted representations are not equivalent. Thus, preserve the
// original formatting in case we ever need to respect this.
$param_values
[]
=
array
(
'value'
=>
$this
->
unescapeParameterValue
(
$matches
[
1
]),
'quoted'
=>
$is_quoted
,
);
$body
=
substr
(
$body
,
strlen
(
$matches
[
0
]));
if
(!
strlen
(
$body
))
{
$this
->
raiseParseFailure
(
self
::
PARSE_MISSING_VALUE
,
pht
(
'Expected ":" after parameters in ICS document property.'
));
}
// If we have a comma now, we're going to read another value. Strip
// it off and keep going.
if
(
$body
[
0
]
==
','
)
{
$body
=
substr
(
$body
,
1
);
continue
;
}
// If we have a semicolon, we're going to read another parameter.
if
(
$body
[
0
]
==
';'
)
{
break
;
}
// If we have a colon, this is the last value and also the last
// property. Break, then handle the colon below.
if
(
$body
[
0
]
==
':'
)
{
break
;
}
$short_body
=
id
(
new
PhutilUTF8StringTruncator
())
->
setMaximumGlyphs
(
32
)
->
truncateString
(
$body
);
// We aren't expecting anything else.
$this
->
raiseParseFailure
(
self
::
PARSE_UNEXPECTED_TEXT
,
pht
(
'Found unexpected text ("%s") after reading parameter value.'
,
$short_body
));
}
$parameters
[]
=
array
(
'name'
=>
$param_name
,
'values'
=>
$param_values
,
);
if
(
$body
[
0
]
==
';'
)
{
$body
=
substr
(
$body
,
1
);
continue
;
}
if
(
$body
[
0
]
==
':'
)
{
$body
=
substr
(
$body
,
1
);
break
;
}
}
}
$value
=
$this
->
unescapeFieldValue
(
$name
,
$parameters
,
$body
);
$node
=
$this
->
getNode
();
$raw
=
$node
->
getAttribute
(
'ics.properties'
,
array
());
$raw
[]
=
array
(
'name'
=>
$name
,
'parameters'
=>
$parameters
,
'value'
=>
$value
,
);
$node
->
setAttribute
(
'ics.properties'
,
$raw
);
switch
(
$node
->
getAttribute
(
'ics.type'
))
{
case
'VEVENT'
:
$this
->
didParseEventProperty
(
$node
,
$name
,
$parameters
,
$value
);
break
;
}
}
private
function
unescapeParameterValue
(
$data
)
{
// The parameter grammar is adjusted by RFC6868 to permit escaping with
// carets. Remove that escaping.
// This escaping is a bit weird because it's trying to be backwards
// compatible and the original spec didn't think about this and didn't
// provide much room to fix things.
$out
=
''
;
$esc
=
false
;
foreach
(
phutil_utf8v
(
$data
)
as
$c
)
{
if
(!
$esc
)
{
if
(
$c
!=
'^'
)
{
$out
.=
$c
;
}
else
{
$esc
=
true
;
}
}
else
{
switch
(
$c
)
{
case
'n'
:
$out
.=
"
\n
"
;
break
;
case
'^'
:
$out
.=
'^'
;
break
;
case
"'"
:
// NOTE: This is "<caret> <single quote>" being decoded into a
// double quote!
$out
.=
'"'
;
break
;
default
:
// NOTE: The caret is NOT an escape for any other characters.
// This is a "MUST" requirement of RFC6868.
$out
.=
'^'
.
$c
;
break
;
}
}
}
// NOTE: Because caret on its own just means "caret" for backward
// compatibility, we don't warn if we're still in escaped mode once we
// reach the end of the string.
return
$out
;
}
private
function
unescapeFieldValue
(
$name
,
array
$parameters
,
$data
)
{
// NOTE: The encoding of the field value data is dependent on the field
// name (which defines a default encoding) and the parameters (which may
// include "VALUE", specifying a type of the data.
$default_types
=
array
(
'CALSCALE'
=>
'TEXT'
,
'METHOD'
=>
'TEXT'
,
'PRODID'
=>
'TEXT'
,
'VERSION'
=>
'TEXT'
,
'ATTACH'
=>
'URI'
,
'CATEGORIES'
=>
'TEXT'
,
'CLASS'
=>
'TEXT'
,
'COMMENT'
=>
'TEXT'
,
'DESCRIPTION'
=>
'TEXT'
,
// TODO: The spec appears to contradict itself: it says that the value
// type is FLOAT, but it also says that this property value is actually
// two semicolon-separated values, which is not what FLOAT is defined as.
'GEO'
=>
'TEXT'
,
'LOCATION'
=>
'TEXT'
,
'PERCENT-COMPLETE'
=>
'INTEGER'
,
'PRIORITY'
=>
'INTEGER'
,
'RESOURCES'
=>
'TEXT'
,
'STATUS'
=>
'TEXT'
,
'SUMMARY'
=>
'TEXT'
,
'COMPLETED'
=>
'DATE-TIME'
,
'DTEND'
=>
'DATE-TIME'
,
'DUE'
=>
'DATE-TIME'
,
'DTSTART'
=>
'DATE-TIME'
,
'DURATION'
=>
'DURATION'
,
'FREEBUSY'
=>
'PERIOD'
,
'TRANSP'
=>
'TEXT'
,
'TZID'
=>
'TEXT'
,
'TZNAME'
=>
'TEXT'
,
'TZOFFSETFROM'
=>
'UTC-OFFSET'
,
'TZOFFSETTO'
=>
'UTC-OFFSET'
,
'TZURL'
=>
'URI'
,
'ATTENDEE'
=>
'CAL-ADDRESS'
,
'CONTACT'
=>
'TEXT'
,
'ORGANIZER'
=>
'CAL-ADDRESS'
,
'RECURRENCE-ID'
=>
'DATE-TIME'
,
'RELATED-TO'
=>
'TEXT'
,
'URL'
=>
'URI'
,
'UID'
=>
'TEXT'
,
'EXDATE'
=>
'DATE-TIME'
,
'RDATE'
=>
'DATE-TIME'
,
'RRULE'
=>
'RECUR'
,
'ACTION'
=>
'TEXT'
,
'REPEAT'
=>
'INTEGER'
,
'TRIGGER'
=>
'DURATION'
,
'CREATED'
=>
'DATE-TIME'
,
'DTSTAMP'
=>
'DATE-TIME'
,
'LAST-MODIFIED'
=>
'DATE-TIME'
,
'SEQUENCE'
=>
'INTEGER'
,
'REQUEST-STATUS'
=>
'TEXT'
,
);
$value_type
=
idx
(
$default_types
,
$name
,
'TEXT'
);
foreach
(
$parameters
as
$parameter
)
{
if
(
$parameter
[
'name'
]
==
'VALUE'
)
{
$value_type
=
idx
(
head
(
$parameter
[
'values'
]),
'value'
);
}
}
switch
(
$value_type
)
{
case
'BINARY'
:
$result
=
base64_decode
(
$data
,
true
);
if
(
$result
===
false
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_BAD_BASE64
,
pht
(
'Unable to decode base64 data: %s'
,
$data
));
}
break
;
case
'BOOLEAN'
:
$map
=
array
(
'true'
=>
true
,
'false'
=>
false
,
);
$result
=
phutil_utf8_strtolower
(
$data
);
if
(!
isset
(
$map
[
$result
]))
{
$this
->
raiseParseFailure
(
self
::
PARSE_BAD_BOOLEAN
,
pht
(
'Unexpected BOOLEAN value "%s".'
,
$data
));
}
$result
=
$map
[
$result
];
break
;
case
'CAL-ADDRESS'
:
$result
=
$data
;
break
;
case
'DATE'
:
// This is a comma-separated list of "YYYYMMDD" values.
$result
=
explode
(
','
,
$data
);
break
;
case
'DATE-TIME'
:
if
(!
strlen
(
$data
))
{
$result
=
array
();
}
else
{
$result
=
explode
(
','
,
$data
);
}
break
;
case
'DURATION'
:
if
(!
strlen
(
$data
))
{
$result
=
array
();
}
else
{
$result
=
explode
(
','
,
$data
);
}
break
;
case
'FLOAT'
:
$result
=
explode
(
','
,
$data
);
foreach
(
$result
as
$k
=>
$v
)
{
$result
[
$k
]
=
(
float
)
$v
;
}
break
;
case
'INTEGER'
:
$result
=
explode
(
','
,
$data
);
foreach
(
$result
as
$k
=>
$v
)
{
$result
[
$k
]
=
(
int
)
$v
;
}
break
;
case
'PERIOD'
:
$result
=
explode
(
','
,
$data
);
break
;
case
'RECUR'
:
$result
=
$data
;
break
;
case
'TEXT'
:
$result
=
$this
->
unescapeTextValue
(
$data
);
break
;
case
'TIME'
:
$result
=
explode
(
','
,
$data
);
break
;
case
'URI'
:
$result
=
$data
;
break
;
case
'UTC-OFFSET'
:
$result
=
$data
;
break
;
default
:
// RFC5545 says we MUST preserve the data for any types we don't
// recognize.
$result
=
$data
;
break
;
}
return
array
(
'type'
=>
$value_type
,
'value'
=>
$result
,
'raw'
=>
$data
,
);
}
private
function
unescapeTextValue
(
$data
)
{
$result
=
array
();
$buf
=
''
;
$esc
=
false
;
foreach
(
phutil_utf8v
(
$data
)
as
$c
)
{
if
(!
$esc
)
{
if
(
$c
==
'
\\
'
)
{
$esc
=
true
;
}
else
if
(
$c
==
','
)
{
$result
[]
=
$buf
;
$buf
=
''
;
}
else
{
$buf
.=
$c
;
}
}
else
{
switch
(
$c
)
{
case
'n'
:
case
'N'
:
$buf
.=
"
\n
"
;
break
;
default
:
$buf
.=
$c
;
break
;
}
$esc
=
false
;
}
}
if
(
$esc
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_UNESCAPED_BACKSLASH
,
pht
(
'ICS document contains TEXT value ending with unescaped '
.
'backslash.'
));
}
$result
[]
=
$buf
;
return
$result
;
}
private
function
raiseParseFailure
(
$code
,
$message
)
{
if
(
$this
->
lines
&&
isset
(
$this
->
lines
[
$this
->
cursor
]))
{
$message
=
pht
(
"ICS Parse Error near line %s:
\n\n
>>> %s
\n\n
%s"
,
$this
->
cursor
+
1
,
$this
->
lines
[
$this
->
cursor
],
$message
);
}
else
{
$message
=
pht
(
'ICS Parse Error: %s'
,
$message
);
}
throw
id
(
new
PhutilICSParserException
(
$message
))
->
setParserFailureCode
(
$code
);
}
private
function
raiseWarning
(
$code
,
$message
)
{
$this
->
warnings
[]
=
array
(
'code'
=>
$code
,
'line'
=>
$this
->
cursor
,
'text'
=>
$this
->
lines
[
$this
->
cursor
],
'message'
=>
$message
,
);
return
$this
;
}
public
function
getWarnings
()
{
return
$this
->
warnings
;
}
private
function
didParseEventProperty
(
PhutilCalendarEventNode
$node
,
$name
,
array
$parameters
,
array
$value
)
{
switch
(
$name
)
{
case
'UID'
:
$text
=
$this
->
newTextFromProperty
(
$parameters
,
$value
);
$node
->
setUID
(
$text
);
break
;
case
'CREATED'
:
$datetime
=
$this
->
newDateTimeFromProperty
(
$parameters
,
$value
);
$node
->
setCreatedDateTime
(
$datetime
);
break
;
case
'DTSTAMP'
:
$datetime
=
$this
->
newDateTimeFromProperty
(
$parameters
,
$value
);
$node
->
setModifiedDateTime
(
$datetime
);
break
;
case
'SUMMARY'
:
$text
=
$this
->
newTextFromProperty
(
$parameters
,
$value
);
$node
->
setName
(
$text
);
break
;
case
'DESCRIPTION'
:
$text
=
$this
->
newTextFromProperty
(
$parameters
,
$value
);
$node
->
setDescription
(
$text
);
break
;
case
'DTSTART'
:
$datetime
=
$this
->
newDateTimeFromProperty
(
$parameters
,
$value
);
$node
->
setStartDateTime
(
$datetime
);
break
;
case
'DTEND'
:
$datetime
=
$this
->
newDateTimeFromProperty
(
$parameters
,
$value
);
$node
->
setEndDateTime
(
$datetime
);
break
;
case
'DURATION'
:
$duration
=
$this
->
newDurationFromProperty
(
$parameters
,
$value
);
$node
->
setDuration
(
$duration
);
break
;
case
'RRULE'
:
$rrule
=
$this
->
newRecurrenceRuleFromProperty
(
$parameters
,
$value
);
$node
->
setRecurrenceRule
(
$rrule
);
break
;
case
'RECURRENCE-ID'
:
$text
=
$this
->
newTextFromProperty
(
$parameters
,
$value
);
$node
->
setRecurrenceID
(
$text
);
break
;
case
'ATTENDEE'
:
$attendee
=
$this
->
newAttendeeFromProperty
(
$parameters
,
$value
);
$node
->
addAttendee
(
$attendee
);
break
;
}
}
private
function
newTextFromProperty
(
array
$parameters
,
array
$value
)
{
$value
=
$value
[
'value'
];
return
implode
(
"
\n\n
"
,
$value
);
}
private
function
newAttendeeFromProperty
(
array
$parameters
,
array
$value
)
{
$uri
=
$value
[
'value'
];
switch
(
idx
(
$parameters
,
'PARTSTAT'
))
{
case
'ACCEPTED'
:
$status
=
PhutilCalendarUserNode
::
STATUS_ACCEPTED
;
break
;
case
'DECLINED'
:
$status
=
PhutilCalendarUserNode
::
STATUS_DECLINED
;
break
;
case
'NEEDS-ACTION'
:
default
:
$status
=
PhutilCalendarUserNode
::
STATUS_INVITED
;
break
;
}
$name
=
$this
->
getScalarParameterValue
(
$parameters
,
'CN'
);
return
id
(
new
PhutilCalendarUserNode
())
->
setURI
(
$uri
)
->
setName
(
$name
)
->
setStatus
(
$status
);
}
private
function
newDateTimeFromProperty
(
array
$parameters
,
array
$value
)
{
$value
=
$value
[
'value'
];
if
(!
$value
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_EMPTY_DATETIME
,
pht
(
'Expected DATE-TIME to have exactly one value, found none.'
));
}
if
(
count
(
$value
)
>
1
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_MANY_DATETIME
,
pht
(
'Expected DATE-TIME to have exactly one value, found more than '
.
'one.'
));
}
$value
=
head
(
$value
);
$tzid
=
$this
->
getScalarParameterValue
(
$parameters
,
'TZID'
);
if
(
preg_match
(
'/Z
\z
/'
,
$value
))
{
if
(
$tzid
)
{
$this
->
raiseWarning
(
self
::
WARN_TZID_UTC
,
pht
(
'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '
.
'parameter with value "%s". This violates RFC5545. The TZID '
.
'will be ignored, and the value will be interpreted as UTC.'
,
$value
,
$tzid
));
}
$tzid
=
'UTC'
;
}
else
if
(
$tzid
!==
null
)
{
$tzid
=
$this
->
guessTimezone
(
$tzid
);
}
try
{
$datetime
=
PhutilCalendarAbsoluteDateTime
::
newFromISO8601
(
$value
,
$tzid
);
}
catch
(
Exception
$ex
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_BAD_DATETIME
,
pht
(
'Error parsing DATE-TIME: %s'
,
$ex
->
getMessage
()));
}
return
$datetime
;
}
private
function
newDurationFromProperty
(
array
$parameters
,
array
$value
)
{
$value
=
$value
[
'value'
];
if
(!
$value
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_EMPTY_DURATION
,
pht
(
'Expected DURATION to have exactly one value, found none.'
));
}
if
(
count
(
$value
)
>
1
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_MANY_DURATION
,
pht
(
'Expected DURATION to have exactly one value, found more than '
.
'one.'
));
}
$value
=
head
(
$value
);
try
{
$duration
=
PhutilCalendarDuration
::
newFromISO8601
(
$value
);
}
catch
(
Exception
$ex
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_BAD_DURATION
,
pht
(
'Invalid DURATION: %s'
,
$ex
->
getMessage
()));
}
return
$duration
;
}
private
function
newRecurrenceRuleFromProperty
(
array
$parameters
,
$value
)
{
return
PhutilCalendarRecurrenceRule
::
newFromRRULE
(
$value
[
'value'
]);
}
private
function
getScalarParameterValue
(
array
$parameters
,
$name
,
$default
=
null
)
{
$match
=
null
;
foreach
(
$parameters
as
$parameter
)
{
if
(
$parameter
[
'name'
]
==
$name
)
{
$match
=
$parameter
;
}
}
if
(
$match
===
null
)
{
return
$default
;
}
$value
=
$match
[
'values'
];
if
(!
$value
)
{
// Parameter is specified, but with no value, like "KEY=". Just return
// the default, as though the parameter was not specified.
return
$default
;
}
if
(
count
(
$value
)
>
1
)
{
$this
->
raiseParseFailure
(
self
::
PARSE_MULTIPLE_PARAMETERS
,
pht
(
'Expected parameter "%s" to have at most one value, but found '
.
'more than one.'
,
$name
));
}
return
idx
(
head
(
$value
),
'value'
);
}
private
function
guessTimezone
(
$tzid
)
{
$map
=
DateTimeZone
::
listIdentifiers
();
$map
=
array_fuse
(
$map
);
if
(
isset
(
$map
[
$tzid
]))
{
// This is a real timezone we recognize, so just use it as provided.
return
$tzid
;
}
// These are alternate names for timezones.
static
$aliases
;
if
(
$aliases
===
null
)
{
$aliases
=
array
(
'Etc/GMT'
=>
'UTC'
,
);
// Load the map of Windows timezones.
$root_path
=
dirname
(
phutil_get_library_root
(
'phutil'
));
$windows_path
=
$root_path
.
'/resources/timezones/windows_timezones.json'
;
$windows_data
=
Filesystem
::
readFile
(
$windows_path
);
$windows_zones
=
phutil_json_decode
(
$windows_data
);
$aliases
=
$aliases
+
$windows_zones
;
}
if
(
isset
(
$aliases
[
$tzid
]))
{
return
$aliases
[
$tzid
];
}
// Look for something that looks like "UTC+3" or "GMT -05.00". If we find
// anything, pick a timezone with that offset.
$offset_pattern
=
'/'
.
'(?:UTC|GMT)'
.
'
\s
*'
.
'(?P<sign>[+-])'
.
'
\s
*'
.
'(?P<h>
\d
+)'
.
'(?:'
.
'[:.](?P<m>
\d
+)'
.
')?'
.
'/i'
;
$matches
=
null
;
if
(
preg_match
(
$offset_pattern
,
$tzid
,
$matches
))
{
$hours
=
(
int
)
$matches
[
'h'
];
$minutes
=
(
int
)
idx
(
$matches
,
'm'
);
$offset
=
(
$hours
*
60
*
60
)
+
(
$minutes
*
60
);
if
(
idx
(
$matches
,
'sign'
)
==
'-'
)
{
$offset
=
-
$offset
;
}
// NOTE: We could possibly do better than this, by using the event start
// time to guess a timezone. However, that won't work for recurring
// events and would require us to do this work after finishing initial
// parsing. Since these unusual offset-based timezones appear to be rare,
// the benefit may not be worth the complexity.
$now
=
new
DateTime
(
'@'
.
time
());
foreach
(
$map
as
$identifier
)
{
$zone
=
new
DateTimeZone
(
$identifier
);
if
(
$zone
->
getOffset
(
$now
)
==
$offset
)
{
$this
->
raiseWarning
(
self
::
WARN_TZID_GUESS
,
pht
(
'TZID "%s" is unknown, guessing "%s" based on pattern "%s".'
,
$tzid
,
$identifier
,
$matches
[
0
]));
return
$identifier
;
}
}
}
$this
->
raiseWarning
(
self
::
WARN_TZID_IGNORED
,
pht
(
'TZID "%s" is unknown, using UTC instead.'
,
$tzid
));
return
'UTC'
;
}
}
Event Timeline
Log In to Comment