diff --git a/lib/filter/mapistore/common.php b/lib/filter/mapistore/common.php index ba187dc..e989496 100644 --- a/lib/filter/mapistore/common.php +++ b/lib/filter/mapistore/common.php @@ -1,928 +1,1048 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_filter_mapistore_common { // Common properties [MS-OXCMSG] protected static $common_map = array( // 'PidTagAccess' => '', // 'PidTagAccessLevel' => '', // 0 - read-only, 1 - modify // 'PidTagChangeKey' => '', 'PidTagCreationTime' => 'creation-date', // PtypTime, UTC 'PidTagLastModificationTime' => 'last-modification-date', // PtypTime, UTC // 'PidTagLastModifierName' => '', // 'PidTagObjectType' => '', // @TODO 'PidTagHasAttachments' => 'attach', // PtypBoolean // 'PidTagRecordKey' => '', // 'PidTagSearchKey' => '', 'PidNameKeywords' => 'categories', ); protected $recipient_track_status_map = array( 'TENTATIVE' => 0x00000002, 'ACCEPTED' => 0x00000003, 'DECLINED' => 0x00000004, ); protected $recipient_type_map = array( 'NON-PARTICIPANT' => 0x00000004, 'OPT-PARTICIPANT' => 0x00000002, 'REQ-PARTICIPANT' => 0x00000001, 'CHAIR' => 0x00000001, ); /** * Mapping of weekdays */ protected static $recurrence_day_map = array( 'SU' => 0x00000000, 'MO' => 0x00000001, 'TU' => 0x00000002, 'WE' => 0x00000003, 'TH' => 0x00000004, 'FR' => 0x00000005, 'SA' => 0x00000006, 'BYDAY-SU' => 0x00000001, 'BYDAY-MO' => 0x00000002, 'BYDAY-TU' => 0x00000004, 'BYDAY-WE' => 0x00000008, 'BYDAY-TH' => 0x00000010, 'BYDAY-FR' => 0x00000020, 'BYDAY-SA' => 0x00000040, ); /** * Extracts data from kolab data array */ public static function get_kolab_value($data, $name) { $name_items = explode('.', $name); $count = count($name_items); $value = $data[$name_items[0]]; // special handling of x-custom properties if ($name_items[0] === 'x-custom') { foreach ((array) $value as $custom) { if ($custom['identifier'] === $name_items[1]) { return $custom['value']; } } return null; } for ($i = 1; $i < $count; $i++) { if (!is_array($value)) { return null; } list($key, $num) = explode(':', $name_items[$i]); $value = $value[$key]; if ($num !== null && $value !== null) { $value = is_array($value) ? $value[$num] : null; } } return $value; } /** * Sets specified kolab data item */ public static function set_kolab_value(&$data, $name, $value) { $name_items = explode('.', $name); $count = count($name_items); $element = &$data; // x-custom properties if ($name_items[0] === 'x-custom') { // this is supposed to be converted later by parse_common_props() $data[$name] = $value; return; } if ($count > 1) { for ($i = 0; $i < $count - 1; $i++) { $key = $name_items[$i]; if (!array_key_exists($key, $element)) { $element[$key] = array(); } $element = &$element[$key]; } } $element[$name_items[$count - 1]] = $value; } /** * Parse common properties in object data (convert into MAPI format) */ protected function parse_common_props(&$result, $data, $context = array()) { if (empty($context)) { // @TODO: throw exception? return; } if ($data['uid'] && $context['folder_uid']) { $result['id'] = kolab_api_filter_mapistore::uid_encode($context['folder_uid'], $data['uid']); } if ($context['folder_uid']) { $result['parent_id'] = $context['folder_uid']; } foreach (self::$common_map as $mapi_idx => $kolab_idx) { if (!isset($result[$mapi_idx]) && ($value = $data[$kolab_idx]) !== null) { switch ($mapi_idx) { case 'PidTagCreationTime': case 'PidTagLastModificationTime': $result[$mapi_idx] = self::date_php2mapi($value, true); break; case 'PidTagHasAttachments': if (!empty($value) && $this->model != 'note') { $result[$mapi_idx] = true; } break; case 'PidNameKeywords': $result[$mapi_idx] = self::parse_categories((array) $value); break; } } } } /** * Convert common properties into kolab format */ protected function convert_common_props(&$result, $data, $original) { // @TODO: id, parent_id? foreach (self::$common_map as $mapi_idx => $kolab_idx) { if (array_key_exists($mapi_idx, $data) && !array_key_exists($kolab_idx, $result)) { $value = $data[$mapi_idx]; switch ($mapi_idx) { case 'PidTagCreationTime': case 'PidTagLastModificationTime': if ($value) { $dt = self::date_mapi2php($value); $result[$kolab_idx] = $dt->format('Y-m-d\TH:i:s\Z'); } break; default: if ($value) { $result[$kolab_idx] = $value; } break; } } } // Handle x-custom fields foreach ((array) $result as $key => $value) { if (strpos($key, 'x-custom.') === 0) { unset($result[$key]); $key = substr($key, 9); foreach ((array) $original['x-custom'] as $idx => $custom) { if ($custom['identifier'] == $key) { if ($value) { $original['x-custom'][$idx]['value'] = $value; } else { unset($original['x-custom'][$idx]); } $x_custom_update = true; continue 2; } } if ($value) { $original['x-custom'][] = array( 'identifier' => $key, 'value' => $value, ); } $x_custom_update = true; } } if ($x_custom_update) { $result['x-custom'] = array_values($original['x-custom']); } } /** * Filter property names with mapping (kolab <> MAPI) * * @param array $attrs Property names * @param bool $reverse Reverse mapping * * @return array Property names */ public function attributes_filter($attrs, $reverse = false) { $map = array_merge(self::$common_map, $this->map()); $result = array(); // add some special common attributes $map['PidTagMessageClass'] = 'PidTagMessageClass'; $map['collection'] = 'collection'; $map['id'] = 'uid'; foreach ($attrs as $attr) { if ($reverse) { if ($name = array_search($attr, $map)) { $result[] = $name; } } else if ($name = $map[$attr]) { $result[] = $name; } } return $result; } /** * Return properties map */ protected function map() { return array(); } /** * Parse categories according to [MS-OXCICAL 2.1.3.1.1.20.3] * * @param array Categories * * @return array Categories */ public static function parse_categories($categories) { if (!is_array($categories)) { return; } $result = array(); foreach ($categories as $idx => $val) { $val = preg_replace('/(\x3B|\x2C|\x06\x1B|\xFE\x54|\xFF\x1B)/', '', $val); $val = preg_replace('/\s+/', ' ', $val); $val = trim($val); $len = mb_strlen($val); if ($len) { if ($len > 255) { $val = mb_substr($val, 0, 255); } $result[mb_strtolower($val)] = $val; } } return array_values($result); } /** * Convert Kolab 'attendee' specification into MAPI recipient * and add it to the result */ - public function attendee_to_recipient($attendee, &$result, $is_organizer = false) + protected function attendee_to_recipient($attendee, &$result, $is_organizer = false) { $email = $attendee['cal-address']; $params = (array) $attendee['parameters']; // parse mailto string if (strpos($email, 'mailto:') === 0) { $email = urldecode(substr($email, 7)); } $emails = rcube_mime::decode_address_list($email, 1); if (!empty($email)) { $email = $emails[key($emails)]; $recipient = array( 'PidTagAddressType' => 'SMTP', 'PidTagDisplayName' => $params['cn'] ?: $email['name'], 'PidTagDisplayType' => 0, 'PidTagEmailAddress' => $email['mailto'], ); if ($is_organizer) { $recipient['PidTagRecipientFlags'] = 0x00000003; $recipient['PidTagRecipientType'] = 0x00000001; } else { $recipient['PidTagRecipientFlags'] = 0x00000001; $recipient['PidTagRecipientTrackStatus'] = (int) $this->recipient_track_status_map[$params['partstat']]; $recipient['PidTagRecipientType'] = $this->to_recipient_type($params['cutype'], $params['role']); } $recipient['PidTagRecipientDisplayName'] = $recipient['PidTagDisplayName']; $result['recipients'][] = $recipient; if (strtoupper($params['rsvp']) == 'TRUE') { $result['PidTagReplyRequested'] = true; $result['PidTagResponseRequested'] = true; } } } /** * Convert MAPI recipient into Kolab attendee */ - public function recipient_to_attendee($recipient, &$result) + protected function recipient_to_attendee($recipient, &$result) { if ($email = $recipient['PidTagEmailAddress']) { $mailto = 'mailto:' . rawurlencode($email); $attendee = array( 'cal-address' => $mailto, 'parameters' => array( 'cn' => $recipient['PidTagDisplayName'] ?: $recipient['PidTagRecipientDisplayName'], ), ); if ($recipient['PidTagRecipientFlags'] == 0x00000003) { $result['organizer'] = $attendee; } else { switch ($recipient['PidTagRecipientType']) { case 0x00000004: $role = 'NON-PARTICIPANT'; break; case 0x00000003: $cutype = 'RESOURCE'; break; case 0x00000002: $role = 'OPT-PARTICIPANT'; break; case 0x00000001: $role = 'REQ-PARTICIPANT'; break; } $map = array_flip($this->recipient_track_status_map); $partstat = $map[$recipient['PidTagRecipientTrackStatus']] ?: 'NEEDS-ACTION'; // @TODO: rsvp? $attendee['parameters']['cutype'] = $cutype; $attendee['parameters']['role'] = $role; $attendee['parameters']['partstat'] = $partstat; $result['attendee'][] = $attendee; } } } + /** + * Convert Kolab valarm specification into MAPI properties + * + * @param array $data Kolab object + * @param array $result Object data (MAPI format) + */ + protected function alarm_from_kolab($data, &$result) + { + // [MS-OXCICAL] 2.1.3.1.1.20.62 + foreach ((array) $data['valarm'] as $alarm) { + if (!empty($alarm['properties']) && $alarm['properties']['action'] != 'DISPLAY') { + continue; + } + + // @TODO alarms with Date-Time instead of Duration + $trigger = $alarm['properties']['trigger']; + + if ($trigger['duration'] + && $trigger['parameters']['related'] != 'END' + && ($delta = self::reminder_duration_to_delta($trigger['duration'])) + ) { + // Find next instance of the appointment (in UTC) + $now = kolab_api::$now ?: new DateTime('now', new DateTimeZone('UTC')); + + if ($data['dtstart']) { + $dtstart = kolab_api_input_json::to_datetime($data['dtstart']); + + // check if start date is from the future + if ($dtstart > $now) { + $reminder_time = $dtstart; + } + // find next occurence + else { + kolab_api_input_json::parse_recurrence($data, $res); + + if (!empty($res['recurrence'])) { + $recurlib = libcalendaring::get_recurrence(); + $recurlib->init($res['recurrence'], $now); + $next = $recurlib->next(); + + if ($next) { + $reminder_time = $next; + } + } + } + } + + $result['PidLidReminderDelta'] = $delta; + + // If all instances are in the past, don't set ReminderTime nor ReminderSet + if ($reminder_time) { + $signal_time = clone $reminder_time; + $signal_time->sub(new DateInterval('PT' . $delta . 'M')); + + $result['PidLidReminderSet'] = true; + $result['PidLidReminderTime'] = $this->date_php2mapi($reminder_time, true); + $result['PidLidReminderSignalTime'] = $this->date_php2mapi($signal_time, true); + } + + // MAPI supports only one alarm + break; + } + } + } + + /** + * Convert MAPI recurrence into Kolab (MS-OXICAL: 2.1.3.2.2) + * + * @param string $data MAPI object + * @param array $result Kolab object + */ + protected function alarm_to_kolab($data, &$result) + { + if ($data['PidLidReminderSet'] && ($delta = $data['PidLidReminderDelta'])) { + $duration = self::reminder_delta_to_duration($delta); + $alarm = array( + 'action' => 'DISPLAY', + 'trigger' => array('duration' => $duration), + // 'description' => 'Reminder', + ); + + $result['valarm'] = array(array('properties' => $alarm)); + } + else if (array_key_exists('PidLidReminderSet', $data) || array_key_exists('PidLidReminderDelta', $data)) { + $result['valarm'] = array(); + } + } + + /** + * Convert PidLidReminderDelta value into xCal duration + */ + protected static function reminder_delta_to_duration($delta) + { + if ($delta == 0x5AE980E1) { + $delta = 15; + } + + $delta = (int) $delta; + + return "-PT{$delta}M"; + } + + /** + * Convert Kolab alarm duration into PidLidReminderDelta + */ + protected static function reminder_duration_to_delta($duration) + { + if ($duration && preg_match('/^-[PT]*([0-9]+)([WDHMS])$/', $duration, $matches)) { + $value = intval($matches[1]); + + switch ($matches[2]) { + case 'S': $value = intval(round($value/60)); break; + case 'H': $value *= 60; break; + case 'D': $value *= 24 * 60; break; + case 'W': $value *= 7 * 24 * 60; break; + } + + return $value; + } + } + /** * Convert Kolab recurrence specification into MAPI properties * - * @param array $data Kolab object - * @param array $type Object data (MAPI format) - * @param string $type Object type (event, task) + * @param array $data Kolab object + * @param array $object Object data (MAPI format) * * @return object MAPI recurrence in binary format */ - public static function recurrence_from_kolab($data, $object = array(), $type = 'event') + protected function recurrence_from_kolab($data, $object = array()) { if ((empty($data['rrule']) || empty($data['rrule']['recur'])) && (empty($data['rdate']) || empty($data['rdate']['date'])) ) { return null; } + $type = $this->model; + // Get event/task start date for FirstDateTime calculations if ($dtstart = kolab_api_input_json::to_datetime($data['dtstart'])) { // StartDate: Set to the date portion of DTSTART, in the time zone specified // by PidLidTimeZoneStruct. This date is stored in minutes after // midnight Jan 1, 1601. Note that this value MUST always be // evenly divisible by 1440. // EndDate: Set to the start date of the last instance of a recurrence, in the // time zone specified by PidLidTimeZoneStruct. This date is // stored in minutes after midnight January 1, 1601. If the // recurrence is infinite, set EndDate to 0x5AE980DF. Note that // this value MUST always be evenly divisible by 1440, except for // the special value 0x5AE980DF. $startdate = clone $dtstart; $startdate->setTime(0, 0, 0); $startdate = self::date_php2mapi($startdate, true); $startdate = intval($startdate / 60); if ($mod = ($startdate % 1440)) { $startdate -= $mod; } // @TODO: get first occurrence of the event using libcalendaring_recurrence class ? } else { rcube::raise_error(array( 'line' => __LINE__, 'file' => __FILE__, 'message' => "Found recurring $type without start date, skipping recurrence", ), true, false); return; } $rule = (array) ($data['rrule'] ? $data['rrule']['recur'] : null); $result = array( 'Period' => $rule && $rule['interval'] ? $rule['interval'] : 1, 'FirstDOW' => self::day2bitmask($rule['wkst'] ?: 'MO'), 'OccurrenceCount' => 0x0000000A, 'StartDate' => $startdate, 'EndDate' => 0x5AE980DF, 'FirstDateTime' => $startdate, 'CalendarType' => kolab_api_filter_mapistore_structure_recurrencepattern::CALENDARTYPE_DEFAULT, 'ModifiedInstanceDates' => array(), 'DeletedInstanceDates' => array(), ); switch ($rule['freq']) { case 'DAILY': $result['RecurFrequency'] = kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_DAILY; $result['PatternType'] = kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_DAY; $result['Period'] *= 1440; break; case 'WEEKLY': // if BYDAY does not exist use day from DTSTART if (empty($rule['byday'])) { $rule['byday'] = strtoupper($startdate->format('S')); } $result['RecurFrequency'] = kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_WEEKLY; $result['PatternType'] = kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_WEEK; $result['PatternTypeSpecific'] = self::day2bitmask($rule['byday'], 'BYDAY-'); break; case 'MONTHLY': $result['RecurFrequency'] = kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_MONTHLY; if (!empty($rule['bymonthday'])) { // MAPI doesn't support multi-valued month days $month_day = min(explode(',', $rule['bymonthday'])); $result['PatternType'] = kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTH; $result['PatternTypeSpecific'] = $month_day == -1 ? 0x0000001F : $month_day; } else { $result['PatternType'] = kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTHNTH; $result['PatternTypeSpecific'][] = self::day2bitmask($rule['byday'], 'BYDAY-'); if (!empty($rule['bysetpos'])) { $result['PatternTypeSpecific'][] = $rule['bysetpos'] == -1 ? 0x00000005 : $rule['bysetpos']; } } break; case 'YEARLY': $result['RecurFrequency'] = kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_YEARLY; $result['Period'] *= 12; // MAPI doesn't support multi-valued months if ($rule['bymonth']) { // @TODO: set $startdate } if (!empty($rule['bymonthday'])) { // MAPI doesn't support multi-valued month days $month_day = min(explode(',', $rule['bymonthday'])); $result['PatternType'] = kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTHNTH; $result['PatternTypeSpecific'] = array(0, $month_day == -1 ? 0x0000001F : $month_day); } else { $result['PatternType'] = kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTHNTH; $result['PatternTypeSpecific'][] = self::day2bitmask($rule['byday'], 'BYDAY-'); if (!empty($rule['bysetpos'])) { $result['PatternTypeSpecific'][] = $rule['bysetpos'] == -1 ? 0x00000005 : $rule['bysetpos']; } } break; } $exception_info = array(); $extended_exception = array(); // Custom occurrences (RDATE) if (!empty($data['rdate'])) { foreach ((array) $data['rdate']['date'] as $dt) { try { $dt = new DateTime($dt, $dtstart->getTimezone()); $dt->setTime(0, 0, 0); $dt = self::date_php2minutes($dt); $result['ModifiedInstanceDates'][] = $dt; $result['DeletedInstanceDates'][] = $dt; $exception_info[] = new kolab_api_filter_mapistore_structure_exceptioninfo(array( 'StartDateTime' => $dt, 'EndDateTime' => $dt + $object['PidLidAppointmentDuration'], 'OriginalStartDate' => $dt, 'OverrideFlags' => 0, )); $extended_exception[] = kolab_api_filter_mapistore_structure_extendedexception::get_empty(); } catch (Exception $e) { } } $result['EndType'] = kolab_api_filter_mapistore_structure_recurrencepattern::ENDTYPE_NOCC; $result['OccurenceCount'] = count($result['ModifiedInstanceDates']); // @FIXME: Kolab format says there can be RDATE and/or RRULE // MAPI specification says there must be RRULE if RDATE is specified if (!$result['RecurFrequency']) { $result['RecurFrequency'] = 0; $result['PatternType'] = 0; } } if ($rule && !empty($rule['until'])) { $result['EndDate'] = intval(self::date_php2mapi($rule['until']) / 10000000 / 60); // @TODO: calculate OccurrenceCount? $result['EndType'] = kolab_api_filter_mapistore_structure_recurrencepattern::ENDTYPE_AFTER; } else if ($rule && !empty($rule['count'])) { $result['EndType'] = kolab_api_filter_mapistore_structure_recurrencepattern::ENDTYPE_NOCC; $result['OccurrenceCount'] = $rule['count']; // @TODO: set EndDate } else if (!isset($result['EndType'])) { $result['EndType'] = kolab_api_filter_mapistore_structure_recurrencepattern::ENDTYPE_NEVER; } // Deleted instances (EXDATE) if (!empty($data['exdate'])) { if (!empty($data['exdate']['date'])) { $exceptions = (array) $data['exdate']['date']; } else if (!empty($data['exdate']['date-time'])) { $exceptions = (array) $data['exdate']['date-time']; } else { $exceptions = array(); } // convert date(-time)s to numbers foreach ($exceptions as $idx => $dt) { try { $dt = new DateTime($dt, $dtstart->getTimezone()); $dt->setTime(0, 0, 0); $result['DeletedInstanceDates'][] = self::date_php2minutes($dt); } catch (Exception $e) { } } } // [MS-OXCICAL] 2.1.3.1.1.20.13: Sort and make exceptions valid foreach (array('DeletedInstanceDates', 'ModifiedInstanceDates') as $key) { if (!empty($result[$key])) { sort($result[$key]); $result[$key] = array_values(array_unique(array_filter($result[$key]))); } } $result = new kolab_api_filter_mapistore_structure_recurrencepattern($result); if ($type == 'task') { return $result->output(true); } // @TODO: exceptions $byhour = $rule['byhour'] ? min(explode(',', $rule['byhour'])) : 0; $byminute = $rule['byminute'] ? min(explode(',', $rule['byminute'])) : 0; $offset = 60 * intval($byhour) + intval($byminute); $arp = array( 'RecurrencePattern' => $result, 'StartTimeOffset' => $offset, 'EndTimeOffset' => $offset + $object['PidLidAppointmentDuration'], 'ExceptionInfo' => $exception_info, 'ExtendedException' => $extended_exception, ); $result = new kolab_api_filter_mapistore_structure_appointmentrecurrencepattern($arp); return $result->output(true); } /** * Convert MAPI recurrence into Kolab (MS-OXICAL: 2.1.3.2.2) * * @param string $rule MAPI binary representation of recurrence rule * @param array $object Kolab object - * @param string $type Object type (task, event) */ - public static function recurrence_to_kolab($rule, &$object, $type = 'event') + protected function recurrence_to_kolab($rule, &$object) { if (empty($rule)) { return array(); } // parse binary (Appointment)RecurrencePattern - if ($type == 'event') { + if ($this->model == 'event') { $arp = new kolab_api_filter_mapistore_structure_appointmentrecurrencepattern(); $arp->input($rule, true); $rp = $arp->RecurrencePattern; } else { $rp = new kolab_api_filter_mapistore_structure_recurrencepattern(); $rp->input($rule, true); } $result = array( 'interval' => $rp->Period, ); switch ($rp->PatternType) { case kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_DAY: $result['freq'] = 'DAILY'; $result['interval'] /= 1440; if ($arp) { $result['byhour'] = floor($arp->StartTimeOffset / 60); $result['byminute'] = $arp->StartTimeOffset - $result['byhour'] * 60; } break; case kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_WEEK: $result['freq'] = 'WEEKLY'; $result['byday'] = self::bitmask2day($rp->PatternTypeSpecific); if ($rp->Period >= 1) { $result['wkst'] = self::bitmask2day($rp->FirstDOW); } break; default: // monthly/yearly $evenly_divisible = $rp->Period % 12 == 0; switch ($rp->PatternType) { case kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTH: case kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTHEND: $result['freq'] = $evenly_divisible ? 'YEARLY' : 'MONTHLY'; break; case kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTHNTH: case kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_HJMONTHNTH: $result['freq'] = $evenly_divisible ? 'YEARLY-NTH' : 'MONTHLY-NTH'; break; default: // not-supported return; } if ($result['freq'] = 'MONTHLY') { $rule['bymonthday'] = intval($rp->PatternTypeSpecific == 0x0000001F ? -1 : $rp->PatternTypeSpecific); } else if ($result['freq'] = 'MONTHLY-NTH') { $result['freq'] = 'MONTHLY'; $result['byday'] = self::bitmask2day($rp->PatternTypeSpecific[0]); if ($rp->PatternTypeSpecific[1]) { $result['bysetpos'] = intval($rp->PatternTypeSpecific[1] == 0x00000005 ? -1 : $rp->PatternTypeSpecific[1]); } } else if ($result['freq'] = 'YEARLY') { $result['interval'] /= 12; $rule['bymonthday'] = intval($rp->PatternTypeSpecific == 0x0000001F ? -1 : $rp->PatternTypeSpecific); $rule['bymonth'] = 0;// @TODO: month from FirstDateTime } else if ($result['freq'] = 'YEARLY-NTH') { $result['freq'] = 'YEARLY'; $result['interval'] /= 12; $result['byday'] = self::bitmask2day($rp->PatternTypeSpecific[0]); $result['bymonth'] = 0;// @TODO: month from FirstDateTime if ($rp->PatternTypeSpecific[1]) { $result['bysetpos'] = intval($rp->PatternTypeSpecific[1] == 0x00000005 ? -1 : $rp->PatternTypeSpecific[1]); } } } if ($rp->EndType == kolab_api_filter_mapistore_structure_recurrencepattern::ENDTYPE_AFTER) { // @TODO: set UNTIL to EndDate + StartTimeOffset, or the midnight of EndDate } else if ($rp->EndType == kolab_api_filter_mapistore_structure_recurrencepattern::ENDTYPE_NOCC) { $result['count'] = $rp->OccurrenceCount; } if ($result['interval'] == 1) { unset($result['interval']); } $object['rrule']['recur'] = $result; $object['exdate'] = array(); $object['rdate'] = array(); // $exception_info = (array) $rp->ExceptionInfo; // $extended_exception = (array) $rp->ExtendedException; $modified_dates = (array) $rp->ModifiedInstanceDates; $deleted_dates = (array) $rp->DeletedInstanceDates; // Deleted/Modified exceptions (EXDATE/RDATE) foreach ($deleted_dates as $date) { $idx = in_array($date, $modified_dates) ? 'rdate' : 'exdate'; $dt = self::date_minutes2php($date); if ($dt) { $object[$idx]['date'][] = $dt->format('Y-m-d'); } } } /** * Returns number of minutes between midnight 1601-01-01 * and specified UTC DateTime */ public static function date_php2minutes($date) { - $start = new DateTime('1601-01-01 00:00:00 +00:00'); + $start = new DateTime('1601-01-01 00:00:00 UTC'); // make sure the specified date is in UTC $date->setTimezone(new DateTimeZone('UTC')); return (int) round(($date->getTimestamp() - $start->getTimestamp()) / 60); } /** - * Convert number of minutes between midnight 1601-01-01 - * and specified UTC DateTime into PHP DateTime + * Convert number of minutes between midnight 1601-01-01 (UTC) into PHP DateTime * * @return DateTime|bool DateTime object or False on failure */ public static function date_minutes2php($minutes) { - $datetime = new DateTime('1601-01-01 00:00:00', new DateTimeZone('UTC')); + $datetime = new DateTime('1601-01-01 00:00:00 UTC'); $interval = new DateInterval(sprintf('PT%dM', $minutes)); return $datetime->add($interval); } /** * Convert DateTime object to MAPI date format */ public static function date_php2mapi($date, $utc = true, $time = null) { // convert string to DateTime if (!is_object($date) && !empty($date)) { // convert date to datetime on 00:00:00 if (preg_match('/^([0-9]{4})-?([0-9]{2})-?([0-9]{2})$/', $date, $m)) { $date = $m[1] . '-' . $m[2] . '-' . $m[3] . 'T00:00:00+00:00'; } $date = new DateTime($date); } else if (is_object($date) && $utc) { // clone the date object if we're going to change timezone $date = clone $date; } else { return; } if ($utc) { $date->setTimezone(new DateTimeZone('UTC')); } if (!empty($time)) { $date->setTime((int) $time['hour'], (int) $time['minute'], (int) $time['second']); } // MAPI PTypTime is 64-bit integer representing the number // of 100-nanosecond intervals since January 1, 1601. // Mapistore format for this type is a float number // seconds since 1601-01-01 00:00:00 $seconds = floatval($date->format('U')) + 11644473600; /* if ($microseconds = intval($date->format('u'))) { $seconds += $microseconds/1000000; } */ return $seconds; } /** * Convert date-time from MAPI format to DateTime */ public static function date_mapi2php($date) { $seconds = floatval(sprintf('%.0f', $date)); // assumes we're working with dates after 1970-01-01 - $dt = new DateTime('@' . intval($seconds - 11644473600)); + $dt = new DateTime('@' . intval($seconds - 11644473600), new DateTimeZone('UTC')); /* if ($microseconds = intval(($date - $seconds) * 1000000)) { $dt = new DateTime($dt->format('Y-m-d H:i:s') . '.' . $microseconds, $dt->getTimezone()); } */ return $dt; } /** * Setting PidTagRecipientType according to [MS-OXCICAL 2.1.3.1.1.20.2] */ protected function to_recipient_type($cutype, $role) { if ($cutype && in_array($cutype, array('RESOURCE', 'ROOM'))) { return 0x00000003; } if ($role && ($type = $this->recipient_type_map[$role])) { return $type; } return 0x00000001; } /** * Converts string of days (TU,TH) to bitmask used by MAPI * * @param string $days * * @return int */ protected static function day2bitmask($days, $prefix = '') { $days = explode(',', $days); $result = 0; foreach ($days as $day) { $result = $result + self::$recurrence_day_map[$prefix.$day]; } return $result; } /** * Convert bitmask used by MAPI to string of days (TU,TH) * * @param int $days * * @return string */ protected static function bitmask2day($days) { $days_arr = array(); foreach (self::$recurrence_day_map as $day => $bit) { if (($days & $bit) === $bit) { $days_arr[] = preg_replace('/^BYDAY-/', '', $day); } } $result = implode(',', $days_arr); return $result; } } diff --git a/lib/filter/mapistore/event.php b/lib/filter/mapistore/event.php index 54c6174..f8497b4 100644 --- a/lib/filter/mapistore/event.php +++ b/lib/filter/mapistore/event.php @@ -1,484 +1,433 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_filter_mapistore_event extends kolab_api_filter_mapistore_common { protected $model = 'event'; protected $map = array( // common properties [MS-OXOCAL] 'PidLidAppointmentSequence' => 'sequence', // PtypInteger32 'PidLidBusyStatus' => '', // PtypInteger32, @TODO: X-MICROSOFT-CDO-BUSYSTATUS 'PidLidAppointmentAuxiliaryFlags' => '', // PtypInteger32 'PidLidLocation' => 'location', // PtypString 'PidLidAppointmentStartWhole' => 'dtstart', // PtypTime, UTC 'PidLidAppointmentEndWhole' => 'dtend', // PtypTime, UTC 'PidLidAppointmentDuration' => '', // PtypInteger32, optional 'PidLidAppointmentSubType' => '', // PtypBoolean 'PidLidAppointmentStateFlags' => '', // PtypInteger32 'PidLidResponseStatus' => '', // PtypInteger32 'PidLidRecurring' => '', // PtypBoolean 'PidLidIsRecurring' => '', // PtypBoolean 'PidLidClipStart' => '', // PtypTime 'PidLidClipEnd' => '', // PtypTime 'PidLidAllAttendeesString' => '', // PtypString 'PidLidToAttendeesString' => '', // PtypString 'PidLidCCAttendeesString' => '', // PtypString 'PidLidNonSendableTo' => '', // PtypString 'PidLidNonSendableCc' => '', // PtypString 'PidLidNonSendableBcc' => '', // PtypString 'PidLidNonSendToTrackStatus' => '', // PtypMultipleInteger32 'PidLidNonSendCcTrackStatus' => '', // PtypMultipleInteger32 'PidLidNonSendBccTrackStatus' => '', // PtypMultipleInteger32 'PidLidAppointmentUnsendableRecipients' => '', // PtypBinary, optional 'PidLidAppointmentNotAllowPropose' => '', // PtypBoolean, @TODO: X-MICROSOFT-CDO-DISALLOW-COUNTER 'PidLidGlobalObjectId' => '', // PtypBinary 'PidLidCleanGlobalObjectId' => '', // PtypBinary 'PidTagOwnerAppointmentId' => '', // PtypInteger32, @TODO: X-MICROSOFT-CDO-OWNERAPPTID 'PidTagStartDate' => '', // PtypTime 'PidTagEndDate' => '', // PtypTime 'PidLidCommonStart' => '', // PtypTime 'PidLidCommonEnd' => '', // PtypTime 'PidLidOwnerCriticalChange' => '', // PtypTime, @TODO: X-MICROSOFT-CDO-CRITICAL-CHANGE 'PidLidIsException' => '', // PtypBoolean 'PidTagResponseRequested' => '', // PtypBoolean 'PidTagReplyRequested' => '', // PtypBoolean 'PidLidTimeZoneStruct' => '', // PtypBinary 'PidLidTimeZoneDescription' => '', // PtypString 'PidLidAppointmentTimeZoneDefinitionRecur' => '', // PtypBinary 'PidLidAppointmentTimeZoneDefinitionStartDisplay' => '', // PtypBinary 'PidLidAppointmentTimeZoneDefinitionEndDisplay' => '', // PtypBinary 'PidLidAppointmentRecur' => '', // PtypBinary 'PidLidRecurrenceType' => '', // PtypInteger32 'PidLidRecurrencePattern' => '', // PtypString 'PidLidLinkedTaskItems' => '', // PtypMultipleBinary 'PidLidMeetingWorkspaceUrl' => '', // PtypString 'PidTagIconIndex' => '', // PtypInteger32 'PidLidAppointmentColor' => '', // PtypInteger32 'PidLidAppointmentReplyTime' => '', // @TODO: X-MICROSOFT-CDO-REPLYTIME 'PidLidIntendedBusyStatus' => '', // @TODO: X-MICROSOFT-CDO-INTENDEDSTATUS // calendar object properties [MS-OXOCAL] 'PidTagMessageClass' => '', 'PidLidSideEffects' => '', // PtypInteger32 'PidLidFExceptionAttendees' => '', // PtypBoolean 'PidLidClientIntent' => '', // PtypInteger32 // common props [MS-OXCMSG] 'PidTagSubject' => 'summary', 'PidTagBody' => 'description', 'PidTagHtml' => '', // @TODO: (?) 'PidTagNativeBody' => '', 'PidTagBodyHtml' => '', 'PidTagRtfCompressed' => '', 'PidTagInternetCodepage' => '', 'PidTagContentId' => '', 'PidTagBodyContentLocation' => '', 'PidTagImportance' => 'priority', 'PidTagSensitivity' => 'class', 'PidLidPrivate' => '', 'PidTagCreationTime' => 'created', 'PidTagLastModificationTime' => 'dtstamp', // reminder properties [MS-OXORMDR] 'PidLidReminderSet' => '', // PtypBoolean 'PidLidReminderSignalTime' => '', // PtypTime 'PidLidReminderDelta' => '', // PtypInteger32 'PidLidReminderTime' => '', // PtypTime 'PidLidReminderOverride' => '', // PtypBoolean 'PidLidReminderPlaySound' => '', // PtypBoolean 'PidLidReminderFileParameter' => '', // PtypString 'PidTagReplyTime' => '', // PtypTime 'PidLidReminderType' => '', // PtypInteger32 ); /** * Message importance for PidTagImportance as defined in [MS-OXCMSG] */ protected $importance = array( 0 => 0x00000000, 1 => 0x00000002, 2 => 0x00000002, 3 => 0x00000002, 4 => 0x00000002, 5 => 0x00000001, 6 => 0x00000000, 7 => 0x00000000, 8 => 0x00000000, 9 => 0x00000000, ); /** * Message sesnitivity for PidTagSensitivity as defined in [MS-OXCMSG] */ protected $sensitivity = array( 'public' => 0x00000000, 'personal' => 0x00000001, 'private' => 0x00000002, 'confidential' => 0x00000003, ); /** * Convert Kolab to MAPI * * @param array Data * @param array Context (folder_uid, object_uid, object) * * @return array Data */ public function output($data, $context = null) { $result = array( 'PidTagMessageClass' => 'IPM.Appointment', // mapistore REST API specific properties 'collection' => 'calendars', ); foreach ($this->map as $mapi_idx => $kolab_idx) { if (empty($kolab_idx)) { continue; } $value = $this->get_kolab_value($data, $kolab_idx); if ($value === null) { continue; } switch ($mapi_idx) { case 'PidTagSensitivity': $value = (int) $this->sensitivity[strtolower($value)]; break; case 'PidTagCreationTime': case 'PidTagLastModificationTime': $value = $this->date_php2mapi($value, true); break; case 'PidTagImportance': $value = (int) $this->importance[(int) $value]; break; case 'PidLidAppointmentStartWhole': case 'PidLidAppointmentEndWhole': $dt = kolab_api_input_json::to_datetime($value); $value = $this->date_php2mapi($dt, true); // this is all-day event if ($dt->_dateonly) { $result['PidLidAppointmentSubType'] = 0x00000001; } else if (empty($data['rrule']) && $dt->getTimezone()->getName() != 'UTC') { $idx = sprintf('PidLidAppointmentTimeZoneDefinition%sDisplay', strpos($mapi_idx, 'Start') ? 'Start' : 'End'); $result[$idx] = $this->timezone_definition($dt); } break; } $result[$mapi_idx] = $value; } // Organizer if (!empty($data['organizer'])) { $this->attendee_to_recipient($data['organizer'], $result, true); } // Attendees [MS-OXCICAL 2.1.3.1.1.20.2] foreach ((array) $data['attendee'] as $attendee) { $this->attendee_to_recipient($attendee, $result); } - // Alarms (MAPI supports only one) - foreach ((array) $data['valarm'] as $alarm) { - if ($alarm['properties'] && $alarm['properties']['action'] == 'DISPLAY' - && ($duration = $alarm['properties']['trigger']['duration']) - && ($delta = self::reminder_duration_to_delta($duration)) - ) { - $result['PidLidReminderDelta'] = $delta; - $result['PidLidReminderSet'] = true; - // PidLidReminderTime - // PidLidReminderSignalTime - break; - } - } - // @TODO: PidLidAppointmentDuration // @TODO: exceptions, resources // Recurrence if ($rule = $this->recurrence_from_kolab($data, $result)) { $result['PidLidAppointmentRecur'] = $rule; if ($dt && $dt->getTimezone()->getName() != 'UTC') { $result['PidLidTimeZoneStruct'] = $this->timezone_structure($dt); $result['PidLidTimeZoneDescription'] = $this->timezone_description($dt); } } + // Alarms (MAPI supports only one) + $this->alarm_from_kolab($data, $result); + $this->parse_common_props($result, $data, $context); return $result; } /** * Convert from MAPI to Kolab * * @param array Data * @param array Data of the object that is being updated * * @return array Data */ public function input($data, $object = null) { $result = array(); if ($data['PidLidTimeZoneStruct']) { $timezone = $this->timezone_structure_to_tzname($data['PidLidTimeZoneStruct']); } foreach ($this->map as $mapi_idx => $kolab_idx) { if (empty($kolab_idx)) { continue; } if (!array_key_exists($mapi_idx, $data)) { continue; } $value = $data[$mapi_idx]; switch ($mapi_idx) { case 'PidTagImportance': $map = array( 0x00000002 => 1, 0x00000001 => 5, 0x00000000 => 9, ); $value = (int) $map[(int) $value]; break; case 'PidTagSensitivity': $map = array_flip($this->sensitivity); $value = $map[$value]; break; case 'PidTagCreationTime': case 'PidTagLastModificationTime': if ($value) { $value = $this->date_mapi2php($value); $value = $value->format('Y-m-d\TH:i:s\Z'); } break; case 'PidLidAppointmentStartWhole': case 'PidLidAppointmentEndWhole': if ($value) { $datetime = $this->date_mapi2php($value); $datetime->_dateonly = !empty($data['PidLidAppointmentSubType']); $tz_idx = sprintf('PidLidAppointmentTimeZoneDefinition%sDisplay', strpos($mapi_idx, 'Start') ? 'Start' : 'End'); if ($data[$tz_idx]) { $tz = $this->timezone_definition_to_tzname($data[$tz_idx]); } else { $tz = $timezone; } $value = kolab_api_input_json::from_datetime($datetime, $tz); } break; } $result[$kolab_idx] = $value; } - // Alarms (MAPI supports only one, DISPLAY) - if ($data['PidLidReminderSet'] && ($delta = $data['PidLidReminderDelta'])) { - $duration = self::reminder_delta_to_duration($delta); - $alarm = array( - 'action' => 'DISPLAY', - 'trigger' => array('duration' => $duration), - 'description' => 'Reminder', - ); - $result['valarm'] = array(array('properties' => $alarm)); - } - else if (array_key_exists('PidLidReminderSet', $data) || array_key_exists('PidLidReminderDelta', $data)) { - $result['valarm'] = array(); - } - // Recurrence if (array_key_exists('PidLidAppointmentRecur', $data)) { - $this->recurrence_to_kolab($data['PidLidAppointmentRecur'], $result, 'event'); + $this->recurrence_to_kolab($data['PidLidAppointmentRecur'], $result); } + // Alarms (MAPI supports only one, DISPLAY) + $this->alarm_to_kolab($data, $result); + if (array_key_exists('recipients', $data)) { $result['attendee'] = array(); $result['organizer'] = array(); foreach ((array) $data['recipients'] as $recipient) { $this->recipient_to_attendee($recipient, $result); } } // @TODO: PidLidAppointmentDuration (?) // @TODO: exception, resources $this->convert_common_props($result, $data, $object); return $result; } /** * Returns the attributes names mapping */ public function map() { $map = array_filter($this->map); // @TODO: add properties that are not in the map $map['PidLidAppointmentRecur'] = 'rrule'; return $map; } - /** - * Convert PidLidReminderDelta value into xCal duration - */ - protected static function reminder_delta_to_duration($delta) - { - if ($delta == 0x5AE980E1) { - $delta = 15; - } - - $delta = (int) $delta; - - return "-PT{$delta}M"; - } - - /** - * Convert Kolab alarm duration into PidLidReminderDelta - */ - protected static function reminder_duration_to_delta($duration) - { - if ($duration && preg_match('/^-[PT]*([0-9]+)([WDHMS])$/', $duration, $matches)) { - $value = intval($matches[1]); - - switch ($matches[2]) { - case 'S': $value = intval(round($value/60)); break; - case 'H': $value *= 60; break; - case 'D': $value *= 24 * 60; break; - case 'W': $value *= 7 * 24 * 60; break; - } - - return $value; - } - } - /** * Generate PidLidTimeZoneDescription string for a timezone * specified in a DateTime object */ protected static function timezone_description($datetime) { $timezone = $datetime->getTimezone(); $location = $timezone->getLocation(); $description = $location['comments']; $offset = $timezone->getOffset($datetime); // some location descriptions are useful, but some are not really // replace with timezone name in such cases if (!$description || strpos($description, 'location') !== false) { $description = $timezone->getName(); } + if ($description == 'Z') { + $description = 'UTC'; + } + // convert seconds into hours offset format $hours = round(abs($offset)/3600); $minutes = round((abs($offset) - $hours * 3600) / 60); $offset = sprintf('%s%02d:%02d', $offset < 0 ? '-' : '+', $hours, $minutes); return sprintf('(GMT%s) %s', $offset, $description); } /** * Generate PidLidTimeZoneDefinitionRecur blob for a timezone * specified in a DateTime object */ protected static function timezone_definition($datetime) { $timezone = $datetime->getTimezone(); $tzrule = kolab_api_filter_mapistore_structure_tzrule::from_datetime($datetime); $tzrule->Flags = kolab_api_filter_mapistore_structure_tzrule::FLAG_EFFECTIVE; // @FIXME $tzdef = new kolab_api_filter_mapistore_structure_timezonedefinition(array( 'TZRules' => array($tzrule), 'KeyName' => $timezone->getName(), )); return $tzdef->output(true); } /** * Generate PidLidTimeZoneStruct blob for a timezone * specified in a DateTime object */ protected static function timezone_structure($datetime) { $tzs = kolab_api_filter_mapistore_structure_timezonestruct::from_datetime($datetime, true); return $tzs->output(true); } /** * Parse PidLidTimeZoneStruct blob and convert to timezone name */ protected static function timezone_structure_to_tzname($data) { $api = kolab_api::get_instance(); $tzs = new kolab_api_filter_mapistore_structure_timezonestruct; $tzs->input($data, true); return $tzs->to_tzname($api->config->get('timezone')); } /** * Parse PidLidTimeZoneDefinitionRecur blob and convert to timezone name */ protected static function timezone_definition_to_tzname($data) { $api = kolab_api::get_instance(); $tzdef = new kolab_api_filter_mapistore_structure_timezonedefinition; $tzdef->input($data, true); // Note: we ignore KeyName as it most likely will not contain Olson TZ name foreach ($tzdef->TZRules as $tzrule) { if ($tzname = $tzrule->to_tzname($api->config->get('timezone'))) { return $tzname; } } } } diff --git a/lib/filter/mapistore/task.php b/lib/filter/mapistore/task.php index fa4667d..775ef06 100644 --- a/lib/filter/mapistore/task.php +++ b/lib/filter/mapistore/task.php @@ -1,288 +1,294 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_filter_mapistore_task extends kolab_api_filter_mapistore_common { protected $model = 'task'; protected $map = array( // task specific props [MS-OXOTASK] 'PidTagProcessed' => '', // PtypBoolean 'PidLidTaskMode' => '', // ignored 'PidLidTaskStatus' => '', // PtypInteger32 'PidLidPercentComplete' => 'percent-complete', // PtypFloating64 'PidLidTaskStartDate' => 'dtstart', // PtypTime 'PidLidTaskDueDate' => 'due', // PtypTime 'PidLidTaskResetReminder' => '', // @TODO // PtypBoolean 'PidLidTaskAccepted' => '', // @TODO // PtypBoolean 'PidLidTaskDeadOccurrence' => '', // @TODO // PtypBoolean 'PidLidTaskDateCompleted' => 'x-custom.MAPI:PidLidTaskDateCompleted', // PtypTime 'PidLidTaskLastUpdate' => '', // PtypTime 'PidLidTaskActualEffort' => 'x-custom.MAPI:PidLidTaskActualEffort', // PtypInteger32 'PidLidTaskEstimatedEffort' => 'x-custom.MAPI:PidLidTaskEstimatedEffort', // PtypInteger32 'PidLidTaskVersion' => '', // PtypInteger32 'PidLidTaskState' => '', // PtypInteger32 'PidLidTaskRecurrence' => '', // PtypBinary 'PidLidTaskAssigners' => '', // PtypBinary 'PidLidTaskStatusOnComplete' => '', // PtypBoolean 'PidLidTaskHistory' => '', // @TODO: ? // PtypInteger32 'PidLidTaskUpdates' => '', // PtypBoolean 'PidLidTaskComplete' => '', // PtypBoolean 'PidLidTaskFCreator' => '', // PtypBoolean 'PidLidTaskOwner' => '', // @TODO // PtypString 'PidLidTaskMultipleRecipients' => '', // PtypBoolean 'PidLidTaskAssigner' => '', // PtypString 'PidLidTaskLastUser' => '', // PtypString 'PidLidTaskOrdinal' => '', // PtypInteger32 'PidLidTaskLastDelegate' => '', // PtypString 'PidLidTaskFRecurring' => '', // PtypBoolean 'PidLidTaskOwnership' => '', // @TODO // PtypInteger32 'PidLidTaskAcceptanceState' => '', // PtypInteger32 'PidLidTaskFFixOffline' => '', // PtypBoolean 'PidLidTaskGlobalId' => '', // @TODO // PtypBinary 'PidLidTaskCustomFlags' => '', // ignored 'PidLidTaskRole' => '', // ignored 'PidLidTaskNoCompute' => '', // ignored 'PidLidTeamTask' => '', // ignored // common props [MS-OXCMSG] 'PidTagSubject' => 'summary', 'PidTagBody' => 'description', 'PidTagHtml' => '', // @TODO: (?) 'PidTagNativeBody' => '', 'PidTagBodyHtml' => '', 'PidTagRtfCompressed' => '', 'PidTagInternetCodepage' => '', 'PidTagMessageClass' => '', 'PidLidCommonStart' => 'dtstart', 'PidLidCommonEnd' => 'due', 'PidTagIconIndex' => '', // @TODO 'PidTagCreationTime' => 'created', // PtypTime, UTC 'PidTagLastModificationTime' => 'dtstamp', // PtypTime, UTC ); /** * Values for PidLidTaskStatus property */ protected $status_map = array( 'none' => 0x00000000, // PidLidPercentComplete = 0 'in-progress' => 0x00000001, // PidLidPercentComplete > 0 and PidLidPercentComplete < 1 'complete' => 0x00000002, // PidLidPercentComplete = 1 'waiting' => 0x00000003, 'deferred' => 0x00000004, ); /** * Values for PidLidTaskHistory property */ protected $history_map = array( 'none' => 0x00000000, 'accepted' => 0x00000001, 'rejected' => 0x00000002, 'changed' => 0x00000003, 'due-changed' => 0x00000004, 'assigned' => 0x00000005, ); /** * Convert Kolab to MAPI * * @param array Data * @param array Context (folder_uid, object_uid, object) * * @return array Data */ public function output($data, $context = null) { $result = array( 'PidTagMessageClass' => 'IPM.Task', // mapistore REST API specific properties 'collection' => 'tasks', ); foreach ($this->map as $mapi_idx => $kolab_idx) { if (empty($kolab_idx)) { continue; } $value = $this->get_kolab_value($data, $kolab_idx); if ($value === null) { continue; } switch ($mapi_idx) { case 'PidLidPercentComplete': $value /= 100; break; case 'PidLidTaskStartDate': case 'PidLidTaskDueDate': $value = $this->date_php2mapi($value, false, array('hour' => 0)); break; case 'PidLidCommonStart': case 'PidLidCommonEnd': // case 'PidLidTaskLastUpdate': case 'PidTagCreationTime': case 'PidTagLastModificationTime': $value = $this->date_php2mapi($value, true); break; case 'PidLidTaskActualEffort': case 'PidLidTaskEstimatedEffort': $value = (int) $value; break; } if ($value === null) { continue; } $result[$mapi_idx] = $value; } // set status $percent = $result['PidLidPercentComplete']; if ($precent == 1) { $result['PidLidTaskStatus'] = $this->status_map['complete']; // PidLidTaskDateCompleted (?) } else if ($precent > 0) { $result['PidLidTaskStatus'] = $this->status_map['in-progress']; } else { $result['PidLidTaskStatus'] = $this->status_map['none']; } // Organizer if (!empty($data['organizer'])) { $this->attendee_to_recipient($data['organizer'], $result, true); } // Attendees [MS-OXCICAL 2.1.3.1.1.20.2] foreach ((array) $data['attendee'] as $attendee) { $this->attendee_to_recipient($attendee, $result); } // Recurrence if ($rule = $this->recurrence_from_kolab($data, $result)) { $result['PidLidTaskRecurrence'] = $rule; $result['PidLidTaskFRecurring'] = true; } + // Alarms (MAPI supports only one) + $this->alarm_from_kolab($data, $result); + $this->parse_common_props($result, $data, $context); return $result; } /** * Convert from MAPI to Kolab * * @param array Data * @param array Data of the object that is being updated * * @return array Data */ public function input($data, $object = null) { $result = array(); foreach ($this->map as $mapi_idx => $kolab_idx) { if (empty($kolab_idx)) { continue; } if (!array_key_exists($mapi_idx, $data)) { continue; } $value = $data[$mapi_idx]; switch ($mapi_idx) { case 'PidLidPercentComplete': $value = intval($value * 100); break; case 'PidLidTaskStartDate': case 'PidLidTaskDueDate': if (intval($value) !== 0x5AE980E0) { $value = $this->date_mapi2php($value); $value = $value->format('Y-m-d'); } break; case 'PidLidCommonStart': case 'PidLidCommonEnd': // $value = $this->date_mapi2php($value, true); break; case 'PidTagCreationTime': case 'PidTagLastModificationTime': if ($value) { $value = $this->date_mapi2php($value); $value = $value->format('Y-m-d\TH:i:s\Z'); } break; } $result[$kolab_idx] = $value; } if ($data['PidLidTaskComplete']) { $result['status'] = 'COMPLETED'; } // Recurrence if (array_key_exists('PidLidTaskRecurrence', $data)) { $this->recurrence_to_kolab($data['PidLidTaskRecurrence'], $result, 'task'); } + // Alarms (MAPI supports only one) + $this->alarm_to_kolab($data, $result); + if (array_key_exists('recipients', $data)) { $result['attendee'] = array(); $result['organizer'] = array(); foreach ((array) $data['recipients'] as $recipient) { $this->recipient_to_attendee($recipient, $result); } } $this->convert_common_props($result, $data, $object); return $result; } /** * Returns the attributes names mapping */ public function map() { $map = array_filter($this->map); $map['PidLidTaskRecurrence'] = 'rrule'; return $map; } } diff --git a/lib/input/json.php b/lib/input/json.php index 0b0b357..371d16a 100644 --- a/lib/input/json.php +++ b/lib/input/json.php @@ -1,282 +1,296 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_input_json extends kolab_api_input { /** * Get request data (JSON) * * @param string Expected object type * @param bool Disable filters application * @param array Original object data (set on update requests) * * @return array Request data */ public function input($type = null, $disable_filters = false, $original = null) { if ($this->input_body === null) { $data = file_get_contents('php://input'); $data = trim($data); $data = json_decode($data, true); $this->input_body = $data; } if (!$disable_filters) { if ($this->filter) { if (!empty($original)) { // convert object data into API format $data = $this->api->get_object_data($original, $type); } $this->filter->input_body($this->input_body, $type, $data); } // convert input to internal kolab_storage format if ($type) { $class = "kolab_api_input_json_$type"; $model = new $class; $model->input($this->input_body, $original); } } return $this->input_body; } /** * Convert xCard/xCal date and date-time into internal DateTime * * @param array|string Date or Date-Time * * @return DateTime */ public static function to_datetime($input) { if (empty($input)) { return; } if (is_array($input)) { if ($input['date-time']) { if ($input['parameters']['tzid']) { $tzid = str_replace('/kolab.org/', '', $input['parameters']['tzid']); } else { $tzid = 'UTC'; } $datetime = $input['date-time']; try { $timezone = new DateTimeZone($tzid); } catch (Exception $e) {} } else if ($input['timestamp']) { $datetime = $input['timestamp']; } else if ($input['date']) { $datetime = $input['date']; $is_date = true; } else { return; } } else { $datetime = $input; $is_date = preg_match('/^[0-9]{4}-?[0-9]{2}-?[0-9]{2}$/', $input); } try { $dt = new DateTime($datetime, $timezone ?: new DateTimeZone('UTC')); } catch (Exception $e) { return; } if ($is_date) { $dt->_dateonly = true; $dt->setTime(0, 0, 0); } return $dt; } /** * Convert PHP DateTime into xCard/xCal date or date-time * * @param DateTime $datetime DateTime object * @param string $timezone Timezone identifier * * @return array */ public static function from_datetime($datetime, $timezone = null) { $format = $datetime->_dateonly ? 'Y-m-d' : 'Y-m-d\TH:i:s'; $type = $datetime->_dateonly ? 'date' : 'date-time'; if ($timezone && $timezone != 'UTC') { try { $tz = new DateTimeZone($timezone); $datetime->setTimezone($tz); $result['parameters'] = array( 'tzid' => '/kolab.org/' . $timezone ); } catch (Exception $e) { } } else if (!$datetime->_dateonly) { $format .= '\Z'; } $result[$type] = $datetime->format($format); return $result; } /** * Add x-custom fields to the result */ public static function add_x_custom($data, &$result) { if (array_key_exists('x-custom', (array) $data)) { $value = (array) $data['x-custom']; foreach ((array) $value as $idx => $v) { if ($v['identifier'] && $v['value'] !== null) { $value[$idx] = array($v['identifier'], $v['value']); } else { unset($value[$idx]); } } $result['x-custom'] = $value; } } /** * Parse mailto URI, e.g. attendee/cal-address property * * @param string $uri Mailto: uri * @param string $params Element parameters * * @return string E-mail address */ public static function parse_mailto_uri($uri, &$params = array()) { if (strpos($uri, 'mailto:') === 0) { $uri = substr($uri, 7); $uri = rawurldecode($uri); $emails = rcube_mime::decode_address_list($uri, 1, true, null, false); $email = $emails[1]; if (!empty($email['mailto'])) { if (empty($params['cn']) && !empty($email['name'])) { $params['cn'] = $email['name']; } return $email['mailto']; } } } /** * Parse attendees property input * * @param array $attendees Attendees list * * @return array Attendees list in kolab_format_xcal format */ public static function parse_attendees($attendees) { foreach ((array) $attendees as $idx => $attendee) { $params = $attendee['parameters']; $email = kolab_api_input_json::parse_mailto_uri($attendee['cal-address'], $params); foreach (array('to', 'from') as $val) { foreach ((array) $params['delegated-' . $val] as $del) { if ($del_email = kolab_api_input_json::parse_mailto_uri($del, $params)) { $delegated[$val][] = $del_email; } } } if ($email) { $attendees[$idx] = array_filter(array( 'email' => $email, 'name' => $params['cn'], 'status' => $params['partstat'], 'role' => $params['role'], 'rsvp' => (bool) $params['rsvp'] || strtoupper($params['rsvp']) === 'TRUE', 'cutype' => $params['cutype'], 'dir' => $params['dir'], 'delegated-to' => $delegated['to'], 'delegated-from' => $delegated['from'], )); } else { unset($attendees[$idx]); } } return $attendees; } /** * Handle recurrence rule input * * @param array $data Input data * @param array $result Result data */ public static function parse_recurrence($data, &$result) { + if (array_key_exists('rrule', $data)) { + $result['recurrence'] = array(); + $recur_keys = array( + 'freq', 'interval', 'until', 'count', 'bymonth', 'bymonthday', 'byday', + 'byyearday', 'bysetpos', 'byhour', 'byminute', 'bysecond', 'wkst', + ); + + foreach ($recur_keys as $key) { + if ($data['rrule']['recur'][$key]) { + $result['recurrence'][strtoupper($key)] = $data['rrule']['recur'][$key]; + } + } + } + // Recurrence: deleted exceptions (EXDATE) if (array_key_exists('exdate', $data)) { $result['recurrence']['EXDATE'] = array(); if (!empty($data['exdate']['date'])) { $result['recurrence']['EXDATE'] = (array) $data['exdate']['date']; } else if (!empty($data['exdate']['date-time'])) { $result['recurrence']['EXDATE'] = (array) $data['exdate']['date-time']; } } // Recurrence (RDATE) if (array_key_exists('rdate', $data)) { $result['recurrence']['RDATE'] = array(); if (!empty($data['rdate']['date'])) { $result['recurrence']['RDATE'] = (array) $data['rdate']['date']; } else if (!empty($data['exdate']['date-time'])) { $result['recurrence']['RDATE'] = (array) $data['rdate']['date-time']; } } } } diff --git a/lib/kolab_api.php b/lib/kolab_api.php index 88893b5..95f92d9 100644 --- a/lib/kolab_api.php +++ b/lib/kolab_api.php @@ -1,469 +1,478 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api extends rcube { const APP_NAME = 'Kolab REST API'; const VERSION = '0.1'; public $backend; public $filter; public $input; public $output; + /** + * Current time in UTC. Use it to override + * system time, e.g. for unit-testing. + * + * @var DateTime + */ + public static $now; + protected $model; /** * This implements the 'singleton' design pattern * * @return kolab_api The one and only instance */ public static function get_instance() { if (!self::$instance || !is_a(self::$instance, 'kolab_api')) { $path = kolab_api_input::request_path(); $request = array_shift($path) ?: 'info'; $class = 'kolab_api_' . $request; if (!$request || !class_exists($class)) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND, array( 'line' => __LINE__, 'file' => __FILE__, 'message' => "Invalid request method: $request" )); } self::$instance = new $class(); self::$instance->startup(); } return self::$instance; } /** * Initial startup function * to register session, create database and imap connections */ protected function startup() { $this->init(self::INIT_WITH_DB | self::INIT_WITH_PLUGINS); // Get list of plugins // WARNING: We can use only plugins that are prepared for this // e.g. are not using output or rcmail objects or // doesn't throw errors when using them $plugins = (array) $this->config->get('kolab_api_plugins', array('kolab_auth')); $plugins = array_unique(array_merge($plugins, array('libkolab'))); // this way we're compatible with Roundcube Framework 1.2 // we can't use load_plugins() here foreach ($plugins as $plugin) { $this->plugins->load_plugin($plugin, true); } } /** * Exception handler * * @param kolab_api_exception Exception */ public static function exception_handler($exception) { $code = $exception->getCode(); $message = $exception->getMessage(); if ($code == 401) { header('WWW-Authenticate: Basic realm="' . self::APP_NAME .'"'); } if (!$exception instanceof kolab_api_exception) { rcube::raise_error($exception, true, false); } header("HTTP/1.1 $code $message"); exit; } /** * Program execution handler */ protected function initialize_handler() { // Handle request input $this->input = kolab_api_input::factory($this); // Get input/output filter $this->filter = $this->input->filter; // Start session, validate it and authenticate the user if needed if (!$this->session_validate()) { $this->authenticate(); $authenticated = true; } // Initialize backend $this->backend = kolab_api_backend::get_instance(); // set response output class $this->output = kolab_api_output::factory($this); // Filter the input, we want this after authentication if ($this->filter) { $this->filter->input($this->input); } if ($authenticated) { $this->output->headers(array('X-Session-Token' => session_id())); } } /** * Script shutdown handler */ public function shutdown() { parent::shutdown(); // write performance stats to logs/console if ($this->config->get('devel_mode')) { if (function_exists('memory_get_peak_usage')) $mem = memory_get_peak_usage(); else if (function_exists('memory_get_usage')) $mem = memory_get_usage(); $log = trim(kolab_api_input::request_uri() . ($mem ? sprintf(' [%.1f MB]', $mem/1024/1024) : '')); if (defined('KOLAB_API_START')) { rcube::print_timer(KOLAB_API_START, $log); } else { rcube::console($log); } } } /** * Validate the submitted session token */ protected function session_validate() { $sess_id = $this->input->request_header('X-Session-Token'); if (empty($sess_id)) { session_start(); return false; } session_id($sess_id); session_start(); // Session timeout $timeout = $this->config->get('kolab_api_session_timeout'); if ($timeout && $_SESSION['time'] && $_SESSION['time'] < time() - $timeout) { $_SESSION = array(); return false; } // update session time $_SESSION['time'] = time(); return true; } /** * Authentication request handler (HTTP Auth) */ protected function authenticate() { if (!empty($_SERVER['PHP_AUTH_USER'])) { $username = $_SERVER['PHP_AUTH_USER']; $password = $_SERVER['PHP_AUTH_PW']; } // when used with (f)cgi no PHP_AUTH* variables are available without defining a special rewrite rule else if (!isset($_SERVER['PHP_AUTH_USER'])) { // "Basic didhfiefdhfu4fjfjdsa34drsdfterrde..." if (isset($_SERVER['REMOTE_USER'])) { $basicAuthData = base64_decode(substr($_SERVER['REMOTE_USER'], 6)); } else if (isset($_SERVER['REDIRECT_REMOTE_USER'])) { $basicAuthData = base64_decode(substr($_SERVER['REDIRECT_REMOTE_USER'], 6)); } else if (isset($_SERVER['Authorization'])) { $basicAuthData = base64_decode(substr($_SERVER['Authorization'], 6)); } else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { $basicAuthData = base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6)); } if (isset($basicAuthData) && !empty($basicAuthData)) { list($username, $password) = explode(':', $basicAuthData); } } if (!empty($username)) { $backend = kolab_api_backend::get_instance(); $result = $backend->authenticate($username, $password); } if (empty($result)) { throw new kolab_api_exception(kolab_api_exception::UNAUTHORIZED); } $_SESSION['time'] = time(); } /** * Handle API request */ public function run() { $this->initialize_handler(); $path = $this->input->path; $method = $this->input->method; if (!$path[1] && $path[0] && $method == 'POST') { $this->api_object_create(); } else if ($path[1]) { switch (strtolower($path[2])) { case 'attachments': if ($method == 'HEAD') { $this->api_object_count_attachments(); } else if ($method == 'GET') { $this->api_object_list_attachments(); } break; case '': if ($method == 'GET') { $this->api_object_info(); } else if ($method == 'PUT') { $this->api_object_update(); } else if ($method == 'HEAD') { $this->api_object_exists(); } else if ($method == 'DELETE') { $this->api_object_delete(); } } } throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } /** * Fetch object info */ protected function api_object_info() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $context = array('folder_uid' => $folder, 'object' => $object); $this->output->send($object, $this->model, $context); } /** * Create an object */ protected function api_object_create() { $folder = $this->input->path[0]; $input = $this->input->input($this->model); $context = array('folder_uid' => $folder); $uid = $this->backend->object_create($folder, $input, $this->model); $this->output->send(array('uid' => $uid), $this->model, $context, array('uid')); } /** * Update specified object */ protected function api_object_update() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $context = array( 'folder_uid' => $folder, 'object_uid' => $uid, 'object' => $object, ); // parse input and merge with current data (result is in kolab_format/kolab_api_mail) $input = $this->input->input($this->model, false, $object); // update object on the backend $uid = $this->backend->object_update($folder, $input, $this->model); $this->output->send(array('uid' => $uid), $this->model, $context); } /** * Check if specified object exists */ protected function api_object_exists() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $this->output->send_status(kolab_api_output::STATUS_OK); } /** * Remove specified object */ protected function api_object_delete() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $this->backend->objects_delete($folder, array($uid)); $this->output->send_status(kolab_api_output::STATUS_EMPTY); } /** * Count object attachments */ protected function api_object_count_attachments() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $context = array( 'folder_uid' => $folder, 'object_uid' => $uid, 'object' => $object, ); $count = !empty($object['_attachments']) ? count($object['_attachments']) : 0; $this->output->headers(array('X-Count' => $count), $context); $this->output->send_status(kolab_api_output::STATUS_OK); } /** * List object attachments */ protected function api_object_list_attachments() { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $object = $this->backend->object_get($folder, $uid); $props = $this->input->args['properties'] ? explode(',', $this->input->args['properties']) : null; $context = array( 'folder_uid' => $folder, 'object_uid' => $uid, 'object' => $object, ); // @TODO: currently Kolab format (libkolabxml) allows attachments // in events, tasks and notes. We should support them also in contacts $list = $this->get_object_attachments($object); $this->output->send($list, 'attachment-list', $context, $props); } /** * Extract attachments from the object, depending if it's * Kolab object or email message */ protected function get_object_attachments($object) { // this is a kolab_format object data if (is_array($object)) { $list = (array) $object['_attachments']; foreach ($list as $idx => $att) { $attachment = new rcube_message_part; $attachment->mime_id = $att['id']; $attachment->filename = $att['name']; $attachment->mimetype = $att['mimetype']; $attachment->size = $att['size']; $attachment->disposition = 'attachment'; $attachment->encoding = $att['encoding']; $list[$idx] = $attachment; } } // this is kolab_api_mail or rcube_message(_header) else { $list = (array) $object->attachments; } return $list; } /** * Convert kolab_format object into API format * * @param array Object data in kolab_format * @param string Object type * * @return array Object data in API format */ public function get_object_data($object, $type) { $output = $this->output; if (!$this->output instanceof kolab_api_output_json) { $class = "kolab_api_output_json"; $output = new $class($this); } return $output->convert($object, $type); } /** * Returns RFC2822 formatted current date in user's timezone * * @return string Date */ public function user_date() { // get user's timezone try { $tz = new DateTimeZone($this->config->get('timezone')); - $date = new DateTime('now', $tz); + $date = self::$now ?: new DateTime('now'); + $date->setTimezone($tz); } catch (Exception $e) { $date = new DateTime(); } return $date->format('r'); } } diff --git a/tests/Unit/Filter/Mapistore/Common.php b/tests/Unit/Filter/Mapistore/Common.php index e0bf086..d59beb4 100644 --- a/tests/Unit/Filter/Mapistore/Common.php +++ b/tests/Unit/Filter/Mapistore/Common.php @@ -1,318 +1,182 @@ array( 'n2' => 'test2', ), 'n3' => 'test3', 'x-custom' => array( array('identifier' => 'i', value => 'val_i'), ), ); $value = kolab_api_filter_mapistore_common::get_kolab_value($data, 'n1.n2'); $this->assertSame('test2', $value); $value = kolab_api_filter_mapistore_common::get_kolab_value($data, 'n3'); $this->assertSame('test3', $value); $value = kolab_api_filter_mapistore_common::get_kolab_value($data, 'n30'); $this->assertSame(null, $value); $value = kolab_api_filter_mapistore_common::get_kolab_value($data, 'x-custom.i'); $this->assertSame('val_i', $value); } /** * Test set_kolab_value method */ function test_set_kolab_value() { $data = array(); kolab_api_filter_mapistore_common::set_kolab_value($data, 'n1.n2', 'test'); $this->assertSame('test', $data['n1']['n2']); kolab_api_filter_mapistore_common::set_kolab_value($data, 'n1', 'test'); $this->assertSame('test', $data['n1']); kolab_api_filter_mapistore_common::set_kolab_value($data, 'x-custom.i', 'test1'); $this->assertSame('test1', $data['x-custom.i']); } /** * Test attributes_filter method */ function test_attributes_filter() { $api = new kolab_api_filter_mapistore_common; $input = array( 'creation-date', 'uid', 'unknown', ); $expected = array( 'PidTagCreationTime', 'id', ); $result = $api->attributes_filter($input, true); $this->assertSame($expected, $result); $input = $expected; $expected = array( 'creation-date', 'uid', ); $result = $api->attributes_filter($input); $this->assertSame($expected, $result); $result = $api->attributes_filter(array()); $this->assertSame(array(), $result); } /** * Test parse_categories method */ function test_parse_categories() { $categories = array( "test\x3Btest", "test\x2Ctest", "a\x06\x1Ba", "b\xFE\x54b", "c\xFF\x1Bc", "test ", " test", ); $expected = array( "testtest", "aa", "bb", "cc", "test", ); $result = kolab_api_filter_mapistore_common::parse_categories($categories); $this->assertSame($expected, $result); } - /** - * Test recurrence_to_kolab - */ - function test_recurrence_to_kolab() - { - // empty result - kolab_api_filter_mapistore_event::recurrence_to_kolab('', $result = array()); - $this->assertSame(array(), $result); - - // build complete AppointmentRecurrencePattern structure - $structure = new kolab_api_filter_mapistore_structure_appointmentrecurrencepattern; - $exceptioninfo = new kolab_api_filter_mapistore_structure_exceptioninfo; - $recurrencepattern = new kolab_api_filter_mapistore_structure_recurrencepattern; - $extendedexception = new kolab_api_filter_mapistore_structure_extendedexception; - $highlight = new kolab_api_filter_mapistore_structure_changehighlight; - - $highlight->ChangeHighlightValue = 4; - $extendedexception->ChangeHighlight = $highlight; - $extendedexception->StartDateTime = 0x0CBC9934; - $extendedexception->EndDateTime = 0x0CBC9952; - $extendedexception->OriginalStartDate = 0x0CBC98F8; - $extendedexception->WideCharSubject = 'Simple Recurrence with exceptions'; - $extendedexception->WideCharLocation = '34/4141'; - - $recurrencepattern->RecurFrequency = 0x200b; - $recurrencepattern->PatternType = 1; - $recurrencepattern->CalendarType = 0; - $recurrencepattern->FirstDateTime = 0x000021C0; - $recurrencepattern->Period = 1; - $recurrencepattern->SlidingFlag = 0; - $recurrencepattern->PatternTypeSpecific = 0x00000032; - $recurrencepattern->EndType = 0x00002022; - $recurrencepattern->OccurrenceCount = 12; - $recurrencepattern->FirstDOW = 0; - $recurrencepattern->DeletedInstanceDates = array(217742400, 218268000, 217787040); - $recurrencepattern->ModifiedInstanceDates = array(217787040); - $recurrencepattern->StartDate = 213655680; - $recurrencepattern->EndDate = 0x0CBCAD20; - - $exceptioninfo->StartDateTime = 0x0CBC9934; - $exceptioninfo->EndDateTime = 0x0CBC9952; - $exceptioninfo->OriginalStartDate = 0x0CBC98F8; - $exceptioninfo->Subject = 'Simple Recurrence with exceptions'; - $exceptioninfo->Location = '34/4141'; - - $structure->StartTimeOffset = 600; - $structure->EndTimeOffset = 630; - $structure->ExceptionInfo = array($exceptioninfo); - $structure->RecurrencePattern = $recurrencepattern; - $structure->ExtendedException = array($extendedexception); - - $rule = $structure->output(true); - kolab_api_filter_mapistore_event::recurrence_to_kolab($rule, $result); - - $this->assertSame('WEEKLY', $result['rrule']['recur']['freq']); - $this->assertSame('SU', $result['rrule']['recur']['wkst']); - $this->assertSame('SU,TU,MO,TH,FR', $result['rrule']['recur']['byday']); - $this->assertSame(12, $result['rrule']['recur']['count']); - $this->assertSame('2015-01-01', $result['exdate']['date'][0]); - $this->assertSame('2016-01-01', $result['exdate']['date'][1]); - $this->assertSame('2015-02-01', $result['rdate']['date'][0]); - } - - /** - * Test recurrence_from_kolab - */ - function test_recurrence_from_kolab() - { - $data = array( - 'dtstart' => '2015-01-01T00:00:00Z', - 'rrule' => array( - 'recur' => array( - 'freq' => 'MONTHLY', - 'bymonthday' => 5, - 'count' => 10, - 'interval' => 2, - ), - ), - 'exdate' => array( - 'date' => array( - '2015-01-01', - '2016-01-01', - ), - ), - 'rdate' => array( - 'date' => array( - '2015-02-01', - ), - ), - ); - - $result = kolab_api_filter_mapistore_event::recurrence_from_kolab($data, $event = array()); - $arp = new kolab_api_filter_mapistore_structure_appointmentrecurrencepattern; - $arp->input($result, true); - - $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTH, $arp->RecurrencePattern->PatternType); - $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_MONTHLY, $arp->RecurrencePattern->RecurFrequency); - - // @TODO: test mode recurrence exception details - $this->assertSame(5, $arp->RecurrencePattern->PatternTypeSpecific); - $this->assertSame(10, $arp->RecurrencePattern->OccurrenceCount); - $this->assertSame(2, $arp->RecurrencePattern->Period); - $this->assertSame(3, $arp->RecurrencePattern->DeletedInstanceCount); - $this->assertCount(3, $arp->RecurrencePattern->DeletedInstanceDates); - $this->assertSame(1, $arp->RecurrencePattern->ModifiedInstanceCount); - $this->assertCount(1, $arp->RecurrencePattern->ModifiedInstanceDates); - $this->assertSame(1, $arp->ExceptionCount); - $this->assertCount(1, $arp->ExceptionInfo); - $this->assertCount(1, $arp->ExtendedException); - - // test $type=task - $data = array( - 'dtstart' => '2015-01-01T00:00:00Z', - 'rrule' => array( - 'recur' => array( - 'freq' => 'YEARLY', - 'bymonth' => 5, - 'bymonthday' => 1, - 'count' => 10, - ), - ), - ); - - $result = kolab_api_filter_mapistore_event::recurrence_from_kolab($data, $task = array(), 'task'); - $rp = new kolab_api_filter_mapistore_structure_recurrencepattern; - $rp->input($result, true); - - $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTHNTH, $rp->PatternType); - $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_YEARLY, $rp->RecurFrequency); - $this->assertSame(1, $rp->PatternTypeSpecific[1]); - $this->assertSame(10, $rp->OccurrenceCount); - $this->assertSame(12, $rp->Period); - - // @TODO: test other $rp properties - } - /** * Test date_php2mapi method */ function test_date_php2mapi() { $date = kolab_api_filter_mapistore_common::date_php2mapi('2014-01-01T00:00:00+00:00'); $this->assertSame(13033008000.0, $date); $date = kolab_api_filter_mapistore_common::date_php2mapi('2014-01-01'); $this->assertSame(13033008000.0, $date); $date = kolab_api_filter_mapistore_common::date_php2mapi('1970-01-01T00:00:00Z'); $this->assertSame(11644473600.0, $date); $date = kolab_api_filter_mapistore_common::date_php2mapi('1601-01-01T00:00:00Z'); $this->assertSame(0.0, $date); $date = new DateTime('1601-01-01T00:00:00Z'); $date = kolab_api_filter_mapistore_common::date_php2mapi($date); $this->assertSame(0.0, $date); /* $date = new DateTime('1970-01-01 00:00:00.1000 +0000'); $date = kolab_api_filter_mapistore::date_php2mapi($date); $this->assertSame(11644473600.1, $date); */ $date = kolab_api_filter_mapistore_common::date_php2mapi(''); $this->assertSame(null, $date); } /** * Test date_mapi2php method */ function test_date_mapi2php() { $format = 'c'; $data = array( 13033008000 => '2014-01-01T00:00:00+00:00', 11644473600 => '1970-01-01T00:00:00+00:00', // 11644473600.00001 => '1970-01-01T00:00:00.10+00:00', 0 => '1601-01-01T00:00:00+00:00', ); foreach ($data as $mapi => $exp) { $date = kolab_api_filter_mapistore_common::date_mapi2php($mapi); $this->assertSame($exp, $date->format($format)); } } /** * Test input date_minutes2php */ function test_date_minutes2php() { // @TODO $this->markTestIncomplete('TODO'); } /** * Test input date_php2minutes */ function test_date_php2minutes() { // @TODO $this->markTestIncomplete('TODO'); } } diff --git a/tests/Unit/Filter/Mapistore/Event.php b/tests/Unit/Filter/Mapistore/Event.php index 69724f8..0b846c2 100644 --- a/tests/Unit/Filter/Mapistore/Event.php +++ b/tests/Unit/Filter/Mapistore/Event.php @@ -1,268 +1,416 @@ output($data, $context); $this->assertSame(kolab_api_tests::mapi_uid('Calendar', false, '100-100-100-100'), $result['id']); $this->assertSame('calendars', $result['collection']); $this->assertSame('IPM.Appointment', $result['PidTagMessageClass']); $this->assertSame(kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T13:03:33Z'), $result['PidTagCreationTime']); $this->assertSame(kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T13:50:18Z'), $result['PidTagLastModificationTime']); $this->assertSame(2, $result['PidLidAppointmentSequence']); $this->assertSame(3, $result['PidTagSensitivity']); $this->assertSame(true, $result['PidTagHasAttachments']); $this->assertSame(array('tag1'), $result['PidNameKeywords']); /* $this->assertSame('/kolab.org/Europe/Berlin', $result['dtstart']['parameters']['tzid']); $this->assertSame('2015-05-15T10:00:00', $result['dtstart']['date-time']); $this->assertSame('/kolab.org/Europe/Berlin', $result['dtend']['parameters']['tzid']); $this->assertSame('2015-05-15T10:30:00', $result['dtend']['date-time']); $this->assertSame('https://some.url', $result['url']); */ $this->assertSame('Summary', $result['PidTagSubject']); $this->assertSame('Description', $result['PidTagBody']); $this->assertSame(2, $result['PidTagImportance']); $this->assertSame('Location', $result['PidLidLocation']); $this->assertSame('German, Mark', $result['recipients'][0]['PidTagDisplayName']); $this->assertSame('mark.german@example.org', $result['recipients'][0]['PidTagEmailAddress']); $this->assertSame(1, $result['recipients'][0]['PidTagRecipientType']); $this->assertSame(3, $result['recipients'][0]['PidTagRecipientFlags']); $this->assertSame('Manager, Jane', $result['recipients'][1]['PidTagDisplayName']); $this->assertSame(1, $result['recipients'][1]['PidTagRecipientType']); $this->assertSame('jane.manager@example.org', $result['recipients'][1]['PidTagEmailAddress']); $this->assertSame(0, $result['recipients'][1]['PidTagRecipientTrackStatus']); $this->assertSame(1, $result['recipients'][1]['PidTagRecipientFlags']); $this->assertSame(15, $result['PidLidReminderDelta']); $this->assertSame(true, $result['PidLidReminderSet']); $this->assertTrue($result['PidLidAppointmentTimeZoneDefinitionStartDisplay'] == $result['PidLidAppointmentTimeZoneDefinitionStartDisplay']); // PidLidTimeZoneDefinition $tzd = new kolab_api_filter_mapistore_structure_timezonedefinition; $tzd->input($result['PidLidAppointmentTimeZoneDefinitionStartDisplay'], true); $this->assertSame('Europe/Berlin', $tzd->KeyName); $this->assertCount(1, $tzd->TZRules); $this->assertSame(2015, $tzd->TZRules[0]->Year); $this->assertSame(-60, $tzd->TZRules[0]->Bias); $data = kolab_api_tests::get_data('101-101-101-101', 'Calendar', 'event', 'json', $context); $result = $api->output($data, $context); $this->assertSame(kolab_api_tests::mapi_uid('Calendar', false, '101-101-101-101'), $result['id']); $this->assertSame('calendars', $result['collection']); $this->assertSame('IPM.Appointment', $result['PidTagMessageClass']); $this->assertSame(0, $result['PidTagSensitivity']); $this->assertSame(kolab_api_filter_mapistore_common::date_php2mapi('2015-05-15T00:00:00Z'), $result['PidLidAppointmentStartWhole']); $this->assertSame(kolab_api_filter_mapistore_common::date_php2mapi('2015-05-15T00:00:00Z'), $result['PidLidAppointmentEndWhole']); $this->assertSame(1, $result['PidLidAppointmentSubType']); $this->assertSame(null, $result['PidTagHasAttachments']); // EXDATE $arp = new kolab_api_filter_mapistore_structure_appointmentrecurrencepattern; $arp->input($result['PidLidAppointmentRecur'], true); $this->assertSame(1, $arp->RecurrencePattern->Period); $this->assertSame(0x200B, $arp->RecurrencePattern->RecurFrequency); $this->assertSame(1, $arp->RecurrencePattern->PatternType); $this->assertSame(2, $arp->RecurrencePattern->DeletedInstanceCount); $this->assertCount(2, $arp->RecurrencePattern->DeletedInstanceDates); // RDATE $data = kolab_api_tests::get_data('102-102-102-102', 'Calendar', 'event', 'json', $context); $result = $api->output($data, $context); // recurrence $arp = new kolab_api_filter_mapistore_structure_appointmentrecurrencepattern; $arp->input($result['PidLidAppointmentRecur'], true); $this->assertSame(2, $arp->RecurrencePattern->DeletedInstanceCount); $this->assertCount(2, $arp->RecurrencePattern->DeletedInstanceDates); $this->assertSame(2, $arp->RecurrencePattern->ModifiedInstanceCount); $this->assertCount(2, $arp->RecurrencePattern->ModifiedInstanceDates); $this->assertSame(2, $arp->ExceptionCount); $this->assertCount(2, $arp->ExceptionInfo); $this->assertCount(2, $arp->ExtendedException); // PidLidTimeZoneStruct $tz = new kolab_api_filter_mapistore_structure_timezonestruct; $tz->input($result['PidLidTimeZoneStruct'], true); $this->assertSame(-60, $tz->Bias); $this->assertSame(0, $tz->StandardYear); $this->assertSame(10, $tz->StandardDate->Month); $this->assertSame('(GMT+01:00) Europe/Berlin', $result['PidLidTimeZoneDescription']); } + /** + * Test recurrences output + */ + function test_output_recurrence() + { + $data = array( + 'dtstart' => '2015-01-01T00:00:00Z', + 'rrule' => array( + 'recur' => array( + 'freq' => 'MONTHLY', + 'bymonthday' => 5, + 'count' => 10, + 'interval' => 2, + ), + ), + 'exdate' => array( + 'date' => array( + '2015-01-01', + '2016-01-01', + ), + ), + 'rdate' => array( + 'date' => array( + '2015-02-01', + ), + ), + ); + + $api = new kolab_api_filter_mapistore_event; + $arp = new kolab_api_filter_mapistore_structure_appointmentrecurrencepattern; + $result = $api->output($data, $context); + $arp->input($result['PidLidAppointmentRecur'], true); + + $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTH, $arp->RecurrencePattern->PatternType); + $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_MONTHLY, $arp->RecurrencePattern->RecurFrequency); + + // @TODO: test mode recurrence exception details + $this->assertSame(5, $arp->RecurrencePattern->PatternTypeSpecific); + $this->assertSame(10, $arp->RecurrencePattern->OccurrenceCount); + $this->assertSame(2, $arp->RecurrencePattern->Period); + $this->assertSame(3, $arp->RecurrencePattern->DeletedInstanceCount); + $this->assertCount(3, $arp->RecurrencePattern->DeletedInstanceDates); + $this->assertSame(1, $arp->RecurrencePattern->ModifiedInstanceCount); + $this->assertCount(1, $arp->RecurrencePattern->ModifiedInstanceDates); + $this->assertSame(1, $arp->ExceptionCount); + $this->assertCount(1, $arp->ExceptionInfo); + $this->assertCount(1, $arp->ExtendedException); + } + + /** + * Test alarms output + */ + function test_output_alarms() + { + kolab_api::$now = new DateTime('2015-01-20 00:00:00 UTC'); + + $data = array( + 'dtstart' => '2015-01-01T00:00:00Z', + 'rrule' => array( + 'recur' => array( + 'freq' => 'MONTHLY', + 'bymonthday' => 5, + 'count' => 10, + 'interval' => 1, + ), + ), + 'valarm' => array( + array( + 'properties' => array( + 'action' => 'DISPLAY', + 'trigger' => array( + 'duration' => '-PT15M', + ), + ), + ), + ), + ); + + $api = new kolab_api_filter_mapistore_event; + $result = $api->output($data, $context); + + $this->assertSame(15, $result['PidLidReminderDelta']); + $this->assertSame(true, $result['PidLidReminderSet']); + $this->assertSame('2015-02-20T00:00:00+00:00', kolab_api_filter_mapistore_common::date_mapi2php($result['PidLidReminderTime'])->format('c')); + $this->assertSame('2015-02-19T23:45:00+00:00', kolab_api_filter_mapistore_common::date_mapi2php($result['PidLidReminderSignalTime'])->format('c')); + } + /** * Test input method */ function test_input() { $api = new kolab_api_filter_mapistore_event; $tzs = new kolab_api_filter_mapistore_structure_timezonestruct(array( 'Bias' => -60, 'StandardBias' => 0, 'DaylightBias' => -60, 'StandardDate' => new kolab_api_filter_mapistore_structure_systemtime(array( 'Month' => 10, 'DayOfWeek' => 0, 'Day' => 5, 'Hour' => 3, )), 'DaylightDate' => new kolab_api_filter_mapistore_structure_systemtime(array( 'Month' => 3, 'Day' => 5, 'DayOfWeek' => 0, 'Hour' => 2, )), )); $data = array( 'PidTagCreationTime' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T13:03:33Z'), 'PidTagLastModificationTime' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T13:50:18Z'), 'PidLidAppointmentSequence' => 10, 'PidTagSensitivity' => 3, 'PidNameKeywords' => array('work'), 'PidTagSubject' => 'subject', 'PidTagBody' => 'body', 'PidTagImportance' => 2, 'PidLidLocation' => 'location', 'PidLidAppointmentStartWhole' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T13:03:33Z'), 'PidLidAppointmentEndWhole' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T16:00:00Z'), 'PidLidReminderDelta' => 15, 'PidLidReminderSet' => true, 'PidLidTimeZoneStruct' => $tzs->output(true), 'recipients' => array( array( 'PidTagDisplayName' => 'German, Mark', 'PidTagEmailAddress' => 'mark.german@example.org', 'PidTagRecipientType' => 1, 'PidTagRecipientFlags' => 3, ), array( 'PidTagDisplayName' => 'Manager, Jane', 'PidTagEmailAddress' => 'manager@example.org', 'PidTagRecipientType' => 1, 'PidTagRecipientTrackStatus' => 2, ), ), ); $result = $api->input($data); $this->assertSame('subject', $result['summary']); $this->assertSame('body', $result['description']); $this->assertSame(10, $result['sequence']); $this->assertSame('confidential', $result['class']); $this->assertSame(array('work'), $result['categories']); $this->assertSame('location', $result['location']); $this->assertSame(1, $result['priority']); $this->assertSame('2015-05-14T13:03:33Z', $result['created']); $this->assertSame('2015-05-14T13:50:18Z', $result['dtstamp']); $this->assertSame('2015-05-14T15:03:33', $result['dtstart']['date-time']); $this->assertSame('2015-05-14T18:00:00', $result['dtend']['date-time']); $this->assertRegexp('/kolab.org/', $result['dtstart']['parameters']['tzid']); $this->assertRegexp('/kolab.org/', $result['dtend']['parameters']['tzid']); $this->assertSame('DISPLAY', $result['valarm'][0]['properties']['action']); - $this->assertSame('Reminder', $result['valarm'][0]['properties']['description']); $this->assertSame('-PT15M', $result['valarm'][0]['properties']['trigger']['duration']); $this->assertSame('Manager, Jane', $result['attendee'][0]['parameters']['cn']); $this->assertSame('TENTATIVE', $result['attendee'][0]['parameters']['partstat']); $this->assertSame('REQ-PARTICIPANT', $result['attendee'][0]['parameters']['role']); // $this->assertSame(true, $result['attendee'][0]['parameters']['rsvp']); $this->assertSame('mailto:manager%40example.org', $result['attendee'][0]['cal-address']); $this->assertSame('German, Mark', $result['organizer']['parameters']['cn']); $this->assertSame('mailto:mark.german%40example.org', $result['organizer']['cal-address']); self::$original = $result; $tzdef = base64_encode(pack("H*", '0201300002001500' . '500061006300690066006900630020005300740061006E0064006100720064002000540069006D006500' . '0100' . '02013E000000D6070000000000000000000000000000E001000000000000C4FFFFFF00000A0000000500020000000000000000000400000001000200000000000000' )); $data = array( 'PidLidAppointmentStartWhole' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T05:00:00Z'), 'PidLidAppointmentEndWhole' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T05:00:00Z'), 'PidLidAppointmentTimeZoneDefinitionStartDisplay' => $tzdef, 'PidLidReminderSet' => false, // @TODO: recurrence, exceptions, alarms ); $result = $api->input($data); $this->assertSame('2015-05-13T22:00:00', $result['dtstart']['date-time']); $this->assertSame('2015-05-14T05:00:00Z', $result['dtend']['date-time']); $this->assertSame(array(), $result['valarm']); } /** * Test input method with merge */ function test_input2() { $api = new kolab_api_filter_mapistore_event; $data = array( // 'PidTagCreationTime' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T13:03:33Z'), // 'PidTagLastModificationTime' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-14T13:50:18Z'), 'PidLidAppointmentSequence' => 20, 'PidTagSensitivity' => 2, 'PidNameKeywords' => array('work1'), 'PidTagSubject' => 'subject1', 'PidTagBody' => 'body1', 'PidTagImportance' => 1, 'PidLidLocation' => 'location1', 'PidLidAppointmentStartWhole' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-15T13:03:33Z'), 'PidLidAppointmentEndWhole' => kolab_api_filter_mapistore_common::date_php2mapi('2015-05-15T16:00:00Z'), 'PidLidReminderDelta' => 25, 'PidLidReminderSet' => true, ); $result = $api->input($data, self::$original); $this->assertSame('subject1', $result['summary']); $this->assertSame('body1', $result['description']); $this->assertSame(20, $result['sequence']); $this->assertSame('private', $result['class']); $this->assertSame(array('work1'), $result['categories']); $this->assertSame('location1', $result['location']); $this->assertSame(5, $result['priority']); // $this->assertSame('2015-05-14T13:03:33Z', $result['created']); // $this->assertSame('2015-05-14T13:50:18Z', $result['dtstamp']); $this->assertSame('2015-05-15T13:03:33Z', $result['dtstart']['date-time']); $this->assertSame('2015-05-15T16:00:00Z', $result['dtend']['date-time']); $this->assertSame('DISPLAY', $result['valarm'][0]['properties']['action']); - $this->assertSame('Reminder', $result['valarm'][0]['properties']['description']); $this->assertSame('-PT25M', $result['valarm'][0]['properties']['trigger']['duration']); - // @TODO: recurrence, exceptions, attendees + // @TODO: exceptions, attendees + } + + /** + * Test input recurrence + */ + function test_input_recurrence() + { + $api = new kolab_api_filter_mapistore_event; + + // build complete AppointmentRecurrencePattern structure + $structure = new kolab_api_filter_mapistore_structure_appointmentrecurrencepattern; + $exceptioninfo = new kolab_api_filter_mapistore_structure_exceptioninfo; + $recurrencepattern = new kolab_api_filter_mapistore_structure_recurrencepattern; + $extendedexception = new kolab_api_filter_mapistore_structure_extendedexception; + $highlight = new kolab_api_filter_mapistore_structure_changehighlight; + + $highlight->ChangeHighlightValue = 4; + $extendedexception->ChangeHighlight = $highlight; + $extendedexception->StartDateTime = 0x0CBC9934; + $extendedexception->EndDateTime = 0x0CBC9952; + $extendedexception->OriginalStartDate = 0x0CBC98F8; + $extendedexception->WideCharSubject = 'Simple Recurrence with exceptions'; + $extendedexception->WideCharLocation = '34/4141'; + + $recurrencepattern->RecurFrequency = 0x200b; + $recurrencepattern->PatternType = 1; + $recurrencepattern->CalendarType = 0; + $recurrencepattern->FirstDateTime = 0x000021C0; + $recurrencepattern->Period = 1; + $recurrencepattern->SlidingFlag = 0; + $recurrencepattern->PatternTypeSpecific = 0x00000032; + $recurrencepattern->EndType = 0x00002022; + $recurrencepattern->OccurrenceCount = 12; + $recurrencepattern->FirstDOW = 0; + $recurrencepattern->DeletedInstanceDates = array(217742400, 218268000, 217787040); + $recurrencepattern->ModifiedInstanceDates = array(217787040); + $recurrencepattern->StartDate = 213655680; + $recurrencepattern->EndDate = 0x0CBCAD20; + + $exceptioninfo->StartDateTime = 0x0CBC9934; + $exceptioninfo->EndDateTime = 0x0CBC9952; + $exceptioninfo->OriginalStartDate = 0x0CBC98F8; + $exceptioninfo->Subject = 'Simple Recurrence with exceptions'; + $exceptioninfo->Location = '34/4141'; + + $structure->StartTimeOffset = 600; + $structure->EndTimeOffset = 630; + $structure->ExceptionInfo = array($exceptioninfo); + $structure->RecurrencePattern = $recurrencepattern; + $structure->ExtendedException = array($extendedexception); + + $rule = $structure->output(true); + $result = $api->input(array('PidLidAppointmentRecur' => $rule), $context); + + $this->assertSame('WEEKLY', $result['rrule']['recur']['freq']); + $this->assertSame('SU', $result['rrule']['recur']['wkst']); + $this->assertSame('SU,TU,MO,TH,FR', $result['rrule']['recur']['byday']); + $this->assertSame(12, $result['rrule']['recur']['count']); + $this->assertSame('2015-01-01', $result['exdate']['date'][0]); + $this->assertSame('2016-01-01', $result['exdate']['date'][1]); + $this->assertSame('2015-02-01', $result['rdate']['date'][0]); } /** * Test map method */ function test_map() { $api = new kolab_api_filter_mapistore_event; $map = $api->map(); $this->assertInternalType('array', $map); $this->assertTrue(!empty($map)); } } diff --git a/tests/Unit/Filter/Mapistore/Task.php b/tests/Unit/Filter/Mapistore/Task.php index 971664a..cb32640 100644 --- a/tests/Unit/Filter/Mapistore/Task.php +++ b/tests/Unit/Filter/Mapistore/Task.php @@ -1,185 +1,267 @@ output($data, $context); $this->assertSame(kolab_api_tests::mapi_uid('Tasks', false, '10-10-10-10'), $result['id']); $this->assertSame(kolab_api_tests::folder_uid('Tasks', false), $result['parent_id']); $this->assertSame('IPM.Task', $result['PidTagMessageClass']); $this->assertSame('tasks', $result['collection']); $this->assertSame('task title', $result['PidTagSubject']); $this->assertSame("task description\nsecond line", $result['PidTagBody']); $this->assertSame(0.56, $result['PidLidPercentComplete']); $this->assertSame(kolab_api_filter_mapistore_common::date_php2mapi('2015-04-20T14:22:18Z', true), $result['PidTagLastModificationTime']); $this->assertSame(kolab_api_filter_mapistore_common::date_php2mapi('2015-04-20T14:22:18Z', true), $result['PidTagCreationTime']); $this->assertSame(8, $result['PidLidTaskActualEffort']); $this->assertSame(true, $result['PidTagHasAttachments']); $this->assertSame(array('tag1'), $result['PidNameKeywords']); $data = kolab_api_tests::get_data('20-20-20-20', 'Tasks', 'task', 'json', $context); $result = $api->output($data, $context); $this->assertSame(kolab_api_tests::mapi_uid('Tasks', false, '20-20-20-20'), $result['id']); $this->assertSame(kolab_api_tests::folder_uid('Tasks', false), $result['parent_id']); $this->assertSame('IPM.Task', $result['PidTagMessageClass']); $this->assertSame('tasks', $result['collection']); $this->assertSame('task', $result['PidTagSubject']); $this->assertSame(kolab_api_filter_mapistore_common::date_php2mapi('2015-04-20', true), $result['PidLidTaskStartDate']); $this->assertSame(kolab_api_filter_mapistore_common::date_php2mapi('2015-04-27', true), $result['PidLidTaskDueDate']); $this->assertSame(null, $result['PidTagHasAttachments']); // organizer/attendees $this->assertSame('German, Mark', $result['recipients'][0]['PidTagDisplayName']); $this->assertSame('mark.german@example.org', $result['recipients'][0]['PidTagEmailAddress']); $this->assertSame(1, $result['recipients'][0]['PidTagRecipientType']); $this->assertSame('Manager, Jane', $result['recipients'][1]['PidTagDisplayName']); $this->assertSame(1, $result['recipients'][1]['PidTagRecipientType']); $this->assertSame('jane.manager@example.org', $result['recipients'][1]['PidTagEmailAddress']); + // reminder + $this->assertSame(15, $result['PidLidReminderDelta']); + $this->assertSame(true, $result['PidLidReminderSet']); + // recurrence $rp = new kolab_api_filter_mapistore_structure_recurrencepattern; $rp->input($result['PidLidTaskRecurrence'], true); $this->assertSame(true, $result['PidLidTaskFRecurring']); $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_DAY, $rp->PatternType); $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_DAILY, $rp->RecurFrequency); } + /** + * Test output recurrence + */ + function test_output_recurrence() + { + // test task recurrence + $data = array( + 'dtstart' => '2015-01-01T00:00:00Z', + 'rrule' => array( + 'recur' => array( + 'freq' => 'YEARLY', + 'bymonth' => 5, + 'bymonthday' => 1, + 'count' => 10, + ), + ), + ); + + $api = new kolab_api_filter_mapistore_task; + $rp = new kolab_api_filter_mapistore_structure_recurrencepattern; + $result = $api->output($data, $context); + $rp->input($result['PidLidTaskRecurrence'], true); + + $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::PATTERNTYPE_MONTHNTH, $rp->PatternType); + $this->assertSame(kolab_api_filter_mapistore_structure_recurrencepattern::RECURFREQUENCY_YEARLY, $rp->RecurFrequency); + $this->assertSame(1, $rp->PatternTypeSpecific[1]); + $this->assertSame(10, $rp->OccurrenceCount); + $this->assertSame(12, $rp->Period); + + // @TODO: test other $rp properties + } + + /** + * Test alarms output + */ + function test_output_alarms() + { + kolab_api::$now = new DateTime('2015-01-20 00:00:00 UTC'); + + $data = array( + 'dtstart' => '2015-01-01T00:00:00Z', + 'rrule' => array( + 'recur' => array( + 'freq' => 'MONTHLY', + 'bymonthday' => 5, + 'count' => 10, + 'interval' => 1, + ), + ), + 'valarm' => array( + array( + 'properties' => array( + 'action' => 'DISPLAY', + 'trigger' => array( + 'duration' => '-PT15M', + ), + ), + ), + ), + ); + + $api = new kolab_api_filter_mapistore_task; + $result = $api->output($data, $context); + + $this->assertSame(15, $result['PidLidReminderDelta']); + $this->assertSame(true, $result['PidLidReminderSet']); + $this->assertSame('2015-02-20T00:00:00+00:00', kolab_api_filter_mapistore_common::date_mapi2php($result['PidLidReminderTime'])->format('c')); + $this->assertSame('2015-02-19T23:45:00+00:00', kolab_api_filter_mapistore_common::date_mapi2php($result['PidLidReminderSignalTime'])->format('c')); + } + /** * Test input method */ function test_input() { $api = new kolab_api_filter_mapistore_task; $data = array( 'id' => kolab_api_tests::mapi_uid('Tasks', false, '10-10-10-10'), 'parent_id' => kolab_api_tests::folder_uid('Tasks', false), 'PidTagCreationTime' => kolab_api_filter_mapistore_common::date_php2mapi('2015-01-20T11:44:59Z'), 'PidTagLastModificationTime' => kolab_api_filter_mapistore_common::date_php2mapi('2015-01-22T11:30:17Z'), 'PidTagMessageClass' => 'IPM.Task', 'PidTagSubject' => 'subject', 'PidLidPercentComplete' => 0.56, 'PidTagBody' => 'body', 'PidLidTaskStartDate' => kolab_api_filter_mapistore_common::date_php2mapi('2015-04-20', true), 'PidLidTaskDueDate' => kolab_api_filter_mapistore_common::date_php2mapi('2015-04-27', true), 'PidLidTaskActualEffort' => 16, 'PidLidTaskEstimatedEffort' => 20, + 'PidLidReminderDelta' => 15, + 'PidLidReminderSet' => true, 'PidNameKeywords' => array('work1'), 'recipients' => array( array( 'PidTagDisplayName' => 'German, Mark', 'PidTagEmailAddress' => 'mark.german@example.org', 'PidTagRecipientType' => 1, 'PidTagRecipientFlags' => 3, ), array( 'PidTagDisplayName' => 'Manager, Jane', 'PidTagEmailAddress' => 'manager@example.org', 'PidTagRecipientType' => 1, 'PidTagRecipientTrackStatus' => 2, ), ), ); $result = $api->input($data); self::$original = $result; $this->assertSame('subject', $result['summary']); $this->assertSame('body', $result['description']); $this->assertSame(56, $result['percent-complete']); $this->assertSame('2015-01-20T11:44:59Z', $result['created']); $this->assertSame('2015-01-22T11:30:17Z', $result['dtstamp']); $this->assertSame('2015-04-20', $result['dtstart']); $this->assertSame('2015-04-27', $result['due']); $this->assertSame('MAPI:PidLidTaskActualEffort', $result['x-custom'][0]['identifier']); $this->assertSame(16, $result['x-custom'][0]['value']); $this->assertSame('MAPI:PidLidTaskEstimatedEffort', $result['x-custom'][1]['identifier']); $this->assertSame(20, $result['x-custom'][1]['value']); $this->assertSame(array('work1'), $result['categories']); $this->assertSame('Manager, Jane', $result['attendee'][0]['parameters']['cn']); $this->assertSame('TENTATIVE', $result['attendee'][0]['parameters']['partstat']); $this->assertSame('REQ-PARTICIPANT', $result['attendee'][0]['parameters']['role']); // $this->assertSame(true, $result['attendee'][0]['parameters']['rsvp']); $this->assertSame('mailto:manager%40example.org', $result['attendee'][0]['cal-address']); $this->assertSame('German, Mark', $result['organizer']['parameters']['cn']); $this->assertSame('mailto:mark.german%40example.org', $result['organizer']['cal-address']); + // alarm + $this->assertSame('DISPLAY', $result['valarm'][0]['properties']['action']); + $this->assertSame('-PT15M', $result['valarm'][0]['properties']['trigger']['duration']); + $data = array( 'PidLidTaskComplete' => true, 'PidLidTaskDateCompleted' => kolab_api_filter_mapistore_common::date_php2mapi('2015-04-20', true), 'PidLidTaskActualEffort' => 100, 'PidLidTaskEstimatedEffort' => 100, // @TODO: recurrence ); $result = $api->input($data); $this->assertSame('COMPLETED', $result['status']); $this->assertSame('MAPI:PidLidTaskDateCompleted', $result['x-custom'][0]['identifier']); $this->assertSame(13073961600.0, $result['x-custom'][0]['value']); } /** * Test input method with merge */ function test_input2() { $api = new kolab_api_filter_mapistore_task; $data = array( 'PidTagCreationTime' => kolab_api_filter_mapistore_common::date_php2mapi('2015-01-20T12:44:59Z'), 'PidTagLastModificationTime' => kolab_api_filter_mapistore_common::date_php2mapi('2015-01-22T12:30:17Z'), // 'PidTagMessageClass' => 'IPM.Task', 'PidTagSubject' => 'subject1', 'PidLidPercentComplete' => 0.66, 'PidTagBody' => 'body1', 'PidLidTaskStartDate' => kolab_api_filter_mapistore_common::date_php2mapi('2015-04-21', true), 'PidLidTaskDueDate' => kolab_api_filter_mapistore_common::date_php2mapi('2015-04-28', true), 'PidLidTaskActualEffort' => 21, 'PidLidTaskEstimatedEffort' => null, ); $result = $api->input($data, self::$original); self::$original = $result; $this->assertSame('subject1', $result['summary']); $this->assertSame('body1', $result['description']); $this->assertSame(66, $result['percent-complete']); $this->assertSame('2015-01-20T12:44:59Z', $result['created']); $this->assertSame('2015-01-22T12:30:17Z', $result['dtstamp']); $this->assertSame('2015-04-21', $result['dtstart']); $this->assertSame('2015-04-28', $result['due']); $this->assertSame('MAPI:PidLidTaskActualEffort', $result['x-custom'][0]['identifier']); $this->assertSame(21, $result['x-custom'][0]['value']); $this->assertCount(1, $result['x-custom']); } /** * Test map method */ function test_map() { $api = new kolab_api_filter_mapistore_task; $map = $api->map(); $this->assertInternalType('array', $map); $this->assertTrue(!empty($map)); } } diff --git a/tests/Unit/Input/Json/Event.php b/tests/Unit/Input/Json/Event.php index a8a4f2c..c80b5bd 100644 --- a/tests/Unit/Input/Json/Event.php +++ b/tests/Unit/Input/Json/Event.php @@ -1,175 +1,188 @@ input($data); } /** * Test expected exception in input method * * @expectedException kolab_api_exception * @expectedExceptionCode 422 */ function test_input_exception2() { $input = new kolab_api_input_json_event; $data = 'test'; $input->input($data); } /** * Test expected exception in input method * * @expectedException kolab_api_exception * @expectedExceptionCode 422 */ function test_input_exception3() { $input = new kolab_api_input_json_event; $data = array('test' => 'test'); // 'dtstamp' field is required $input->input($data); } /** * Test input method (convert JSON to internal format) */ function test_input() { $input = new kolab_api_input_json_event; $data = array( 'description' => 'description', 'summary' => 'summary', 'sequence' => 10, 'class' => 'PUBLIC', 'categories' => array('test'), 'created' => '2015-04-20T14:22:18Z', 'dtstamp' => '2015-04-21T00:00:00Z', 'status' => 'NEEDS-ACTION', 'dtstart' => '2014-01-01', 'dtend' => '2014-02-01', 'location' => null, 'priority' => 1, 'url' => 'url', 'attendee' => array( array( 'parameters' => array( 'cn' => 'Manager, Jane', 'partstat' => 'NEEDS-ACTION', 'role' => 'REQ-PARTICIPANT', 'rsvp' => true, ), 'cal-address' => 'mailto:%3Cjane.manager%40example.org%3E', ), ), 'organizer' => array( 'parameters' => array( 'cn' => 'Organizer', ), 'cal-address' => 'mailto:organizer%40example.org', ), 'exdate' => array( 'date' => array( '2015-06-05', '2015-06-12', ), ), 'rdate' => array( 'date' => array( '2015-06-15', '2015-06-22', ), ), + 'rrule' => array( + 'recur' => array( + 'freq' => 'MONTHLY', + 'bymonthday' => 5, + 'count' => 10, + 'interval' => 2, + ), + ), ); $input->input($data); $this->assertSame('description', $data['description']); $this->assertSame('summary', $data['title']); $this->assertSame('public', $data['sensitivity']); $this->assertSame(10, $data['sequence']); $this->assertSame(array('test'), $data['categories']); $this->assertSame(null, $data['location']); $this->assertSame(1, $data['priority']); $this->assertSame('url', $data['url']); $this->assertSame(kolab_api_input_json::to_datetime('2015-04-20T14:22:18Z')->format('c'), $data['created']->format('c')); $this->assertSame(kolab_api_input_json::to_datetime('2015-04-21T00:00:00Z')->format('c'), $data['changed']->format('c')); $this->assertSame(kolab_api_input_json::to_datetime('2014-01-01')->format('c'), $data['start']->format('c')); $this->assertSame(kolab_api_input_json::to_datetime('2014-02-01')->format('c'), $data['end']->format('c')); $this->assertSame('Manager, Jane', $data['attendees'][0]['name']); $this->assertSame('NEEDS-ACTION', $data['attendees'][0]['status']); $this->assertSame('REQ-PARTICIPANT', $data['attendees'][0]['role']); $this->assertSame(true, $data['attendees'][0]['rsvp']); $this->assertSame('jane.manager@example.org', $data['attendees'][0]['email']); $this->assertSame('Organizer', $data['organizer']['name']); $this->assertSame('organizer@example.org', $data['organizer']['email']); $this->assertSame('2015-06-05', $data['recurrence']['EXDATE'][0]); $this->assertSame('2015-06-12', $data['recurrence']['EXDATE'][1]); $this->assertSame('2015-06-15', $data['recurrence']['RDATE'][0]); $this->assertSame('2015-06-22', $data['recurrence']['RDATE'][1]); + $this->assertSame('MONTHLY', $data['recurrence']['FREQ']); + $this->assertSame(5, $data['recurrence']['BYMONTHDAY']); + $this->assertSame(10, $data['recurrence']['COUNT']); + $this->assertSame(2, $data['recurrence']['INTERVAL']); + self::$original = $data; } /** * Test input method with merging */ function test_input2() { $input = new kolab_api_input_json_event; $data = array( 'description' => 'description1', 'summary' => 'summary1', 'sequence' => 20, 'class' => 'PRIVATE', 'categories' => array('test1'), // 'created' => '2015-04-20T14:22:18Z', // 'dtstamp' => '2015-04-21T00:00:00Z', // 'status' => 'IN-PROCESS', 'dtstart' => '2014-01-11', 'dtend' => '2014-02-11', 'location' => 'location1', 'priority' => 2, 'url' => 'url1', ); $input->input($data, self::$original); $this->assertSame('description1', $data['description']); $this->assertSame('summary1', $data['title']); $this->assertSame('private', $data['sensitivity']); $this->assertSame(20, $data['sequence']); $this->assertSame(array('test1'), $data['categories']); $this->assertSame('location1', $data['location']); $this->assertSame(2, $data['priority']); $this->assertSame('url1', $data['url']); // $this->assertSame(kolab_api_input_json::to_datetime('2015-04-20T14:22:18Z')->format('c'), $data['created']->format('c')); // $this->assertSame(kolab_api_input_json::to_datetime('2015-04-21T00:00:00Z')->format('c'), $data['changed']->format('c')); $this->assertSame(kolab_api_input_json::to_datetime('2014-01-11')->format('c'), $data['start']->format('c')); $this->assertSame(kolab_api_input_json::to_datetime('2014-02-11')->format('c'), $data['end']->format('c')); } } diff --git a/tests/lib/kolab_api_backend.php b/tests/lib/kolab_api_backend.php index ece169c..8f1c756 100644 --- a/tests/lib/kolab_api_backend.php +++ b/tests/lib/kolab_api_backend.php @@ -1,1034 +1,1034 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_backend { /** * Singleton instace of kolab_api_backend * * @var kolab_api_backend */ static protected $instance; public $delimiter = '/'; public $username = 'user@example.org'; public $storage; public $user; public $db = array(); protected $folder = array(); protected $data = array(); /** * This implements the 'singleton' design pattern * * @return kolab_api_backend The one and only instance */ static function get_instance() { if (!self::$instance) { self::$instance = new kolab_api_backend; self::$instance->startup(); // init AFTER object was linked with self::$instance } return self::$instance; } /** * Class initialization */ public function startup() { $api = kolab_api::get_instance(); $db_file = $api->config->get('temp_dir') . '/tests.db'; if (file_exists($db_file)) { $db = file_get_contents($db_file); $this->db = unserialize($db); } $json = file_get_contents(__DIR__ . '/../data/data.json'); $this->data = json_decode($json, true); $this->folders = $this->parse_folders_list($this->data['folders']); if (!array_key_exists('tags', $this->db)) { $this->db['tags'] = $this->data['tags']; } $this->user = new kolab_api_user; $this->storage = $this; } /** * Authenticate a user * * @param string Username * @param string Password * * @return bool */ public function authenticate($username, $password) { return true; } /** * Get list of folders * * @param string $type Folder type * * @return array|bool List of folders, False on backend failure */ public function folders_list($type = null) { return array_values($this->folders); } /** * Returns folder type * * @param string $uid Folder unique identifier * @param string $with_suffix Enable to not remove the subtype * * @return string Folder type */ public function folder_type($uid, $with_suffix = false) { $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } $type = $folder['type'] ?: 'mail'; if (!$with_suffix) { list($type, ) = explode('.', $type); } return $type; } /** * Returns objects in a folder * * @param string $uid Folder unique identifier * * @return array Objects (of type kolab_api_mail or array) * @throws kolab_api_exception */ public function objects_list($uid) { $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } $result = array(); $is_mail = empty($folder['type']) || preg_match('/^mail/', $folder['type']); foreach ((array) $folder['items'] as $id) { $object = $this->object_get($uid, $id); if ($is_mail) { $object = new kolab_api_message($object->headers, array('is_header' => true)); } $result[] = $object; } return $result; } /** * Counts objects in a folder * * @param string $uid Folder unique identifier * * @return int Objects count * @throws kolab_api_exception */ public function objects_count($uid) { $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } return count($folder['items']); } /** * Delete objects in a folder * * @param string $uid Folder unique identifier * @param string|array $set List of object IDs or "*" for all * * @throws kolab_api_exception */ public function objects_delete($uid, $set) { $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } if ($set === '*') { foreach ((array) $this->folders[$uid]['items'] as $i) { unset($this->db['messages'][$i]); } $this->folders[$uid]['items'] = array(); $this->db['items'][$uid] = array(); } else { $this->folders[$uid]['items'] = array_values(array_diff($this->folders[$uid]['items'], $set)); foreach ($set as $i) { unset($this->db['items'][$uid][$i]); unset($this->db['messages'][$i]); } } $this->db['folders'][$uid]['items'] = $this->folders[$uid]['items']; $this->save_db(); } /** * Move objects into another folder * * @param string $uid Folder unique identifier * @param string $target_uid Target folder unique identifier * @param string|array $set List of object IDs or "*" for all * * @throws kolab_api_exception */ public function objects_move($uid, $target_uid, $set) { $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } $target = $this->folders[$target_uid]; if (!$target) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } if ($set === "*") { $set = $this->folders[$uid]['items']; } // @TODO: we should check if all objects from the set exist $diff = array_values(array_diff($this->folders[$uid]['items'], $set)); $this->folders[$uid]['items'] = $diff; $this->db['folders'][$uid]['items'] = $diff; $diff = array_values(array_merge((array) $this->folders[$target_uid]['items'], $set)); $this->folders[$target_uid]['items'] = $diff; $this->db['folders'][$target_uid]['items'] = $diff; foreach ($set as $i) { if ($this->db['items'][$uid][$i]) { $this->db['items'][$target_uid][$i] = $this->db['items'][$uid][$i]; unset($this->db['items'][$uid][$i]); } } $this->save_db(); } /** * Get object data * * @param string $folder_uid Folder unique identifier * @param string $uid Object identifier * * @return kolab_api_mail|array Object data * @throws kolab_api_exception */ public function object_get($folder_uid, $uid) { $folder = $this->folders[$folder_uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } if (!in_array($uid, (array) $folder['items'])) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } if ($data = $this->db['items'][$folder_uid][$uid]) { return $data; } list($type,) = explode('.', $folder['type']); $file = $this->get_file_content($uid, $type); if (empty($file)) { throw new kolab_api_exception(kolab_api_exception::SERVER_ERROR); } // get message content and parse it $file = str_replace("\r?\n", "\r\n", $file); $params = array('uid' => $uid, 'folder' => $folder_uid); $object = new kolab_api_message($file, $params); // get assigned tag-relations $tags = array(); foreach ($this->db['tags'] as $tag_name => $tag) { if (in_array($uid, (array) $tag['members'])) { $tags[] = $tag_name; } } if ($type != 'mail') { $object = $object->to_array($type); $object['categories'] = array_unique(array_merge($tags, (array) $object['categories'])); } else { $object = new kolab_api_message($object); $object->set_categories($tags); } return $object; } /** * Create an object * * @param string $folder_uid Folder unique identifier * @param string $data Object data * @param string $type Object type * * @throws kolab_api_exception */ public function object_create($folder_uid, $data, $type) { $folder = $this->folders[$folder_uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } /* if (strpos($folder['type'], $type) !== 0) { throw new kolab_api_exception(kolab_api_exception::INVALID_REQUEST); } */ $uid = microtime(true); if (is_array($data)) { $categories = $data['categories']; $data['uid'] = $uid; $this->db['items'][$folder_uid][$uid] = $data; } else { $categories = $data->categories; $uid = $data->save($folder['fullpath']); } if (!empty($categories)) { foreach ($categories as $cat) { if (!$this->db['tags'][$cat]) { $this->db['tags'][$cat] = array(); } if (!in_array($uid, (array) $this->db['tags'][$cat]['members'])) { $this->db['tags'][$cat]['members'][] = $uid; } } } $this->folders[$folder_uid]['items'][] = $uid; $this->db['folders'][$folder_uid]['items'] = $this->folders[$folder_uid]['items']; $this->save_db(); return $uid; } /** * Update an object * * @param string $folder_uid Folder unique identifier * @param string $data Object data * @param string $type Object type * * @throws kolab_api_exception */ public function object_update($folder_uid, $data, $type) { $folder = $this->folders[$folder_uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } /* if (strpos($folder['type'], $type) !== 0) { throw new kolab_api_exception(kolab_api_exception::INVALID_REQUEST); } */ // kolab object if (is_array($data)) { $uid = $data['uid']; $categories = $data['categories']; // remove _formatobj which is problematic in serialize/unserialize unset($data['_formatobj']); $this->db['items'][$folder_uid][$uid] = $data; } // email message else { $old_uid = $data->uid; $categories = $data->categories; $uid = $data->save($folder['fullpath']); $this->folders[$folder_uid]['items'][] = $uid; $this->db['folders'][$folder_uid]['items'] = $this->folders[$folder_uid]['items']; } // remove old tag assignments foreach ($this->db['tags'] as $tag_name => $tag) { if (($idx = array_search($old_uid ?: $uid, (array) $this->db['tags'][$tag_name]['members'])) !== false) { unset($this->db['tags'][$tag_name]['members'][$idx]); } } // assign new tags - foreach ($categories as $tag) { + foreach ((array) $categories as $tag) { if (!$this->db['tags'][$tag]) { $this->db['tags'][$tag] = array(); } $this->db['tags'][$tag]['members'][] = $uid; } $this->save_db(); return $uid; } /** * Get attachment body * * @param mixed $object Object data (from self::object_get()) * @param string $part_id Attachment part identifier * @param mixed $mode NULL to return a string, -1 to print body * or file pointer to save the body into * * @return string Attachment body if $mode=null * @throws kolab_api_exception */ public function attachment_get($object, $part_id, $mode = null) { $msg_uid = is_array($object) ? $object['uid'] : $object->uid; // object is a mail message if (!($object instanceof kolab_api_message)) { $object = $object['_message']; } $body = $object->get_part_body($part_id); if (!$mode) { return $body; } else if ($mode === -1) { echo $body; } } /** * Delete an attachment from the message * * @param mixed $object Object data (from self::object_get()) * @param string $id Attachment identifier * * @return string Message/object UID * @throws kolab_api_exception */ public function attachment_delete($object, $id) { $msg_uid = is_array($object) ? $object['uid'] : $object->uid; $key = $msg_uid . ":" . $part_id; // object is a mail message if (!($object instanceof kolab_api_message)) { $object = $object['_message']; } if ($object->get_part_body($id) === null) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } $uid = $object->attachment_delete($id); // change UID only for mail messages if (!is_numeric($msg_uid)) { $this->db['messages'][$msg_uid] = $this->db['messages'][$uid]; unset($this->db['messages'][$uid]); $this->save_db(); $uid = $msg_uid; } if ($msg_uid != $uid) { $folder_uid = $object->folder; $this->folders[$folder_uid]['items'][] = $uid; $this->db['folders'][$folder_uid]['items'] = $this->folders[$folder_uid]['items']; $this->save_db(); } return $uid; } /** * Create an attachment and add to a message/object * * @param mixed $object Object data (from self::object_get()) * @param rcube_message_part $attachment Attachment data * * @return string Message/object UID * @throws kolab_api_exception */ public function attachment_create($object, $attachment) { $msg_uid = is_array($object) ? $object['uid'] : $object->uid; // object is a mail message if (!($object instanceof kolab_api_message)) { $object = $object['_message']; } $uid = $object->attachment_add($attachment); $folder_uid = $object->folder; // change UID only for mail messages if (!is_numeric($msg_uid)) { $this->db['messages'][$msg_uid] = $this->db['messages'][$uid]; unset($this->db['messages'][$uid]); $params = array('uid' => $msg_uid, 'folder' => $folder_uid); $object = new kolab_api_message(base64_decode($this->db['messages'][$msg_uid]), $params); $object = $object->to_array($this->folders[$folder_uid]['type']); // $object['categories'] = $tags; unset($object['_formatobj']); $this->db['items'][$folder_uid][$msg_uid] = $object; $this->save_db(); $uid = $msg_uid; } if ($msg_uid != $uid) { $this->folders[$folder_uid]['items'][] = $uid; $this->db['folders'][$folder_uid]['items'] = $this->folders[$folder_uid]['items']; $this->save_db(); } return $uid; } /** * Update an attachment in a message/object * * @param mixed $object Object data (from self::object_get()) * @param rcube_message_part $attachment Attachment data * * @return string Message/object UID * @throws kolab_api_exception */ public function attachment_update($object, $attachment) { $msg_uid = is_array($object) ? $object['uid'] : $object->uid; // object is a mail message if (!($object instanceof kolab_api_message)) { $object = $object['_message']; } $uid = $object->attachment_update($attachment); $folder_uid = $object->folder; // change UID only for mail messages if (!is_numeric($msg_uid)) { $this->db['messages'][$msg_uid] = $this->db['messages'][$uid]; unset($this->db['messages'][$uid]); $params = array('uid' => $msg_uid, 'folder' => $folder_uid); $object = new kolab_api_message(base64_decode($this->db['messages'][$msg_uid]), $params); $object = $object->to_array($this->folders[$folder_uid]['type']); // $object['categories'] = $tags; unset($object['_formatobj']); $this->db['items'][$folder_uid][$msg_uid] = $object; $this->save_db(); $uid = $msg_uid; } if ($msg_uid != $uid) { $this->folders[$folder_uid]['items'][] = $uid; $this->db['folders'][$folder_uid]['items'] = $this->folders[$folder_uid]['items']; $this->save_db(); } return $uid; } /** * Creates a folder * * @param string $name Folder name (UTF-8) * @param string $parent Parent folder identifier * @param string $type Folder type * * @return bool Folder identifier on success */ public function folder_create($name, $parent = null, $type = null) { $folder = $name; if ($parent) { $parent_folder = $this->folders[$parent]; if (!$parent_folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } $folder = $parent_folder['fullpath'] . $this->delimiter . $folder; } $uid = kolab_api_tests::folder_uid($folder, false); // check if folder exists if ($this->folders[$uid]) { throw new kolab_api_exception(kolab_api_exception::SERVER_ERROR); } $this->folders[$uid] = array( 'name' => $name, 'fullpath' => $folder, 'parent' => $parent ? kolab_api_tests::folder_uid($parent, false) : null, 'uid' => $uid, 'type' => $type ? $type : 'mail', ); $this->db['folders'][$uid] = $this->folders[$uid]; $this->save_db(); return $uid; } /** * Updates a folder * * @param string $uid Folder identifier * @param array $updates Updates (array with keys type, subscribed, active) * * @throws kolab_api_exception */ public function folder_update($uid, $updates) { $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } foreach ($updates as $idx => $value) { $this->db['folders'][$uid][$idx] = $value; $this->folders[$uid][$idx] = $value; } $this->save_db(); } /** * Renames/moves a folder * * @param string $old_name Folder name (UTF8) * @param string $new_name New folder name (UTF8) * * @throws kolab_api_exception */ public function folder_rename($old_name, $new_name) { $old_uid = kolab_api_tests::folder_uid($old_name, false); $new_uid = kolab_api_tests::folder_uid($new_name, false); $folder = $this->folders[$old_uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } if ($this->folders[$new_uid]) { throw new kolab_api_exception(kolab_api_exception::SERVER_ERROR); } $path = explode($this->delimiter, $new_name); $folder['fullpath'] = $new_name; $folder['name'] = array_pop($path); unset($this->folders[$old_uid]); $this->folders[$new_uid] = $folder; $this->db['folders'][$new_uid] = $folder; $this->db['deleted'][] = $old_uid; $this->save_db(); } /** * Deletes folder * * @param string $uid Folder UID * * @return bool True on success, False on failure * @throws kolab_api_exception */ public function folder_delete($uid) { $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } unset($this->folders[$uid]); $this->db['deleted'][] = $uid; $this->save_db(); } /** * Folder info * * @param string $uid Folder UID * * @return array Folder information * @throws kolab_api_exception */ public function folder_info($uid) { $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } // some info is not very interesting here ;) unset($folder['items']); return $folder; } /** * Returns IMAP folder name with full path * * @param string $uid Folder identifier * * @return string Folder full path (UTF-8) */ public function folder_uid2path($uid) { if ($uid === null || $uid === '') { throw new kolab_api_exception(kolab_api_exception::SERVER_ERROR); } $folder = $this->folders[$uid]; if (!$folder) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } return $folder['fullpath']; } /** * Parse folders list into API format */ protected function parse_folders_list($list) { $folders = array(); foreach ($list as $path => $folder) { $uid = kolab_api_tests::folder_uid($path, false); if (!empty($this->db['deleted']) && in_array($uid, $this->db['deleted'])) { continue; } if (strpos($path, $this->delimiter)) { $list = explode($this->delimiter, $path); $name = array_pop($list); $parent = implode($this->delimiter, $list); $parent_id = kolab_api_tests::folder_uid($parent, false); } else { $parent_id = null; $name = $path; } $data = array( 'name' => $name, 'fullpath' => $path, 'parent' => $parent_id, 'uid' => $uid, ); if (!empty($this->db['folders']) && !empty($this->db['folders'][$uid])) { $data = array_merge($data, $this->db['folders'][$uid]); } $folders[$uid] = array_merge($folder, $data); } foreach ((array) $this->db['folders'] as $uid => $folder) { if (!$folders[$uid]) { $folders[$uid] = $folder; } } // sort folders uasort($folders, array($this, 'sort_folder_comparator')); return $folders; } /** * Callback for uasort() that implements correct * locale-aware case-sensitive sorting */ protected function sort_folder_comparator($str1, $str2) { $path1 = explode($this->delimiter, $str1['fullpath']); $path2 = explode($this->delimiter, $str2['fullpath']); foreach ($path1 as $idx => $folder1) { $folder2 = $path2[$idx]; if ($folder1 === $folder2) { continue; } return strcoll($folder1, $folder2); } } /** * Save current database state */ public function save_db() { $api = kolab_api::get_instance(); $db_file = $api->config->get('temp_dir') . '/tests.db'; $db = serialize($this->db); file_put_contents($db_file, $db); } /** * Wrapper for rcube_imap::set_flag() */ public function set_flag($uid, $flag) { $flag = strtoupper($flag); $folder_uid = $this->folder_uid($folder); $flags = (array) $this->db['flags'][$uid]; if (strpos($flag, 'UN') === 0) { $flag = substr($flag, 3); $flags = array_values(array_diff($flags, array($flag))); } else { $flags[] = $flag; $flags = array_unique($flags); } $this->db['flags'][$uid] = $flags; $this->save_db(); return true; } /** * Wrapper for rcube_imap::save_message() */ public function save_message($folder, $streams) { $folder_uid = $this->folder_uid($folder); $uid = '3' . count($this->db['messages']) . preg_replace('/^[0-9]+\./', '', microtime(true)); $content = ''; foreach ($streams as $stream) { rewind($stream); $content .= stream_get_contents($stream); } $this->db['messages'][$uid] = base64_encode($content); $this->save_db(); return $uid; } /** * Wrapper for rcube_imap::delete_message() */ public function delete_message($uid, $folder) { $folder_uid = $this->folder_uid($folder); $this->folders[$folder_uid]['items'] = array_values(array_diff((array)$this->folders[$folder_uid]['items'], array($uid))); unset($this->db['items'][$folder_uid][$uid]); unset($this->db['messages'][$uid]); $this->db['folders'][$folder_uid]['items'] = $this->folders[$folder_uid]['items']; $this->save_db(); return true; } /** * Wrapper for rcube_imap::get_raw_body */ public function get_raw_body($uid, $fp = null, $part = null) { $file = $this->get_file_content($uid); $file = explode("\r\n\r\n", $file, 2); if (stripos($part, 'TEXT') !== false) { $body = $file[1]; if (preg_match('/^([0-9]+)/', $part, $m)) { if (preg_match('/boundary="?([^"]+)"?/', $file[0], $mm)) { $parts = explode('--' . $mm[1], $body); $parts = explode("\r\n\r\n", $parts[$m[1]], 2); $body = $parts[1]; } else { $body = ''; } } } if ($fp) { fwrite($fp, $body); return true; } else { return $body; } } /** * Wrapper for rcube_imap::get_raw_headers */ public function get_raw_headers($uid) { $file = $this->get_file_content($uid); $file = explode("\r\n\r\n", $file, 2); return $file[0]; } /** * Wrapper for rcube_imap::set_folder */ public function set_folder($folder) { // do nothing } /** * Find folder UID by its name */ protected function folder_uid($name) { foreach ($this->folders as $uid => $folder) { if ($folder['fullpath'] == $name) { return $uid; } } } /** * Get sample message from tests/data dir */ protected function get_file_content($uid, $type = null) { if ($file = $this->db['messages'][$uid]) { $file = base64_decode($file); } else { if (empty($type)) { foreach (array('mail', 'event', 'task', 'note', 'contact') as $t) { $file = __DIR__ . '/../data/' . $t . '/' . $uid; if (file_exists($file)) { $type = $t; break; } } } $file = file_get_contents(__DIR__ . '/../data/' . $type . '/' . $uid); } return $file; } } /** * Dummy class imitating rcube_user */ class kolab_api_user { public function get_username($type) { $api = kolab_api_backend::get_instance(); list($local, $domain) = explode('@', $api->username); if ($type == 'domain') { return $domain; } else if ($type == 'local') { return $local; } return $api->username; } public function get_user_id() { return 10; } public function get_identity() { return array( 'email' => 'user@example.org', 'name' => 'Test User', ); } } diff --git a/tests/lib/kolab_api_tests.php b/tests/lib/kolab_api_tests.php index 2603212..01d4041 100644 --- a/tests/lib/kolab_api_tests.php +++ b/tests/lib/kolab_api_tests.php @@ -1,418 +1,420 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_tests { static $items_map; static $folders_map; static $db; /** * Reset backend state */ public static function reset_backend() { $rcube = rcube::get_instance(); $temp_dir = $rcube->config->get('temp_dir'); $filename = $temp_dir . '/tests.db'; if (file_exists($filename)) { unlink($filename); } $username = $rcube->config->get('tests_username'); $password = $rcube->config->get('tests_password'); if (!$username) { return; } $authenticated = self::login($username, $password); if (!$authenticated) { throw new Exception("IMAP login failed for user $username"); } // get all existing folders $imap = $rcube->get_storage(); $old_folders = $imap->list_folders('', '*'); $old_subscribed = $imap->list_folders_subscribed('', '*'); // get configured folders $json = file_get_contents(__DIR__ . '/../data/data.json'); $data = json_decode($json, true); $items = array(); $uids = array(); // initialize/update content in existing folders // create configured folders if they do not exists foreach ($data['folders'] as $folder_name => $folder) { if (($idx = array_search($folder_name, $old_folders)) !== false) { // cleanup messages in the folder $imap->delete_message('*', $folder_name); unset($old_folders[$idx]); // make sure it's subscribed if (!in_array($folder_name, $old_subscribed)) { $imap->subscribe($folder_name); } } else { // create the folder $imap->create_folder($folder_name, true); } // set folder type kolab_storage::set_folder_type($folder_name, $folder['type']); list($type, ) = explode('.', $folder['type']); // append messages foreach ((array) $folder['items'] as $uid) { $file = file_get_contents(__DIR__ . "/../data/$type/$uid"); // replace member message references if ($uid == '99-99-99-99') { $repl = urlencode($username) . '/INBOX/' . $items[1]; $file = str_replace('mark.german%40example.org/INBOX/1', $repl, $file); } $res = $imap->save_message($folder_name, $file); if (is_numeric($uid)) { $items[$uid] = $res; } } } // remove extra folders $deleted = array(); foreach ($old_folders as $folder) { // ...but only personal if ($imap->folder_namespace($folder) == 'personal') { $path = explode('/', $folder); while (array_pop($path) !== null) { if (in_array(implode('/', $path), $deleted)) { $deleted[] = $folder; continue 2; } } if (!$imap->delete_folder($folder)) { throw new Exception("Failed removing '$folder'"); } $deleted[] = $folder; } else { } } // get folder UIDs map $uid_keys = array(kolab_storage::UID_KEY_CYRUS); // get folder identifiers $metadata = $imap->get_metadata('*', $uid_keys); if (!is_array($metadata)) { throw new Exception("Failed to get folders metadata"); } foreach ($metadata as $folder => $meta) { $uids[$folder] = $meta[kolab_storage::UID_KEY_CYRUS]; } self::$items_map = $items; self::$folders_map = $uids; } /** * Initialize testing environment */ public static function init() { $rcube = rcube::get_instance(); // If tests_username is set we use real Kolab server // otherwise use dummy backend class which emulates a real server if (!$rcube->config->get('tests_username')) { // Load backend wrappers for tests // @TODO: maybe we could replace kolab_storage and rcube_imap instead? require_once __DIR__ . '/kolab_api_backend.php'; // Message wrapper for unit tests require_once __DIR__ . '/kolab_api_message.php'; // extend include path with kolab_format/kolab_storage classes $include_path = __DIR__ . '/../../lib/ext/plugins/libkolab/lib' . PATH_SEPARATOR . ini_get('include_path'); set_include_path($include_path); + + require_once __DIR__ . '/../../lib/ext/plugins/libcalendaring/libcalendaring.php'; } // load HTTP_Request2 wrapper for functional/integration tests require_once __DIR__ . '/kolab_api_request.php'; } /** * Initializes kolab_api_request object * * @param string Accepted response type (xml|json) * * @return kolab_api_request Request object */ public static function get_request($type, $suffix = '') { $rcube = rcube::get_instance(); $base_uri = $rcube->config->get('tests_uri', 'http://localhost/copenhagen-tests'); $username = $rcube->config->get('tests_username', 'test@example.org'); $password = $rcube->config->get('tests_password', 'test@example.org'); if ($suffix) { $base_uri .= $suffix; } $request = new kolab_api_request($base_uri, $username, $password); // set expected response type $request->set_header('Accept', $type == 'xml' ? 'application/xml' : 'application/json'); return $request; } /** * Get data object */ public static function get_data($uid, $folder_name, $type, $format = '', &$context = null) { require_once __DIR__ . '/kolab_api_message.php'; $file = file_get_contents(__DIR__ . "/../data/$type/$uid"); $folder_uid = self::folder_uid($folder_name, false); // get message content and parse it $file = str_replace("\r?\n", "\r\n", $file); $params = array('uid' => $uid, 'folder' => $folder_uid); $object = new kolab_api_message($file, $params); if (empty(self::$db)) { $json = file_get_contents(__DIR__ . '/../data/data.json'); self::$db = json_decode($json, true); } // get assigned tag-relations $tags = array(); foreach (self::$db['tags'] as $tag_name => $tag) { if (in_array($uid, (array) $tag['members'])) { $tags[] = $tag_name; } } if ($type != 'mail') { $object = $object->to_array($type); $object['categories'] = $tags; } else { $object = new kolab_api_message($object); $object->set_categories($tags); } $context = array( 'object' => $object, 'folder_uid' => $folder_uid, 'object_uid' => $uid, ); if ($format) { $model = self::get_output_class($format, $type); $object = $model->element($object); } return $object; } public static function get_output_class($format, $type) { // fake GET request to have proper API class in kolab_api::get_instance $_GET['request'] = "{$type}s"; $output = "kolab_api_output_{$format}"; $class = "{$output}_{$type}"; $output = new $output(kolab_api::get_instance()); $model = new $class($output); return $model; } /** * Get folder UID by name */ public static function folder_uid($name, $api_test = true) { if ($api_test && !empty(self::$folders_map)) { if (self::$folders_map[$name]) { return self::$folders_map[$name]; } // it maybe is a newly created folder? check the metadata again $rcube = rcube::get_instance(); $imap = $rcube->get_storage(); $uid_keys = array(kolab_storage::UID_KEY_CYRUS); $metadata = $imap->get_metadata($name, $uid_keys); if ($uid = $metadata[$name][kolab_storage::UID_KEY_CYRUS]) { return self::$folders_map[$name] = $uid; } } return md5($name); } /** * Get message UID */ public static function msg_uid($uid, $api_test = true) { if ($uid && $api_test && !empty(self::$items_map)) { if (self::$items_map[$uid]) { return self::$items_map[$uid]; } } return $uid; } /** * Build MAPI object identifier */ public static function mapi_uid($folder_name, $api_test, $msg_uid, $attachment_uid = null) { $folder_uid = self::folder_uid($folder_name, $api_test); $msg_uid = self::msg_uid($msg_uid, $api_test); return kolab_api_filter_mapistore::uid_encode($folder_uid, $msg_uid, $attachment_uid); } protected static function login($username, $password) { $rcube = rcube::get_instance(); $login_lc = $rcube->config->get('login_lc'); $host = $rcube->config->get('default_host'); $default_port = $rcube->config->get('default_port', 143); $rcube->storage = null; $storage = $rcube->get_storage(); // parse $host $a_host = parse_url($host); if ($a_host['host']) { $host = $a_host['host']; $ssl = (isset($a_host['scheme']) && in_array($a_host['scheme'], array('ssl','imaps','tls'))) ? $a_host['scheme'] : null; if (!empty($a_host['port'])) { $port = $a_host['port']; } else if ($ssl && $ssl != 'tls' && (!$default_port || $default_port == 143)) { $port = 993; } } if (!$port) { $port = $default_port; } // Convert username to lowercase. If storage backend // is case-insensitive we need to store always the same username if ($login_lc) { if ($login_lc == 2 || $login_lc === true) { $username = mb_strtolower($username); } else if (strpos($username, '@')) { // lowercase domain name list($local, $domain) = explode('@', $username); $username = $local . '@' . mb_strtolower($domain); } } // Here we need IDNA ASCII // Only rcube_contacts class is using domain names in Unicode $host = rcube_utils::idn_to_ascii($host); $username = rcube_utils::idn_to_ascii($username); // user already registered? if ($user = rcube_user::query($username, $host)) { $username = $user->data['username']; } // authenticate user in IMAP if (!$storage->connect($host, $username, $password, $port, $ssl)) { throw new Exception("Unable to connect to IMAP"); } // No user in database, but IMAP auth works if (!is_object($user)) { if ($rcube->config->get('auto_create_user')) { // create a new user record $user = rcube_user::create($username, $host); if (!$user) { throw new Exception("Failed to create a user record"); } } else { throw new Exception("Access denied for new user $username. 'auto_create_user' is disabled"); } } // overwrite config with user preferences $rcube->user = $user; $rcube->config->set_user_prefs((array)$user->get_prefs()); /* $_SESSION['user_id'] = $user->ID; $_SESSION['username'] = $user->data['username']; $_SESSION['storage_host'] = $host; $_SESSION['storage_port'] = $port; $_SESSION['storage_ssl'] = $ssl; $_SESSION['password'] = $rcube->encrypt($password); $_SESSION['login_time'] = time(); */ setlocale(LC_ALL, 'en_US.utf8', 'en_US.UTF-8'); // clear the cache $storage->clear_cache('mailboxes', true); // to clear correctly the cache index in testing environments // (where we call self::reset_backend() many times in one go) // we need to also close() the cache if ($ctype = $rcube->config->get('imap_cache')) { $cache = $rcube->get_cache('IMAP', $ctype, $rcube->config->get('imap_cache_ttl', '10d')); $cache->close(); } // clear also libkolab cache $db = $rcube->get_dbh(); $db->query('DELETE FROM `kolab_folders`'); return true; } }