name = mb_strtoupper($name); $this->values = (array) $values; $this->setImplicitValueType(); } /** * Create a property instance given a string. * * @param string $string An unfolded property line */ public static function createFromString(string $string) : self { $parsed_string = self::parseLine($string); $property = new self($parsed_string['name'], $parsed_string['values']); foreach ($parsed_string['params'] as $name => $values) { $property->addParameter($name, $values); } return $property; } /** * Create a property instance of type DATE-TIME from UNIX timestamps. * * @param int|int[] $timestamps */ public static function createFromTimestamps(string $name, $timestamps, ?string $tzid=null) : self { $result = new self($name, self::convertTimestamps($timestamps, $tzid)); if (isset($tzid)) { $result->addParameter('TZID', $tzid); } return $result; } /** * Convert a DATE-TIME value to a UNIX timestamp. */ public static function convertDatetimeValue(string $value, ?string $tzid=null) : int { if (!isset($tzid)) { // FORM #1: DATE WITH LOCAL TIME if (!str_ends_with($value, 'Z')) { throw new \Exception("Floating times not supported"); } // FORM #2: DATE WITH UTC TIME $value = rtrim($value, 'Z'); $tzid = 'UTC'; } else { if (str_ends_with($value, 'Z')) { throw new \Exception("Both a TZID parameter and a Z suffix are not supported (see RFC 5545 section 3.3.5"); } // FORM #3: DATE WITH LOCAL TIME AND TIME ZONE REFERENCE } $datetime = DateTime::createFromFormat('Ymd\THis', $value, new DateTimeZone($tzid)); return $datetime->getTimestamp(); } /** * Convert an array of UNIX timestamps to DATE-TIME values. * * @param int|int[] $timestamps * @return string[] */ public static function convertTimestamps($timestamps, ?string $tzid=null) : array { $values = []; $timestamps = (array) $timestamps; $format = self::DATETIME_FORMAT; if (!isset($tzid)) { $tzid = 'UTC'; $format .= '\Z'; } foreach ($timestamps as $timestamp) { $date = new DateTime('now', new DateTimeZone($tzid)); $date->setTimestamp($timestamp); $values[] = $date->format($format); } return $values; } /** * Adds a parameter/parameters to the property. * * @param string|string[] $values */ public function addParameter(string $name, $values) : void { // Parameter names are case-insensitive, but by convention we use uppercase. // Parameters can have multiple values [param = param-name "=" param-value *("," param-value)]. // See, for example, DELEGATED-FROM and DELEGATED-TO in RFC 5545. $uc_name = mb_strtoupper($name); $this->params[$uc_name] = array_merge($this->params[$uc_name] ?? [], (array) $values); // If the value type has been set explicitly using a VALUE parameter then update the value type. if ($uc_name == 'VALUE') { // The VALUE parameter can only have one value. $this->value_type = mb_strtoupper($values[0]); } } /* * Get the property name. */ public function getName() : string { return $this->name; } /** * Get the property values. */ public function getValues() : array { return $this->values; } public function getParamValues(string $name) : array { return $this->params[$name] ?? []; } /** * Convert the property to an unfolded string. */ public function toString() : string { $result = $this->name; foreach ($this->params as $name => $values) { $result .= ';' . $name . '=' . implode(',', array_map([self::class, 'escapeParamValue'], $values)); } if ($this->value_type == self::VALUE_TYPE_TEXT) { $value_string = implode(',', array_map([self::class, 'escapeText'], $this->values)); } else { $value_string = implode(',', $this->values); } return "$result:$value_string" . Calendar::EOL; } /** * Converts a property of value type DATE-TIME to UNIX timestamps. * * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.5 * * @return int[] */ public function toTimestamps() : array { if ($this->value_type !== self::VALUE_TYPE_DATE_TIME) { throw new \Exception("Property '$this->name' is not of type DATE-TIME"); } $result = []; $tzid = (isset($this->params['TZID'])) ? $this->params['TZID'][0] : null; foreach ($this->values as $value) { $result[] = self::convertDatetimeValue($value, $tzid); } return $result; } /** * Parse a property content line * * @param string $line An unfolded property line * @return array{'name': string, 'params': array, 'values': string[]} */ private static function parseLine(string $line) : array { $result = []; $params = []; // Get the property name, which will be the part before the first colon or semicolon. $split = preg_split('/([:;])/', $line, 2, PREG_SPLIT_DELIM_CAPTURE); $result['name'] = $split[0]; // Get any parameters, which come after a semicolon that isn't in a double-quoted string. while ($split[1] == ';') { $split = preg_split('/([:;](?![^"]*"{1}[:;]))/', $split[2], 2, PREG_SPLIT_DELIM_CAPTURE); $param = self::parseParam($split[0]); $params[$param['name']] = $param['values']; } $result['params'] = $params; // Finally, get the property values, which come after a colon that isn't in a double-quoted string. $result['values'] = self::parsePropertyValues($split[2]); return $result; } private static function parseParam(string $param) : array { $result = []; $split = preg_split('/(=)/', $param, 2, PREG_SPLIT_DELIM_CAPTURE); $result['name'] = $split[0]; $result['values'] = self::parseParamValues($split[2]); return $result; } /** * Parse a parameter value string. */ private static function parseParamValues(string $value_string) : array { // Property parameters can have multiple values (see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.4). // Split the sting by unescaped commas. $result = preg_split('/(,(?![^"]*"{1},))/', $value_string); // Unescape the values return array_map([self::class, 'unescapeParamValue'], $result); } /** * Parse a property value string. */ private static function parsePropertyValues(string $value_string) : array { // Properties can have multiple values, separated by commas. These are difficult // to parse using a regex as look-behinds need to be fixed width, so we can't look // for an odd or even number of backslashes. Instead, we just iterate through the // characters in the string. $result = []; $value = ''; $in_escape = false; $iterator = new Utf8String($value_string); while (null !== ($current_char = $iterator->current())) { if ($in_escape) { if (!in_array($current_char, ["\\", ";", ",", "\n", "\N"])) { $message = "Invalid escape sequence '\\$current_char' in value string '$value_string'."; trigger_error($message, E_USER_WARNING); } $value .= $current_char; $in_escape = false; } elseif ($current_char == "\\") { $in_escape = true; } elseif ($current_char == ",") { $result[] = $value; $value = ''; } else { $value .= $current_char; } $iterator->next(); } $result[] = $value; return $result; } /** * Escapes a parameter value, if necessary, so that it can be used in an iCalendar property. */ private static function escapeParamValue(string $value) : string { // From RFC 5545: // quoted-string = DQUOTE *QSAFE-CHAR DQUOTE // QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-US-ASCII // ; Any character except CONTROL and DQUOTE // "Property parameter values MUST NOT contain the DQUOTE character. The // DQUOTE character is used as a delimiter for parameter values that // contain restricted characters or URI text." if (str_contains($value, '"')) { $value = str_replace('"', "'", $value); $message = "Parameter value '$value' contains double quotes. This is not allowed. They have been replaced with single quotes."; trigger_error($message, E_USER_WARNING); } // "Property parameter values that contain the COLON, SEMICOLON, or COMMA // character separators MUST be specified as quoted-string text values." if (preg_match('/[:;,]/', $value)) { $value = '"' . $value . '"'; } return $value; } /** * Unescape a parameter value. */ private static function unescapeParamValue(string $str) : string { return trim($str, '"'); } /** * Escape text for use in a property TEXT value. */ private static function escapeText(string $text) : string { // Escape '\' $text = str_replace("\\", "\\\\", $text); // Escape ';' $text = str_replace(";", "\;", $text); // Escape ',' $text = str_replace(",", "\,", $text); // EOL can only be \n $text = str_replace("\r\n", "\n", $text); // Escape '\n' $text = str_replace("\n", "\\n", $text); // Escape '\N' $text = str_replace("\N", "\\N", $text); return $text; } /** * Reverses RFC 5545 escaping of text. * * Only suitable for a single value, not a list of values. */ private static function unescapeText(string $str) : string { // Unescape '\N' $str = str_replace("\\N", "\N", $str); // Unescape '\n' $str = str_replace("\\n", "\n", $str); // Unescape ',' $str = str_replace("\,", ",", $str); // Unescape ';' $str = str_replace("\;", ";", $str); // Unescape '\' $str = str_replace("\\\\", "\\", $str); return $str; } private function setImplicitValueType() : void { // See RFC 5545 for the default value types for each property. switch ($this->name) { case 'ATTENDEE': case 'ORGANIZER': $this->value_type = self::VALUE_TYPE_CAL_ADDRESS; break; case 'COMPLETED': case 'CREATED': case 'DTEND': case 'DTSTAMP': case 'DTSTART': case 'DUE': case 'EXDATE': case 'LAST-MODIFIED': case 'RDATE': case 'RECURRENCE-ID': $this->value_type = self::VALUE_TYPE_DATE_TIME; break; case 'DURATION': case 'TRIGGER': $this->value_type = self::VALUE_TYPE_DURATION; break; case 'GEO': $this->value_type = self::VALUE_TYPE_FLOAT; break; case 'PERCENT-COMPLETE': case 'PRIORITY': case 'REPEAT': case 'SEQUENCE': $this->value_type = self::VALUE_TYPE_INTEGER; break; case 'FREEBUSY': $this->value_type = self::VALUE_TYPE_PERIOD; break; case 'RRULE': $this->value_type = self::VALUE_TYPE_RECUR; break; case 'ATTACH': case 'TZURL': case 'URL': $this->value_type = self::VALUE_TYPE_URI; break; case 'TZOFFSETFROM': case 'TZOFFSETTO': $this->value_type = self::VALUE_TYPE_UTC_OFFSET; break; default: $this->value_type = self::VALUE_TYPE_TEXT; break; } } }