Files
IPB/applications/calendar/sources/Icalendar/ICSParser.php
T
2025-12-19 05:47:12 -08:00

1358 lines
36 KiB
PHP

<?php
/**
* @brief iCalendar ICS Parser
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @subpackage Calendar
* @since 19 Dec 2013
*/
namespace IPS\calendar\Icalendar;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* iCalendar ICS Parser
* @todo [Future] Tags we do not support but may want to look into supporting: EXDATE, EXRULE, RDATE
*/
class _ICSParser
{
/**
* Parse an RRULE into an array of recurrence data we can use
*
* @param string $rrule The ICS RRULE value
* @param string $timezone Timezone to treat recurring end date as
* @return array
* @todo [Future] We do not support HOURLY, MINUTELY or SECONDLY "FREQ" values
* @todo [Future] We do not support the properties BYSETPOS, BYYEARDAY, BYMONTH, BYHOUR, BYMINUTE, BYSECOND, BYWEEKNO, WKST
* @todo [Future] We do not support complex BYDAY values, such as 1TU or -1FR
* @todo [Future] We do not support multiple values for BYMONTHDAY, nor do we support negative values
* @throws \InvalidArgumentException
* @note Some implementations separate with : and some with ; so we have to support both
*/
public static function parseRrule( $rrule, $timezone=NULL )
{
$rule = array_map( 'trim', explode( ';', str_replace( ':', ';', $rrule ) ) );
$dayNames = array();
$repeatData = array(
'event_repeat' => FALSE, /* Enable/disable checkbox */
'event_repeats' => NULL, /* Daily, weekly, monthly, yearly */
'event_repeat_freq' => NULL, /* Repeat every 1 day, 2 days, 3 days, etc. */
'repeat_end_occurrences' => NULL, /* Ends after x occurrences (if this and end_date are both empty, that means repeat never ends) */
'repeat_end_date' => NULL, /* Ends on x date (which is separate from the event end date - e.g. jan 9 2014 3pm to jan 10 2014 3pm, repeat annually until jan 9 2019) */
);
foreach( \IPS\calendar\Date::getDayNames() as $day )
{
$repeatData['repeat_freq_on_' . $day['ical'] ] = NULL; /* If repeating weekly, this is the days of the week as checkboxes (e.g. repeat every wed, fri and sat) */
$dayNames[] = $day['ical'];
}
foreach( $rule as $ruleData )
{
$_ruleData = explode( '=', $ruleData );
switch( $_ruleData[0] )
{
default:
throw new \InvalidArgumentException( $_ruleData[0] );
break;
case 'FREQ':
$frequency = mb_strtolower( $_ruleData[1] );
if( in_array( $frequency, array( 'daily', 'weekly', 'monthly', 'yearly' ) ) )
{
$repeatData['event_repeats'] = $frequency;
$repeatData['event_repeat'] = TRUE;
}
else
{
/* We don't support less than daily*/
throw new \InvalidArgumentException( 'FREQ' );
}
break;
case 'BYDAY':
$days = explode( ',', $_ruleData[1] );
foreach( $days as $day )
{
foreach( $dayNames as $dayName )
{
$dayPos = \stripos( $day, $dayName );
if( $dayPos !== FALSE )
{
/* We only support basic day recurrence values, not the negative/positive numbers */
if( $dayPos > 0 )
{
throw new \InvalidArgumentException( 'BYDAY' );
}
else
{
$repeatData['repeat_freq_on_' . $dayName ] = TRUE;
}
break;
}
}
}
break;
case 'BYMONTHDAY':
$values = array_map( 'trim', explode( ',', $_ruleData[1] ) );
if( count( $values ) > 1 OR \stripos( $values[0], '-' ) !== FALSE )
{
throw new \InvalidArgumentException( 'BYMONTHDAY' );
}
break;
case 'COUNT':
$repeatData['repeat_end_occurrences'] = (int) $_ruleData[1];
break;
case 'INTERVAL':
$repeatData['event_repeat_freq'] = (int) $_ruleData[1];
break;
case 'UNTIL':
$repeatData['repeat_end_date'] = new \IPS\calendar\Date( $_ruleData[1], $timezone ? new \DateTimeZone( $timezone ) : NULL );
break;
}
}
/* If no repeat frequency specified, default to 1 (e.g. every week, every day, etc.) */
if( $repeatData['event_repeats'] AND !$repeatData['event_repeat_freq'] )
{
$repeatData['event_repeat_freq'] = 1;
}
return $repeatData;
}
/**
* Create an RRULE value from an array of data
*
* @note This is basically the opposite of parseRrule
* @see self::parseRrule()
* @param array $repeat The event repeat data
* @return string|NULL
*/
public static function buildRrule( $repeat )
{
/* If checkbox was not checked, just return */
if( !isset( $repeat['event_repeat'] ) OR !$repeat['event_repeat'] )
{
return NULL;
}
/* The basics - always expected */
$rrule = array(
'FREQ=' . mb_strtoupper( $repeat['event_repeats'] ),
'INTERVAL=' . $repeat['event_repeat_freq']
);
/* Other possible properties - not all will be present */
if( isset( $repeat['repeat_end_occurrences'] ) AND $repeat['repeat_end_occurrences'] )
{
$rrule[] = 'COUNT=' . $repeat['repeat_end_occurrences'];
}
if( isset( $repeat['repeat_end_date'] ) AND $repeat['repeat_end_date'] )
{
$rrule[] = 'UNTIL=' . \IPS\calendar\Date::createFromForm( $repeat['repeat_end_date'], NULL, $repeat['event_timezone'] )->setTimezone( new \DateTimeZone( 'UTC' ) )->modifiedIso8601();
}
/* By-day rule */
$days = array();
foreach( \IPS\calendar\Date::getDayNames() as $day )
{
if( isset( $repeat['repeat_freq_on_' . $day['ical'] ] ) AND $repeat['repeat_freq_on_' . $day['ical'] ] )
{
$days[] = $day['ical'];
}
}
if( count( $days ) )
{
$rrule[] = 'BYDAY=' . implode( ',', $days );
}
return implode( ';', $rrule );
}
/**
* @brief Calendar we are importing to
*/
protected $calendar = NULL;
/**
* @brief Member we are importing as
*/
protected $member = NULL;
/**
* @brief Feed we are importing from
*/
protected $feed = NULL;
/**
* Perform some basic error checking
*
* @param string $content The ICS contents
* @return bool
* @throws \UnexpectedValueException
* @note This is abstracted as a static method so we can perform error checking prior to saving the feed
*/
public static function isValid( $content )
{
/* Perform some basic error checking */
if( !$content )
{
throw new \UnexpectedValueException( "NO_CONTENT" );
}
$_raw = preg_replace( "#(\n\r|\r|\n){1,}#", "\n", $content );
$_raw = explode( "\n", $_raw );
if( !count($_raw) )
{
throw new \UnexpectedValueException( "NO_CONTENT" );
}
if( $_raw[0] != 'BEGIN:VCALENDAR' )
{
throw new \UnexpectedValueException( "BAD_CONTENT" );
}
return TRUE;
}
/**
* Parse the supplied contents (which may come from a URL or an uploaded file) and import events
*
* @param string $content The ICS contents
* @param int|\IPS\calendar\Calendar $calendar The calendar to import to
* @param int|\IPS\Member $member The member the imported events should be 'from'
* @param int $feed The feed we are importing (used to detect and prevent duplicate imports)
* @param int $venue The venue if provided
* @return array Number of events imported and skipped
* @throws \UnexpectedValueException
*/
public function parse( $content, $calendar, $member, $feed=NULL, $venue=NULL )
{
/* Load the calendar */
$this->calendar = ( $calendar instanceof \IPS\calendar\Calendar ) ? $calendar : \IPS\calendar\Calendar::load( $calendar );
/* And the member */
$this->member = ( $member instanceof \IPS\Member ) ? $member : \IPS\Member::load( $member );
/* And the feed */
if( $feed !== NULL )
{
$this->feed = \IPS\calendar\Icalendar::load( $feed );
}
/* Perform some basic error checking */
static::isValid( $content );
$_raw = preg_replace( "#(\n\r|\r|\n){1,}#", "\n", $content );
$_raw = explode( "\n", $_raw );
/* Store the raw data we will parse */
$this->_rawIcsData = $_raw;
/* Now loop and start parsing */
foreach( $this->_rawIcsData as $k => $v )
{
$line = explode( ':', $v );
switch( $line[0] )
{
case 'BEGIN':
$this->_parseBeginBlock( $line[1], $k );
break;
/* Unsupported at this time */
case 'CALSCALE':
case 'METHOD':
case 'X-WR-TIMEZONE':
case 'X-WR-RELCALID':
default:
break;
}
}
/* Convert the raw ICS data to GMT now */
if( count($this->_parsedIcsData) )
{
$this->_parsedIcsData = $this->_convertToGmt( $this->_parsedIcsData );
}
/* Now loop over the results in order to insert */
$_imported = 0;
$_skipped = 0;
$_newEvents = 0;
$maxImport = 500;
// Leave this here - useful for debugging
// print_r($this->_parsedIcsData);exit;
if ( count( $this->_parsedIcsData ) and isset( $this->_parsedIcsData['events'] ) )
{
usort( $this->_parsedIcsData['events'], function( $a, $b ) {
if ( $a['start']['raw_ts'] == $b['start']['raw_ts'] )
{
return 0;
}
return ( $a['start']['raw_ts'] > $b['start']['raw_ts'] ) ? -1 : 1;
} );
/* Loop over the events */
foreach( $this->_parsedIcsData['events'] as $event )
{
if ( $_imported > $maxImport )
{
break;
}
/* Quickly, if we don't support the recurrence data provided let's just skip this event */
if( isset( $event['recurr'] ) )
{
try
{
$rrule = static::parseRrule( $event['recurr'] );
}
catch( \InvalidArgumentException $e )
{
continue;
}
}
$event['uid'] = $event['uid'] ? $event['uid'] : md5( implode( ',', $event['start'] ) . implode( ',', $event['end'] ) );
/* Figure out some times */
$event_unix_from = $event['start']['utc_time'];
$event_unix_to = isset( $event['end'] ) ? $event['end']['utc_time'] : NULL;
$event_all_day = ( $event['start']['type'] == 'DATE' ) ? TRUE : FALSE;
/* End dates in iCalendar format are "exclusive", meaning they are actually the day ahead. */
/* @link http://microformats.org/wiki/dtend-issue */
/* Only adjust end date if end date is not equal too the start date already. */
/* @see Ticket 876817 */
if( $event_unix_to AND $event['end']['type'] == 'DATE' AND ( $event_unix_from->getTimestamp() != $event_unix_to->getTimestamp() ) )
{
$event_unix_to = $event_unix_to->sub( new \DateInterval( 'P1D' ) );
}
/* It is a single day event if end date is equal to start date */
if( $event_unix_from == $event_unix_to )
{
$event_unix_to = NULL;
}
/* If there is a duration, calculate the end date again */
if ( ! $event_unix_to AND isset( $event['duration'] ) AND $event['duration'] )
{
preg_match( "#(\d+?)H#is", $event['duration'], $match );
$hour = $match[1] ? $match[1] : 0;
preg_match( "#(\d+?)M#is", $event['duration'], $match );
$minute = $match[1] ? $match[1] : 0;
preg_match( "#(\d+?)S#is", $event['duration'], $match );
$second = $match[1] ? $match[1] : 0;
$event_unix_to = $event_unix_from->add( new \DateIntveral( 'PT' . ( $hour * 3600 ) . 'H' . ( $minute * 60 ) . 'M' . $second . 'S' ) );
}
/* If this is an all day event, adjust the timestamps */
if( $event_all_day )
{
$event_unix_from = $event_unix_from->setTime( 0, 0, 0 );
$event_unix_to = $event_unix_to ? $event_unix_to->setTime( 0, 0, 0 ) : NULL;
}
/* If we are missing crucial data, skip this event */
if( !$event_unix_from OR ( ( !isset( $event['description'] ) OR !$event['description'] ) AND ( !isset( $event['summary'] ) OR !$event['summary'] ) ) )
{
$_skipped++;
continue;
}
/* Update previously imported events, if possible */
$eventId = NULL;
$_new = FALSE;
if( $event['uid'] )
{
try
{
/* If we are importing an ICS file, feed here will be null. */
if ( is_null( $this->feed ) )
{
/* Bubble up so we can create a new event */
throw new \UnderflowException;
}
$eventId = \IPS\Db::i()->select( 'import_event_id', 'calendar_import_map', array( array( 'import_guid=? and import_feed_id=?', $event['uid'], $this->feed->id ) ) )->first();
try
{
$newEvent = \IPS\calendar\Event::load( $eventId );
}
catch( \OutOfRangeException $e )
{
/* This event seems to have been deleted - skip it. */
$_skipped++;
continue;
}
$_skipped++;
}
catch( \UnderflowException $e )
{
$newEvent = new \IPS\calendar\Event;
$_new = TRUE;
/* Basics */
$newEvent->post_key = md5( mt_rand() );
$newEvent->member_id = $this->member->member_id;
$newEvent->calendar_id = $this->calendar->id;
if( $venue )
{
$newEvent->venue = $venue;
}
$newEvent->approved = 1;
/* RSVP ? */
$newEvent->rsvp = ( $this->feed === NULL ) ? 1 : (int) $this->feed->allow_rsvp;
/* Time */
$newEvent->saved = ( isset( $event['created'] ) AND $event['created'] AND $event['created'] < time() ) ? $event['created'] : time();
}
}
/* Basics */
$newEvent->title = ( isset( $event['summary'] ) AND $event['summary'] ) ? $event['summary'] : mb_substr( strip_tags( $event['description'] ), 0, 100 );
$newEvent->content = ( isset( $event['description'] ) AND $event['description'] ) ? nl2br( $event['description'] ) : $event['summary'] . ( isset( $event['location'] ) ? '<br>' . $event['location'] : '' );
$newEvent->sequence = ( isset( $event['sequence'] ) ? intval( $event['sequence'] ) : 0 );
/* Times and dates */
$newEvent->lastupdated = ( isset( $event['last_modified'] ) AND $event['last_modified'] AND $event['last_modified'] < time() ) ? $event['last_modified'] : ( ( isset( $event['created'] ) AND $event['created'] AND $event['created'] < time() ) ? $event['created'] : time() );
$newEvent->all_day = $event_all_day;
$newEvent->recurring = ( isset( $event['recurr'] ) ) ? $event['recurr'] : NULL;
$newEvent->start_date = $event_unix_from->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
$newEvent->end_date = $event_unix_to ? $event_unix_to->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ) : NULL;
/* Geolocation? */
if( isset( $event['geo'] ) AND is_array( $event['geo'] ) AND count( $event['geo'] ) )
{
try
{
$newEvent->location = json_encode( \IPS\GeoLocation::getByLatLong( $event['geo']['lat'], $event['geo']['long'] ) );
}
catch( \BadFunctionCallException $e ){}
}
elseif( isset( $event['location'] ) )
{
try
{
$location = new \IPS\GeoLocation;
$location->addressLines = array( $event['location'] );
$location->getLatLong();
/* We get by LatLong because getLatLong() does not populate the other address data. */
$newEvent->location = json_encode( \IPS\GeoLocation::getByLatLong( $location->lat, $location->long ) );
}
catch( \BadFunctionCallException $e ){}
}
/* Save */
$newEvent->save();
/* Increment counter */
$_imported++;
/* Increment only if this is a new event */
if( $_new )
{
$_newEvents++;
}
/* Add to index */
\IPS\Content\Search\Index::i()->index( $newEvent );
/* Update map */
if( $this->feed !== NULL AND !$eventId )
{
\IPS\Db::i()->insert( 'calendar_import_map', array(
'import_feed_id' => $this->feed->id,
'import_event_id' => $newEvent->id,
'import_guid' => $event['uid'],
) );
}
/* Add any event attendees that are members of our installation */
if( !$eventId AND isset($event['attendee']) AND count($event['attendee']) )
{
foreach( $event['attendee'] as $attendee )
{
if( $attendee['email'] )
{
$_loadedMember = \IPS\Member::load( $attendee['email'] );
if( $_loadedMember->member_id )
{
\IPS\Db::i()->insert( 'calendar_event_rsvp', array(
'rsvp_member_id' => $_loadedMember->member_id,
'rsvp_event_id' => $newEvent->id,
'rsvp_date' => time(),
) );
}
}
}
}
}
}
/* Increment post counts */
if( $_newEvents )
{
$this->member->member_posts = $this->member->member_posts + $_newEvents;
$this->member->member_last_post = time();
$this->member->save();
}
/* Return the data */
return array( 'skipped' => $_skipped, 'imported' => $_imported );
}
/**
* @brief Array of calendar events we are adding to an iCalendar export
*/
protected $_events = array();
/**
* Add an event
*
* @param \IPS\calendar\Event $event Event data
* @return void
*/
public function addEvent( $event )
{
if( $event->id )
{
$this->_events[ $event->id ] = $event;
}
}
/**
* Remove an event
*
* @param int $eventId Event id
* @return void
*/
public function removeEvent( $eventId )
{
if( $eventId )
{
unset( $this->_events[ $eventId ] );
}
}
/**
* Build iCalendar feed and return
*
* @param int|\IPS\calendar\Calendar|NULL $calendar The calendar the feed belongs to
* @return string iCalendar feed (can be downloaded or sent as webcal subscription)
*/
public function buildICalendarFeed( $calendar=NULL )
{
/* Load the calendar */
$this->calendar = ( $calendar instanceof \IPS\calendar\Calendar ) ? $calendar : ( $calendar !== NULL ? \IPS\calendar\Calendar::load( $calendar ) : NULL );
/* Start formatting the output */
$output = "BEGIN:VCALENDAR\r\n";
$output .= "VERSION:2.0\r\n";
$output .= "PRODID:-//IP.Board Calendar " . \IPS\Application::load( 'calendar' )->version . "//EN\r\n";
$output .= "METHOD:PUBLISH\r\n";
$output .= "CALSCALE:GREGORIAN\r\n";
if( $this->calendar !== NULL )
{
$output .= "X-WR-CALNAME:" . $this->_encodeSpecialCharacters( $this->calendar->_title ) . "\r\n";
}
/* Add the time zones to the export */
$output .= $this->_addTimezones();
/* Then add the events */
$output .= $this->_addEvents();
/* Finalize the output */
$output .= "END:VCALENDAR\r\n";
/* And return */
return $output;
}
/**
* Build the VTIMEZONE parts of the iCalendar feed
*
* @return string
*/
protected function _addTimezones()
{
/* Initialize */
$output = '';
/* Get the years that all events span */
$years = array();
if( count( $this->_events ) )
{
foreach( $this->_events as $event )
{
$years[ $event->_start_date->year ] = $event->_start_date->year;
if( $event->_end_date )
{
$_startTime = $event->_start_date->getTimestamp();
while( $_startTime < $event->_end_date->getTimestamp() )
{
$years[ $event->_start_date->year ] = $event->_start_date->year;
$years[ $event->_end_date->year ] = $event->_end_date->year;
$_startTime += 2592000; // add one month
}
}
}
}
/* Now add the timezones */
foreach( $years as $year )
{
$_daylight_start = strtotime( 'last Sunday of March ' . $year );
$_standard_start = strtotime( 'last Sunday of October ' . $year );
$_daylight = gmmktime( 2, 0, 0, 3 , gmdate( 'j', $_daylight_start ), $year );
$_standard = gmmktime( 2, 0, 0, 10, gmdate( 'j', $_standard_start ), $year );
$output .= "BEGIN:VTIMEZONE\r\n";
$output .= "TZID:Europe/London\r\n";
$output .= "TZURL:http://tzurl.org/zoneinfo/Europe/London\r\n";
$output .= "X-LIC-LOCATION:Europe/London\r\n";
$output .= "BEGIN:DAYLIGHT\r\n";
$output .= "TZOFFSETFROM:+0000\r\n";
$output .= "TZOFFSETTO:+0100\r\n";
$output .= "TZNAME:BST\r\n";
$output .= "DTSTART:" . \IPS\calendar\Date::ts( $_daylight )->modifiedIso8601( TRUE, TRUE ) . "\r\n";
$output .= "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\n";
$output .= "END:DAYLIGHT\r\n";
$output .= "BEGIN:STANDARD\r\n";
$output .= "TZOFFSETFROM:+0100\r\n";
$output .= "TZOFFSETTO:+0000\r\n";
$output .= "TZNAME:GMT\r\n";
$output .= "DTSTART:" . \IPS\calendar\Date::ts( $_standard )->modifiedIso8601( TRUE, TRUE ) . "\r\n";
$output .= "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\n";
$output .= "END:STANDARD\r\n";
$output .= "END:VTIMEZONE\r\n";
}
/* Return the final output */
return $output;
}
/**
* Return a UID for iCalendar
*
* @param \IPS\calendar\Event $event Event
* @param string
*/
protected static function _buildUid( $event )
{
$baseUrl = \IPS\Http\Url::internal('');
return $event->id . '-' . $event->calendar_id . '-' . md5( (string) $baseUrl ) . '@' . $baseUrl->data['host'];
}
/**
* Build the VEVENT parts of the iCalendar feed
*
* @return string
*/
protected function _addEvents()
{
/* Basic Init */
$output = '';
/* Loop over the events */
if( count( $this->_events ) )
{
foreach( $this->_events as $event )
{
/* Normal stuff */
$output .= "BEGIN:VEVENT\r\n";
$output .= "SUMMARY:" . $this->_encodeSpecialCharacters( $event->title ) . "\r\n";
$output .= "DTSTAMP:" . \IPS\calendar\Date::ts( $event->saved )->modifiedIso8601( TRUE, TRUE ) . "\r\n";
$output .= "SEQUENCE:" . $event->sequence . "\r\n";
$output .= "UID:" . static::_buildUid( $event ) . "\r\n";
$output .= $this->_foldLines( "ORGANIZER;CN=\"" . $this->_encodeSpecialCharacters( $event->author()->name, false ) . '":' . \IPS\Settings::i()->email_out ) . "\r\n";
/* Attachments */
$attachments = array();
preg_match_all( "/(http.+?attachment\.php\?id=(\d+))/i", $event->content, $matches );
if( is_array($matches) AND count($matches) )
{
foreach( $matches[2] as $k => $v )
{
$attachments[ $v ] = $matches[0][ $k ];
}
}
if( count( $attachments ) )
{
foreach( \IPS\Db::i()->select( '*', 'core_attachments', 'attach_id IN(' . implode( ',', array_keys( $attachments ) ) . ')' ) as $attachment )
{
$file = \IPS\File::get( 'core_Attachment', $attachment['attach_location'] );
$output .= "ATTACH;FMTTYPE=" . \IPS\File::getMimeType( $file->originalFilename ) . ":" . $attachments[ $attachment['attach_id'] ] . "\r\n";
}
}
/* Description */
$output .= "DESCRIPTION:" . $this->_encodeSpecialCharacters( $event->content ) . "\r\n";
/* Add the times/dates */
if( $event->_end_date )
{
if( $event->all_day )
{
$output .= "DTSTART;VALUE=DATE:" . $event->_start_date->modifiedIso8601( FALSE ) . "\r\n";
$output .= "DTEND;VALUE=DATE:" . $event->_end_date->modifiedIso8601( FALSE ) . "\r\n";
}
else
{
$output .= "DTSTART:" . $event->_start_date->modifiedIso8601( TRUE, TRUE ) . "\r\n";
$output .= "DTEND:" . $event->_end_date->modifiedIso8601( TRUE, TRUE ) . "\r\n";
}
}
else
{
if( $event->all_day )
{
$output .= "DTSTART;VALUE=DATE:" . $event->_start_date->modifiedIso8601( FALSE ) . "\r\n";
}
else
{
$output .= "DTSTART:" . $event->_start_date->modifiedIso8601( TRUE, TRUE ) . "\r\n";
}
}
/* Is this event recurring? */
if ( $event->recurring )
{
$output .= "RRULE:" . $event->recurring . "\r\n";
}
/* Any attendees to the event? */
try
{
foreach( $event->attendees( \IPS\calendar\Event::RSVP_YES ) as $attendee )
{
$output .= $this->_foldLines( "ATTENDEE;CN=\"" . $this->_encodeSpecialCharacters( $attendee->name, false ) . '";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED:' . \IPS\Settings::i()->email_out ) . "\r\n";
}
}
catch( \BadMethodCallException $e ){}
/* End */
$output .= "END:VEVENT\r\n";
}
}
/* And return the combined output now */
return $output;
}
/**
* Encode special characters in a string for iCalendar
*
* @param string $text String to encode
* @param bool $lineFold Line-fold
* @return string Encoded string
*/
protected function _encodeSpecialCharacters( $text, $lineFold=true )
{
$text = strip_tags( str_replace( array( "<br>", "<br />" ), "\n", $text ) );
$text = str_replace( "\\", "\\\\", $text );
$text = str_replace( "\n" , '\\n', $text );
$text = str_replace( "\r" , '\\n', $text );
$text = str_replace( ',' , '\,', $text );
$text = str_replace( ';' , '\;', $text );
$text = str_replace( '"', '\"', $text );
if( $lineFold )
{
$text = $this->_foldLines( $text );
}
return $text;
}
/**
* Fold lines per RFC2445
*
* @param string $text String to fold
* @return string
* @link https://gist.github.com/81747
*/
protected function _foldLines( $text )
{
$return = array();
$_extra = 15; /* Takes into account line beginning, i.e. "DESCRIPTION:" */
while( \strlen($text) > 60 )
{
$space = 75 - $_extra; /* Remove line beginning - subsequent loops this will be tab character */
$mbcc = $space;
while( $mbcc )
{
$line = mb_substr( $text, 0, $mbcc ); /* Get first chunk of chars */
$octet = \strlen( $line ); /* Determine how long this really is (3-byte letters could triple the size) */
/* Too long ? */
if( $octet > $space )
{
if( $mbcc - ( $octet - $space ) < 1 )
{
$mbcc -= round( $mbcc / 3 );
}
else
{
$mbcc -= $octet - $space;
}
}
else
{
$return[] = $line;
$_extra = 1;
$text = mb_substr( $text, $mbcc );
break;
}
}
}
/* Anything left? */
if( !empty($text) )
{
$return[] = $text;
}
/* Return now */
return implode( "\r\n\t", $return );
}
/**
* @brief Type of begin block we are currently parsing
*/
protected $_begin = '';
/**
* @brief Raw iCalendar feed data after parsing
*/
protected $_parsedIcsData = array();
/**
* @brief Raw iCalendar data before parsing
*/
protected $_rawIcsData = array();
/**
* @brief Earliest timestamp from feed
*/
protected $_feedEarliest = 0;
/**
* @brief Latest timestamp from feed
*/
protected $_feedLatest = 0;
/**
* Un-encode special characters in a string coming from iCalendar feed
*
* @param string $text String to unencode
* @return string Unencoded string
*/
protected function _unencodeSpecialCharacters( $text )
{
/* Reverse encoding */
if( \stripos( $text, 'encoding=' ) === 0 )
{
preg_match( "#encoding=(.+?):(.+?)$#i", $text, $matches );
if( $matches[1] )
{
switch( mb_strtolower($matches[1]) )
{
case 'base64':
$text = base64_decode( $matches[2] );
break;
case 'quoted-printable':
$text = quoted_printable_decode( $matches[2] );
break;
}
}
else
{
$text = mb_substr( $text, mb_strpos( $text, ':' ) );
}
}
$text = str_replace( '\\n', "\n", $text );
$text = str_replace( '\,', "," , $text );
$text = str_replace( '\;', ";" , $text );
$text = str_replace( '\:', ":" , $text );
$text = str_replace( 'DQUOTE', '"' , $text );
return $text;
}
/**
* Unfold lines per RFC2445 4.1
*
* @param string $string Starting string
* @param int $line Starting line number
* @return string
*/
protected function _unfoldLines( $string, $line )
{
/* Recursively unfold lines as needed */
if( isset( $this->_rawIcsData[ $line + 1 ] ) AND ( mb_substr( $this->_rawIcsData[ $line + 1 ], 0, 1 ) == ' ' OR mb_substr( $this->_rawIcsData[ $line + 1 ], 0, 1 ) == "\t" ) )
{
$string .= ltrim( $this->_rawIcsData[ $line + 1 ] );
$string = $this->_unfoldLines( $string, $line + 1 );
}
return $string;
}
/**
* Unparse time information from iCalendar datetime info
*
* @param string $string iCalendar line
* @return array Time information
*/
protected function _unparseTimeInfo( $string )
{
/* init */
$value = NULL;
$rawTime = NULL;
$tzid = NULL;
/* split the timestamp from the properties */
$dt = explode( ':', $string );
/* Set the raw time value */
$rawTime = $dt[1];
/* Which properties do we have */
$properties = explode( ';', $dt[0] );
foreach( $properties as $property )
{
if( mb_strpos( $property, '=' ) !== false )
{
$_property = explode( '=', $property );
/* Grab our needed values from the given properties */
switch( $_property[0] )
{
case 'VALUE':
$value = $_property[1];
break;
case 'TZID':
$tzid = $_property[1];
break;
}
}
}
/* If the value is a date without a time, then type should be reset to date */
$type = ( mb_strlen( $rawTime ) === 8 ) ? 'DATE' : 'DATETIME';
/* Format return array */
$return = array(
'type' => $type,
'raw' => $rawTime,
'raw_ts' => strtotime( $rawTime ),
'tzid' => ( $tzid ) ? str_replace( '"', '', $tzid ) : '',
);
/* Is this the earliest or latest timestamp? */
if ( ( $this->_feedEarliest == 0 ) OR ( $return['raw_ts'] < $this->_feedEarliest ) )
{
$this->_feedEarliest = $return['raw_ts'];
}
if ( ( $this->_feedLatest == 0 ) OR ( $return['raw_ts'] > $this->_feedLatest ) )
{
$this->_feedLatest = $return['raw_ts'];
}
/* Return our results */
return $return;
}
/**
* Parse a 'BEGIN:' block in an iCalendar feed
*
* @param string $type Type of 'BEGIN' object
* @param int $start Line number
* @return void
*/
protected function _parseBeginBlock( $type, $start )
{
switch( $type )
{
case 'VCALENDAR':
$this->_begin = 'VCALENDAR';
$this->_processVcalendarObject( $start + 1 );
break;
case 'STANDARD':
if ( $this->_begin == 'VTIMEZONE' )
{
$this->_processTimezoneTypeObject( $start + 1, 'STANDARD' );
}
break;
case 'VEVENT':
$this->_begin = 'VEVENT';
$this->_processEventObject( $start + 1 );
break;
/* Anything else is unsupported at this time */
default:
break;
}
}
/**
* @brief Keep track of object we are parsing inside an event object
*/
protected $currentlyParsing = NULL;
/**
* Parse event object in an ical feed
*
* @param int $start Line number
* @return void
*/
protected function _processEventObject( $start )
{
/* Init */
$_break = false;
$_event = array();
$this->currentlyParsing = 'EVENT';
/* Loop over the lines */
$_recid = null;
for( $i = $start, $j = count( $this->_rawIcsData ); $i < $j; $i++ )
{
/* Unparse and get content */
$tmp = $this->_unparseContent( $this->_rawIcsData[$i], $i );
if ( !$tmp )
{
continue;
}
$_type = $tmp['type'];
$_data = $tmp['data'];
if( $this->currentlyParsing != 'EVENT' AND !in_array( $_type, array( 'END', 'BEGIN' ) ) )
{
continue;
}
switch( $_type )
{
case 'CLASS':
$_event['access_class'] = $_data;
break;
case 'CREATED':
if( !isset( $_event['created'] ) OR !$_event['created'] )
{
$_event['created'] = strtotime( $_data );
}
break;
case 'SUMMARY':
if( mb_strpos( $_data, 'LANGUAGE=' ) === 0 )
{
$_data = preg_replace( "/^LANGUAGE=(.+?):(.+?)$/i", "\\2", $_data );
}
$_event['summary'] = $this->_unencodeSpecialCharacters( $_data );
break;
case 'DESCRIPTION':
if( mb_strpos( $_data, 'LANGUAGE=' ) === 0 )
{
$_data = preg_replace( "/^LANGUAGE=(.+?):(.+?)$/i", "\\2", $_data );
}
$_event['description'] = $this->_unencodeSpecialCharacters( $_data );
break;
case 'DURATION':
$_event['duration'] = $_data;
break;
case 'DTSTART':
$_event['start'] = $this->_unparseTimeInfo( $this->_rawIcsData[$i] );
break;
case 'DTEND':
$_event['end'] = $this->_unparseTimeInfo( $this->_rawIcsData[$i] );
break;
case 'DTSTAMP':
$_event['created'] = strtotime( $_data );
break;
case 'LAST-MODIFIED':
$_event['last_modified'] = strtotime( $_data );
break;
case 'TRANSP':
$_event['time_transparent'] = $_data;
break;
case 'GEO':
$_geo = explode( ":", $_data );
$_event['geo'] = array( 'lat' => $_geo[0], 'long' => $_geo[1] );
break;
case 'ORGANIZER':
if ( $_data )
{
$line = explode( ':', $_data );
$_event['organizer'] = array( 'name' => str_replace( 'CN=', '', $line[0] ), 'email' => $line[1] );
}
break;
case 'ATTENDEE':
$line = explode( ':', $_data );
$_email = '';
foreach( $line as $_line )
{
$_line = str_replace( 'cn=', '', mb_strtolower($_line) );
if( filter_var( $_line, FILTER_VALIDATE_EMAIL ) !== FALSE )
{
$_email = $_line;
}
}
$_event['attendee'][] = array( 'name' => str_replace( 'CN=', '', $line[0] ), 'email' => $_email );
break;
case 'UID':
$_event['uid'] = $_data;
break;
case 'STATUS':
$_event['status'] = $_data;
break;
case 'LOCATION':
$_event['location'] = $this->_unencodeSpecialCharacters( $_data );
break;
case 'SEQUENCE':
$_event['sequence'] = intval($_data);
break;
case 'RRULE':
$_event['recurr'] = $_data;
break;
case 'BEGIN':
$this->currentlyParsing = $_data;
$this->_parseBeginBlock( $_data, $i );
break;
case 'RECURRENCE-ID':
$_recid = $_data;
break;
case 'END':
if( $this->currentlyParsing == 'EVENT' )
{
$_break = true;
}
else
{
$this->currentlyParsing = 'EVENT';
}
break;
}
if( $_break )
{
if( $_recid )
{
$_event['uid'] = md5( $_event['uid'] . $_recid );
}
$this->_parsedIcsData['events'][] = $_event;
break;
}
}
}
/**
* Parse core vcalendar object in an ical feed
*
* @param int $start Line number
* @return void
*/
protected function _processVcalendarObject( $start )
{
/* Loop over the lines */
for( $i = $start, $j = count( $this->_rawIcsData ); $i < $j; $i++ )
{
/* Unparse and get the data */
$tmp = $this->_unparseContent( $this->_rawIcsData[$i], $i );
if ( !$tmp )
{
continue;
}
$_type = $tmp['type'];
$_data = $tmp['data'];
switch( $_type )
{
case 'PRODID':
$this->_parsedIcsData['core']['product'] = $_data;
break;
case 'VERSION':
$this->_parsedIcsData['core']['version'] = $_data;
break;
case 'BEGIN':
$this->_parseBeginBlock( $_data, $i );
break;
case 'X-WR-CALNAME':
$this->_parsedIcsData['core']['calendar_name'] = $_data;
break;
case 'END':
return;
break;
}
}
}
/**
* Unformat content from incoming ical feed
*
* @param string $string String content to unparse
* @param int $line Line number
* @return mixed Array of data, or false
*/
protected function _unparseContent( $string, $line )
{
/* If the line starts with a space it was folded (skip it) */
if( \substr( $this->_rawIcsData[ $line ], 0, 1 ) == ' ' )
{
return false;
}
/* Process */
$_temp = preg_split( "/(:|;)/", $string );
$_type = array_shift( $_temp );
$_data = implode( ':', $_temp );
/* Unfold lines if necessary */
$_data = $this->_unfoldLines( $_data, $line );
/* Return the data */
return array( 'type' => $_type, 'data' => $_data );
}
/**
* Convert times to GMT based on timezones
*
* @param array $data Parsed data
* @return array
*/
protected function _convertToGmt( $data )
{
/* Fix events */
if ( is_array( $data['events'] ) AND count( $data['events'] ) )
{
foreach( $data['events'] as $id => $event )
{
foreach( array( 'start', 'end' ) as $method )
{
/* Is a timezone specified? */
if ( isset( $event[ $method ]['tzid'] ) AND $event[ $method ]['tzid'] )
{
/* Let's test it first... */
if( in_array( $event[ $method ]['tzid'], \IPS\DateTime::getTimezoneIdentifiers() ) )
{
try
{
$timezone = new \DateTimeZone( $event[ $method ]['tzid'] );
}
catch ( \Exception $e )
{
$timezone = NULL;
}
}
$event[ $method ]['utc_time'] = new \IPS\DateTime( $event[ $method ]['raw'], $timezone );
}
elseif( isset( $event[ $method ]['raw'] ) )
{
$event[ $method ]['utc_time'] = new \IPS\DateTime( $event[ $method ]['raw'] );
}
}
$data['events'][ $id ] = $event;
}
}
return $data;
}
}