diff --git a/lib/filter/mapistore.php b/lib/filter/mapistore.php index a0f214e..949368e 100644 --- a/lib/filter/mapistore.php +++ b/lib/filter/mapistore.php @@ -1,629 +1,642 @@ | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ class kolab_api_filter_mapistore extends kolab_api_filter { protected $input; protected $attrs_filter; // Common properties [MS-OXCMSG] protected static $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' => '', // @TODO // 'PidTagRecordKey' => '', // 'PidTagSearchKey' => '', 'PidNameKeywords' => 'categories', ); /** * Modify request path * * @param array (Exploded) request path */ public function path(&$path) { // handle differences between OpenChange API and Kolab API // here we do only very basic modifications, just to be able // to select apprioprate api action class if ($path[0] == 'calendars') { $path[0] = 'events'; } } /** * Executed before every api action * * @param kolab_api_input Request */ public function input(&$input) { $this->input = $input; $this->common_action = !in_array($input->action, array('folders', 'info')); // handle differences between OpenChange API and Kolab API switch ($input->action) { case 'folders': // in OpenChange folders/1/folders means get all folders if ($input->method == 'GET' && $input->path[0] === '1' && $input->path[1] == 'folders') { $input->path = array(); $type = 'folder'; } // in OpenChange folders/0/folders means get the hierarchy of the NON IPM Subtree // we should ignore/send empty request else if ($input->method == 'GET' && $input->path[0] === '0' && $input->path[1] == 'folders') { // @TODO throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } else if ($input->path[1] == 'messages') { $input->path[1] = 'objects'; if ($input->args['properties']) { $type = $input->api->backend->folder_type($input->path[0]); list($type, ) = explode('.', $type); } } else if ($input->path[1] == 'deletemessages') { $input->path[1] = 'deleteobjects'; } // properties filter, map MAPI attribute names to Kolab attributes if ($type && $input->args['properties']) { $this->attrs_filter = explode(',', $this->input->args['properties']); $properties = $this->attributes_filter($this->attrs_filter, $type); $input->args['properties'] = implode(',', $properties); } break; case 'notes': // Notes do not have attachments in Exchange if ($input->path[1] === 'attachments' || count($this->path) > 2) { throw new kolab_api_exception(kolab_api_exception::NOT_FOUND); } break; } // convert / to // or /// if ($this->common_action && ($uid = $input->path[0])) { list($folder, $msg, $attach) = self::uid_decode($uid); $path = array($folder, $msg); if ($attach) { $path[] = $attach; } array_splice($input->path, 0, 1, $path); } // convert parent_id into path on object create request if ($input->method == 'POST' && $this->common_action && !count($input->path)) { $data = $input->input(null, true); if ($data['parent_id']) { $input->path[0] = $data['parent_id']; } else { throw new kolab_api_exception(kolab_api_exception::INVALID_REQUEST); } } // convert parent_id into path on object update request else if ($input->method == 'PUT' && $folder && count($input->path) == 2) { $data = $input->input(null, true); if ($data['parent_id'] && $data['parent_id'] != $folder) { $this->parent_change_handler($data); } } } /** * Executed when parsing request body * * @param string Request data * @param string Expected object type * @param string Original object data (set on update requests) */ public function input_body(&$data, $type = null, $original_object = null) { $input = $this->input; // handle differences between OpenChange API and Kolab API // Note: input->path is already modified by input() and path() above switch ($input->action) { case 'folders': // folders//deletemessages input if ($input->path[1] == 'deleteobjects') { // Kolab API expects just a list of identifiers, I.e.: // [{"id": "1"}, {"id": "2"}] => ["1", "2"] foreach ((array) $data as $idx => $element) { $data[$idx] = $element['id']; } } break; } switch ($type) { case 'attachment': case 'event': case 'note': case 'task': case 'contact': case 'mail': case 'folder': $model = $this->get_model_class($type); $data = $model->input($data, $original_object); break; } } /** * Apply filter on output data * * @param array Result data * @param string Object type * @param array Context (folder_uid, object_uid, object) * @param array Optional attributes filter */ public function output(&$result, $type, $context = null, $attrs_filter = array()) { // handle differences between OpenChange API and Kolab API $model = $this->get_model_class($type); if (!empty($this->attrs_filter)) { $attrs_filter = array_combine($this->attrs_filter, $this->attrs_filter); } else if (!empty($attrs_filter)) { $attrs_filter = $this->attributes_filter($attrs_filter, $type, true); $attrs_filter = array_combine($attrs_filter, $attrs_filter); } foreach ($result as $idx => $data) { if ($filtered = $model->output($data, $context)) { // apply properties filter (again) if (!empty($attrs_filter)) { $filtered = array_intersect_key($filtered, $attrs_filter); } $result[$idx] = $filtered; } else { unset($result[$idx]); $unset = true; } } if ($unset) { $result = array_values($result); } } /** * Executed for response headers * * @param array Response headers */ public function headers(&$headers) { // handle differences between OpenChange API and Kolab API foreach ($headers as $name => $value) { switch ($name) { case 'X-Count': $headers['X-mapistore-rowcount'] = $value; unset($headers[$name]); break; } } } /** * Executed for empty response status * * @param int Status code */ public function send_status(&$status) { // handle differences between OpenChange API and Kolab API if ($this->input->method == 'PUT' && !in_array($input->action, array('info'))) { // Mapistore expects 204 on object updates // however, we'd like to send modified UID of the object sometimes // $status = kolab_api_output::STATUS_EMPTY; } } /** * 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; } /** * Converts kolab identifiers describind the object into * MAPI identifier that can be easily used in URL. * * @param string Folder UID * @param string Object UID * @param string Optional attachment identifier * * @return string Object identifier */ public static function uid_encode($folder_uid, $msg_uid, $attach_id = null) { $result = array($folder_uid, $msg_uid); if ($attach_id) { $result[] = $attach_id; } $result = array_map(array('kolab_api_filter_mapistore', 'uid_encode_item'), $result); return implode('.', $result); } /** * Converts back the MAPI identifier into kolab folder/object/attachment IDs * * @param string Object identifier * * @return array Object identifiers */ public static function uid_decode($uid) { $result = explode('.', $uid); $result = array_map(array('kolab_api_filter_mapistore', 'uid_decode_item'), $result); return $result; } /** * Encodes UID element */ protected static function uid_encode_item($str) { $fn = function($match) { return '_' . ord($match[1]); }; $str = preg_replace_callback('/([^0-9a-zA-Z-])/', $fn, $str); return $str; } /** * Decodes UID element */ protected static function uid_decode_item($str) { $fn = function($match) { return chr($match[1]); }; $str = preg_replace_callback('/_([0-9]{2})/', $fn, $str); return $str; } /** * Parse common properties in object data (convert into MAPI format) */ public static function parse_common_props(&$result, $data, $context = array()) { if (empty($context)) { // @TODO: throw exception? return; } if ($data['uid'] && $context['folder_uid']) { $result['id'] = self::uid_encode($context['folder_uid'], $data['uid']); } if ($context['folder_uid']) { $result['parent_id'] = $context['folder_uid']; } foreach (self::$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 'PidNameKeywords': $result[$mapi_idx] = self::parse_categories((array) $value); break; } } } } /** * Convert common properties into kolab format */ public static function convert_common_props(&$result, $data, $original) { // @TODO: id, parent_id? foreach (self::$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 */ public function attributes_filter($attrs, $type = null, $reverse = false) { $map = self::$map; $result = array(); if ($type) { $model = $this->get_model_class($type); $map = array_merge($map, $model->map()); } // 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 instance of model class object */ protected function get_model_class($type) { $class = "kolab_api_filter_mapistore_$type"; return new $class($this); } /** * Convert DateTime object to MAPI date format */ public 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); } if (!is_object($date)) { 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. - // Note: probably does not work on 32-bit systems - return ($date->format('U') + 11644473600) * 10000000; + // 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 function date_mapi2php($date) { - // Note: probably does not work on 32-bit systems - $seconds = intval($date / 10000000) - 11644473600; + $seconds = floatval(sprintf('%.0f', $date)); // assumes we're working with dates after 1970-01-01 - return new DateTime('@' . $seconds); + $dt = new DateTime('@' . intval($seconds - 11644473600)); +/* + if ($microseconds = intval(($date - $seconds) * 1000000)) { + $dt = new DateTime($dt->format('Y-m-d H:i:s') . '.' . $microseconds, $dt->getTimezone()); + } +*/ + return $dt; } /** * 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); } /** * Handles object parent modification (move) */ protected function parent_change_handler($data) { $folder = $this->input->path[0]; $uid = $this->input->path[1]; $target = $data['parent_id']; $api = kolab_api::get_instance(); // move the object $api->backend->objects_move($folder, $target, array($uid)); // replace folder uid in input arguments $this->input->path[0] = $target; // exit if the rest of input is empty if (count($data) < 2) { $api->output->send_status(kolab_api_output::STATUS_EMPTY); } } } diff --git a/tests/Unit/Filter/Mapistore.php b/tests/Unit/Filter/Mapistore.php index 51b1a5f..166870e 100644 --- a/tests/Unit/Filter/Mapistore.php +++ b/tests/Unit/Filter/Mapistore.php @@ -1,196 +1,197 @@ array( 'n2' => 'test2', ), 'n3' => 'test3', 'x-custom' => array( array('identifier' => 'i', value => 'val_i'), ), ); $value = kolab_api_filter_mapistore::get_kolab_value($data, 'n1.n2'); $this->assertSame('test2', $value); $value = kolab_api_filter_mapistore::get_kolab_value($data, 'n3'); $this->assertSame('test3', $value); $value = kolab_api_filter_mapistore::get_kolab_value($data, 'n30'); $this->assertSame(null, $value); $value = kolab_api_filter_mapistore::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::set_kolab_value($data, 'n1.n2', 'test'); $this->assertSame('test', $data['n1']['n2']); kolab_api_filter_mapistore::set_kolab_value($data, 'n1', 'test'); $this->assertSame('test', $data['n1']); kolab_api_filter_mapistore::set_kolab_value($data, 'x-custom.i', 'test1'); $this->assertSame('test1', $data['x-custom.i']); } /** * Test uid_encode method */ function test_uid_encode() { $uid = kolab_api_filter_mapistore::uid_encode('folder', 'msg'); $this->assertSame('folder.msg', $uid); $uid = kolab_api_filter_mapistore::uid_encode('folder', 'msg', 'attach'); $this->assertSame('folder.msg.attach', $uid); $uid = kolab_api_filter_mapistore::uid_encode('f-ol.der', 'm-s.g', 'att.a-ch'); $this->assertSame('f-ol_46der.m-s_46g.att_46a-ch', $uid); } /** * Test uid_decode method */ function test_uid_decode() { $uid = kolab_api_filter_mapistore::uid_decode('f-ol_46der.m-s_46g.att_46a-ch'); $this->assertSame(array('f-ol.der', 'm-s.g', 'att.a-ch'), $uid); } /** * Test parse_common_props method */ function test_parse_common_props() { kolab_api_filter_mapistore::parse_common_props($result = array(), array(), array()); $this->assertSame(array(), $result); } /** * Test attributes_filter method */ function test_attributes_filter() { $api = new kolab_api_filter_mapistore; $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 date_php2mapi method */ function test_date_php2mapi() { $date = kolab_api_filter_mapistore::date_php2mapi('2014-01-01T00:00:00+00:00'); - $this->assertSame(130330080000000000, $date); + $this->assertSame(13033008000.0, $date); $date = kolab_api_filter_mapistore::date_php2mapi('2014-01-01'); - $this->assertSame(130330080000000000, $date); + $this->assertSame(13033008000.0, $date); $date = kolab_api_filter_mapistore::date_php2mapi('1970-01-01T00:00:00Z'); - $this->assertSame(116444736000000000, $date); + $this->assertSame(11644473600.0, $date); $date = kolab_api_filter_mapistore::date_php2mapi('1601-01-01T00:00:00Z'); - $this->assertSame(0, $date); + $this->assertSame(0.0, $date); $date = new DateTime('1601-01-01T00:00:00Z'); $date = kolab_api_filter_mapistore::date_php2mapi($date); - $this->assertSame(0, $date); - - $date = new DateTime('1970-01-01T00:00:00Z'); + $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(116444736000000000, $date); - + $this->assertSame(11644473600.0 + (1000/1000000), $date); +*/ $date = kolab_api_filter_mapistore::date_php2mapi(''); $this->assertSame(null, $date); } /** * Test date_mapi2php method */ function test_date_mapi2php() { $format = 'c'; $data = array( - 130330080000000000 => '2014-01-01T00:00:00+00:00', - 116444736000000000 => '1970-01-01T00:00:00+00:00', - 0 => '1601-01-01T00:00:00+00:00', + 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::date_mapi2php($mapi); $this->assertSame($exp, $date->format($format)); } } /** * 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::parse_categories($categories); $this->assertSame($expected, $result); } }