From 38c07d7f69c11414193042de897bfa8315134bf3 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Wed, 15 Sep 2021 18:45:32 +0200 Subject: [PATCH] WIP REST API for contacts using JsContacts draft --- .../inc/class.addressbook_groupdav.inc.php | 67 +- api/src/Contacts/JsContact.php | 986 ++++++++++++++++++ 2 files changed, 1030 insertions(+), 23 deletions(-) create mode 100644 api/src/Contacts/JsContact.php diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 313fdb4ab9..caeb42d7e1 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -13,6 +13,7 @@ use EGroupware\Api; use EGroupware\Api\Acl; +use EGroupware\Api\Contacts\JsContact; /** * CalDAV/CardDAV/GroupDAV access: Addressbook handler @@ -588,11 +589,20 @@ class addressbook_groupdav extends Api\CalDAV\Handler { return $contact; } - $handler = self::_get_handler(); - $options['data'] = $contact['list_id'] ? $handler->getGroupVCard($contact) : - $handler->getVCard($contact['id'],$this->charset,false); - // e.g. Evolution does not understand 'text/vcard' - $options['mimetype'] = 'text/x-vcard; charset='.$this->charset; + // jsContact or vCard + if (JsContact::isJsContact()) + { + $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : JsContact::getJsCard($contact); + $options['mimetype'] = $contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD; + } + else + { + $handler = self::_get_handler(); + $options['data'] = $contact['list_id'] ? $handler->getGroupVCard($contact) : + $handler->getVCard($contact['id'], $this->charset, false); + // e.g. Evolution does not understand 'text/vcard' + $options['mimetype'] = 'text/x-vcard; charset=' . $this->charset; + } header('Content-Encoding: identity'); header('ETag: "'.$this->get_etag($contact).'"'); return true; @@ -618,31 +628,42 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $oldContact; } - $handler = self::_get_handler(); - // Fix for Apple Addressbook - $vCard = preg_replace('/item\d\.(ADR|TEL|EMAIL|URL)/', '\1', - htmlspecialchars_decode($options['content'])); - $charset = null; - if (!empty($options['content_type'])) + if (JsContact::isJsContact()) { - $content_type = explode(';', $options['content_type']); - if (count($content_type) > 1) + $contact = JsContact::parseJsCard($options['content']); + // just output it again for now + header('Content-Type: application/json'); + echo json_encode($contact, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + return "200 Ok"; + } + else + { + $handler = self::_get_handler(); + // Fix for Apple Addressbook + $vCard = preg_replace('/item\d\.(ADR|TEL|EMAIL|URL)/', '\1', + htmlspecialchars_decode($options['content'])); + $charset = null; + if (!empty($options['content_type'])) { - array_shift($content_type); - foreach ($content_type as $attribute) + $content_type = explode(';', $options['content_type']); + if (count($content_type) > 1) { - trim($attribute); - list($key, $value) = explode('=', $attribute); - switch (strtolower($key)) + array_shift($content_type); + foreach ($content_type as $attribute) { - case 'charset': - $charset = strtoupper(substr($value,1,-1)); + trim($attribute); + list($key, $value) = explode('=', $attribute); + switch (strtolower($key)) + { + case 'charset': + $charset = strtoupper(substr($value,1,-1)); + } } } } - } - $contact = $handler->vcardtoegw($vCard, $charset); + $contact = $handler->vcardtoegw($vCard, $charset); + } if (is_array($oldContact) || ($oldContact = $this->bo->read(array('contact_uid' => $contact['uid'])))) { @@ -753,7 +774,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler //error_log(__METHOD__."(, $id, '$user') read(_list)($save_ok) returned ".array2string($contact)); } - // send evtl. necessary respose headers: Location, etag, ... + // send evtl. necessary response headers: Location, etag, ... $this->put_response_headers($contact, $options['path'], $retval, self::$path_attr != 'id'); if ($this->debug > 1) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($retval)); diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php new file mode 100644 index 0000000000..0810239cf7 --- /dev/null +++ b/api/src/Contacts/JsContact.php @@ -0,0 +1,986 @@ + + * @package addressbook + * @copyright (c) 2021 by Ralf Becker + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + */ + +namespace EGroupware\Api\Contacts; + +use EGroupware\Api; + +/** + * Rendering contacts as JSON using new JsContact format + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07 (newer, here implemented format) + * @link https://datatracker.ietf.org/doc/html/rfc7095 jCard (older vCard compatible contact data as JSON, NOT implemented here!) + */ +class JsContact +{ + const MIME_TYPE = "application/jscontact+json"; + const MIME_TYPE_JSCARD = "application/jscontact+json;type=card"; + const MIME_TYPE_JSCARDGROUP = "application/jscontact+json;type=cardgroup"; + const MIME_TYPE_JSON = "application/json"; + + /** + * Check if request want's JSON + * + * @param ?string $type default use Content-Type or Accept HTTP header depending on request method + * @return bool true: jsContact, false: other eg. vCard + */ + public static function isJsContact(string $type=null) + { + if (!isset($type)) + { + $type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST']) ? + $_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT']; + } + return strpos($type, self::MIME_TYPE) !== false || + strpos($type, self::MIME_TYPE_JSON) !== false; + } + + const JSON_OPTIONS = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE; + + /** + * Get jsCard for given contact + * + * @param int|array $contact + * @return string + * @throws Api\Exception\NotFound + */ + public static function getJsCard($contact) + { + if (is_scalar($contact) && !($contact = self::getContacts()->read($contact))) + { + throw new Api\Exception\NotFound(); + } + return json_encode(array_filter([ + 'uid' => $contact['uid'], + 'prodId' => 'EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'], + 'created' => self::UTCDateTime($contact['created']), + 'updated' => self::UTCDateTime($contact['modified']), + //'kind' => '', // 'individual' or 'org' + //'relatedTo' => [], + 'name' => self::nameComponents($contact), + 'fullName' => self::localizedString($contact['n_fn']), + //'nickNames' => [], + 'organizations' => array_filter(['org' => self::organization($contact)]), + 'titles' => self::titles($contact), + 'emails' => array_filter([ + 'work' => empty($contact['email']) ? null : ['email' => $contact['email']], + 'private' => empty($contact['email_home']) ? null : ['email' => $contact['email_home']], + ]), + 'phones' => self::phones($contact), + 'online' => array_filter([ + 'url' => !empty($contact['url']) ? ['resource' => $contact['url'], 'type' => 'uri', 'contexts' => ['work' => true]] : null, + 'url_home' => !empty($contact['url_home']) ? ['resource' => $contact['url_home'], 'type' => 'uri', 'contexts' => ['private' => true]] : null, + ]), + 'addresses' => [ + 'work' => self::address($contact, 'work', 1), + 'home' => self::address($contact, 'home'), + ], + 'photos' => self::photos($contact), + 'anniversaries' => self::anniversaries($contact), + 'notes' => [self::localizedString($contact['note'])], + 'categories' => self::categories($contact['cat_id']), + 'egroupware.org/customfields' => self::customfields($contact), + 'egroupware.org/assistant' => $contact['assistent'], + 'egroupware.org/fileAs' => $contact['fileas'], + ]), self::JSON_OPTIONS); + } + + /** + * Parse JsCard + * + * @param string $json + * @return array + */ + public static function parseJsCard(string $json) + { + $data = json_decode($json, JSON_THROW_ON_ERROR); + + if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist + + $contact = []; + foreach($data as $name => $value) + { + switch($name) + { + case 'uid': + if (!is_string($value) || empty($value)) + { + throw new \InvalidArgumentException("Invalid uid value!"); + } + $contact['uid'] = $value; + break; + + case 'name': + $contact += self::parseNameComponents($value); + break; + + case 'fullName': + $contact['n_fn'] = self::parseLocalizedString($value); + break; + + case 'organizations': + $contact += self::parseOrganizations($value); + break; + + case 'titles': + $contact += self::parseTitles($value); + break; + + case 'emails': + $contact += self::parseEmails($value); + break; + + case 'phones': + $contact += self::parsePhones($value); + break; + + case 'online': + $contact += self::parseOnline($value); + break; + + case 'addresses': + $contact += self::parseAddresses($value); + break; + + case 'photos': + $contact += self::parsePhotos($value); + break; + + case 'anniversaries': + $contact += self::parseAnniversaries($value); + break; + + case 'notes': + $contact['note'] = implode("\n", array_map(static function($note) + { + return self::parseLocalizedString($note); + }, $value)); + break; + + case 'categories': + $contact['cat_id'] = self::parseCategories($value); + break; + + case 'egroupware.org/customfields': + $contact += self::parseCustomfields($value); + break; + + case 'egroupware.org/assistant': + $contact['assistent'] = $value; + break; + + case 'egroupware.org/fileAs': + $contact['fileas'] = $value; + break; + + case 'prodId': case 'created': case 'updated': case 'kind': + break; + + default: + error_log(__METHOD__."() $name=".json_encode($value).' --> ignored'); + break; + } + } + return $contact; + } + + /** + * Return organisation + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.2.4 + * @param array $contact + * @return array + */ + protected static function organization(array $contact) + { + if (empty($contact['org_name'])) + { + return null; // name is mandatory + } + return array_filter([ + 'name' => self::localizedString($contact['org_name']), + 'units' => empty($contact['org_unit']) ? null : ['org_unit' => self::localizedString($contact['org_unit'])], + ]); + } + + /** + * Parse Organizations + * + * As we store only one organization, the rest get lost, multiple units get concatenated by space. + * + * @param array $orgas + * @return array + */ + protected static function parseOrganizations(array $orgas) + { + $contact = []; + foreach($orgas as $orga) + { + $contact['org_name'] = self::parseLocalizedString($orga['name']); + $contact['org_unit'] = implode(' ', array_map(static function($unit) + { + return self::parseLocalizedString($unit); + }, $orga['units'])); + break; + } + if (count($orgas) > 1) + { + error_log(__METHOD__."() more then 1 organization --> ignored"); + } + return $contact; + } + + /** + * Return titles of a contact + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.2.5 + * @param array $contact + */ + protected static function titles(array $contact) + { + return array_filter([ + 'title' => self::localizedString($contact['title']), + 'role' => self::localizedString($contact['role']), + ]); + } + + /** + * Parse titles, thought we only have "title" and "role" available for storage + * + * @param array $titles + * @return array + */ + protected static function parseTitles(array $titles) + { + $contact = []; + if (isset($titles[$id='title']) || isset($contact[$id='jobTitle'])) + { + $contact['title'] = self::parseLocalizedString($titles[$id]); + unset($titles[$id]); + } + if (isset($titles[$id='role'])) + { + $contact['role'] = self::parseLocalizedString($titles[$id]); + unset($titles[$id]); + } + if (!isset($contact['title']) && $titles) + { + $contact['title'] = self::parseLocalizedString(array_shift($titles)); + } + if (!isset($contact['role']) && $titles) + { + $contact['role'] = self::parseLocalizedString(array_shift($titles)); + } + if (count($titles)) + { + error_log(__METHOD__."() only 2 titles can be stored --> rest is ignored!"); + } + return $contact; + } + + /** + * Return EGroupware custom fields + * + * @param array $contact + * @return array + */ + protected static function customfields(array $contact) + { + $fields = []; + foreach(Api\Storage\Customfields::get('addressbook') as $name => $data) + { + $value = $contact['#'.$name]; + if (isset($value)) + { + switch($data['type']) + { + case 'date-time': + $value = Api\DateTime::to($value, Api\DateTime::RFC3339); + break; + case 'float': + $value = (double)$value; + break; + case 'int': + $value = (int)$value; + break; + case 'select': + $value = explode(',', $value); + break; + } + $fields[$name] = array_filter([ + 'value' => $value, + 'type' => $data['type'], + 'label' => $data['label'], + 'values' => $data['values'], + ]); + } + } + return $fields; + } + + /** + * Parse custom fields + * + * Not defined custom fields are ignored! + * + * @param array $cfs name => object with attribute data and optional type, label, values + * @return array + */ + protected static function parseCustomfields(array $cfs) + { + $contact = []; + $definition = Api\Storage\Customfields::get('addressbook'); + + foreach($cfs as $name => $data) + { + if (!is_array($data) || !array_key_exists('value', $data)) + { + throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data)); + } + if (isset($definition[$name])) + { + switch($definition[$name]['type']) + { + case 'date-time': + $data['value'] = Api\DateTime::to($data['value'], 'object'); + break; + case 'float': + $data['value'] = (double)$data['value']; + break; + case 'int': + $data['value'] = round($data['value']); + break; + case 'select': + if (is_scalar($data['value'])) $data['value'] = explode(',', $data['value']); + $data['value'] = array_intersect(array_keys($definition[$name]['values']), $data['value']); + $data['value'] = $data['value'] ? implode(',', (array)$data['value']) : null; + break; + } + $contact['#'.$name] = $data['value']; + } + } + return $contact; + } + + /** + * Return object of category-name(s) => true + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.4 + * @param ?string $cat_ids comma-sep. cat_id's + * @return true[] + */ + protected static function categories(?string $cat_ids) + { + $cat_ids = array_filter($cat_ids ? explode(',', $cat_ids): []); + + return array_combine(array_map(static function ($cat_id) + { + return Api\Categories::id2name($cat_id); + }, $cat_ids), array_fill(0, count($cat_ids), true)); + } + + /** + * Parse categories object + * + * @param array $categories category-name => true pairs + * @return ?string comma-separated cat_id's + */ + protected static function parseCategories(array $categories) + { + static $bo=null; + $cat_ids = []; + if ($categories) + { + if (!isset($bo)) $bo = new Api\Contacts(); + $cat_ids = $bo->find_or_add_categories(array_keys($categories)); + } + return $cat_ids ? implode(',', $cat_ids) : null; + } + + /** + * @var string[] address attribute => contact attr pairs + */ + protected static $jsAddress2attr = [ + 'locality' => 'locality', + 'region' => 'region', + 'country' => 'countryname', + //'postOfficeBox' => '', + 'postcode' => 'postalcode', + 'countryCode' => 'countrycode', + ]; + /** + * @var string[] address attribute => contact attr pairs we have only once + */ + protected static $jsAddress2workAttr = [ + 'fullAddress' => 'label', + 'coordinates' => 'geo', + 'timeZone' => 'tz', + ]; + + /** + * Return address object + * + * @param array $contact + * @param string $type "work" or "home" only currently + * @param ?int $preference 1=highest, ..., 100=lowest (=null) + * @return array + */ + protected static function address(array $contact, string $type, int $preference=null) + { + $prefix = $type === 'work' ? 'adr_one_' : 'adr_two_'; + $js2attr = self::$jsAddress2attr; + if ($type === 'work') $js2attr += self::$jsAddress2workAttr; + + $address = array_map(static function($attr) use ($contact, $prefix) + { + return $contact[$prefix.$attr]; + }, $js2attr) + [ + 'street' => self::streetComponents($contact[$prefix.'street'], $contact[$prefix.'street2']), + 'contexts' => [$type => true], + 'pref' => $preference, + ]; + + return array_filter($address); + } + + /** + * Parse addresses object containing multiple addresses + * + * @param array $addresses + * @return array + */ + protected static function parseAddresses(array $addresses) + { + $n = 0; + $last_type = null; + $contact = []; + foreach($addresses as $id => $address) + { + $contact += ($values=self::parseAddress($address, $id, $last_type)); + if (++$n > 2) + { + error_log(__METHOD__."() Ignoring $n. address id=$id: ".json_encode($address)); + break; + } + } + // make sure our address-unspecific attributes get not lost, because they were sent in 2nd address object + foreach(self::$jsAddress2workAttr as $attr) + { + if (!empty($contact[$attr]) && !empty($values[$attr])) + { + $contact[$attr] = $values[$attr]; + } + } + return $contact; + } + + /** + * Parse address object + * + * As we only have a work and a home address we need to make sure to no fill one twice. + * + * @param array $address address-object + * @param string $id index + * @param ?string $last_type "work" or "home" + * @return array + */ + protected static function parseAddress(array $address, string $id, string &$last_type=null) + { + $type = !isset($last_type) && (empty($address['context']['private']) || $id !== 'private') || $last_type === 'home' ? 'work' : 'home'; + $last_type = $type; + $prefix = $type === 'work' ? 'adr_one_' : 'adr_two_'; + + $contact = [$prefix.'street' => null, $prefix.'street2' => null]; + list($contact[$prefix.'street'], $contact[$prefix.'street2']) = self::parseStreetComponents($address['street']); + foreach(self::$jsAddress2attr+self::$jsAddress2workAttr as $js => $attr) + { + if (isset($address[$js]) && !is_string($address[$js])) + { + throw new \InvalidArgumentException("Invalid address object with id '$id'"); + } + $contact[$prefix.$attr] = $address[$js]; + } + return $contact; + } + + /** + * Our data module does NOT distinguish between all the JsContact components therefore we only send a "name" component + * + * Trying to automatic parse following examples with eg. '/^(\d+[^ ]* )?(.*?)( \d+[^ ]*)?$/': + * 1. "Streetname 123" --> name, number --> Ok + * 2. "123 Streetname" --> number, name --> Ok + * 3. "Streetname 123 App. 3" --> name="Streetname 123 App.", number="3" --> Wrong + * + * ==> just use "name" for now and concatenate incoming data with one space + * ==> add 2. street line with separator "\n" and again name + * + * @param string $street + * @param ?string $street2=null 2. address line + * @return array[] array of objects with attributes type and value + */ + protected static function streetComponents(string $street, ?string $street2=null) + { + $components = [['type' => 'name', 'value' => $street]]; + + if (!empty($street2)) + { + $components[] = ['type' => 'separator', 'value' => "\n"]; + $components[] = ['type' => 'name', 'value' => $street2]; + } + return $components; + } + + /** + * Parse street components + * + * As we have only 2 address-lines, we combine all components, with one space as separator, if none given. + * Then we split it into 2 lines. + * + * @param array $components + * @return string[] street and street2 values + */ + protected static function parseStreetComponents(array $components) + { + $street = []; + $last_type = null; + foreach($components as $component) + { + if (!is_array($component) || !is_string($component['value'])) + { + throw new \InvalidArgumentException("Invalid streetComponents!"); + } + if ($street && $last_type !== 'separator') // if we have no separator, we add a space + { + $street[] = ' '; + } + $street[] = $component['value']; + $last_type = $component['type']; + } + return preg_split("/\r?\n/", implode('', $street), 2); + } + + /** + * @var array mapping contact-attribute-names to jscontact phones + */ + protected static $phone2jscard = [ + 'tel_work' => ['features' => ['voice' => true], 'contexts' => ['work' => true]], + 'tel_cell' => ['features' => ['cell' => true], 'contexts' => ['work' => true]], + 'tel_fax' => ['features' => ['fax' => true], 'contexts' => ['work' => true]], + 'tel_assistent' => ['features' => ['voice' => true], 'contexts' => ['assistant' => true]], + 'tel_car' => ['features' => ['voice' => true], 'contexts' => ['car' => true]], + 'tel_pager' => ['features' => ['pager' => true], 'contexts' => ['work' => true]], + 'tel_home' => ['features' => ['voice' => true], 'contexts' => ['private' => true]], + 'tel_fax_home' => ['features' => ['fax' => true], 'contexts' => ['private' => true]], + 'tel_cell_private' => ['features' => ['cell' => true], 'contexts' => ['private' => true]], + 'tel_other' => ['features' => ['voice' => true], 'contexts' => ['work' => true]], + ]; + + /** + * Return "phones" resources + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.3.2 + * @param array $contact + * @return array[] + */ + protected static function phones(array $contact) + { + $phones = []; + foreach(self::$phone2jscard as $name => $attributes) + { + if (!empty($contact[$name])) + { + $phones[$name] = array_filter([ + 'phone' => $contact[$name], + 'pref' => $name === $contact['tel_prefer'] ? 1 : null, + 'label' => '', + ]+$attributes); + } + } + return $phones; + } + + /** + * Parse phone objects + * + * @param array $phones $id => object with attribute "phone" and optional "features" and "context" + * @return array + */ + protected static function parsePhones(array $phones) + { + $contact = []; + + // check for good matches + foreach($phones as $id => $phone) + { + if (!is_array($phone) || !is_string($phone['phone'])) + { + throw new \InvalidArgumentException("Invalid phone: " . json_encode($phone)); + } + // first check for "our" id's + if (isset(self::$phone2jscard[$id]) && !isset($contact[$id])) + { + $contact[$id] = $phone['phone']; + unset($phones[$id]); + continue; + } + // check if we have a phone with at least one matching features AND one matching contexts + foreach (self::$phone2jscard as $attr => $data) + { + if (!isset($contact[$attr]) && + isset($phone['features']) && array_intersect(array_keys($data['features']), array_keys($phone['features'])) && + isset($phone['contexts']) && array_intersect(array_keys($data['contexts']), array_keys($phone['contexts']))) + { + $contact[$attr] = $phone['phone']; + unset($phones[$id]); + break; + } + } + } + // check for not so good matches + foreach($phones as $id => $phone) + { + // check if only one of them matches + foreach (self::$phone2jscard as $attr => $data) + { + if (!isset($contact[$attr]) && + isset($phone['features']) && array_intersect(array_keys($data['features']), array_keys($phone['features'])) || + isset($phone['contexts']) && array_intersect(array_keys($data['contexts']), array_keys($phone['contexts']))) + { + $contact[$attr] = $phone['phone']; + unset($phones[$id]); + break; + } + } + } + // store them where we still have space + foreach($phones as $id => $phone) + { + // store them where we still have space + foreach(self::$phone2jscard as $attr => $data) + { + if (!isset($contact[$attr])) + { + $contact[$attr] = $phone['phone']; + unset($phones[$id]); + } + } + } + if ($phones) + { + error_log(__METHOD__."() more then the supported ".count(self::$phone2jscard)." phone found --> ignoring access ones"); + } + return $contact; + } + + /** + * Parse online resource objects + * + * We currently only support 2 URLs, rest get's ignored! + * + * @param array $values + * @return array + */ + protected static function parseOnline(array $values) + { + $contact = []; + foreach($values as $id => $value) + { + if (!is_array($value) || !is_string($value['resource'])) + { + throw new \InvalidArgumentException("Invalid online resource with id '$id': ".json_encode($value)); + } + // check for "our" id's + if (in_array($id, ['url', 'url_home'])) + { + $contact[$id] = $value['resource']; + unset($values[$id]); + } + // check for matching context + elseif (!isset($contact['url']) && empty($value['contexts']['private'])) + { + $contact['url'] = $value['resource']; + unset($values[$id]); + } + // check it's free + elseif (!isset($contact['url_home'])) + { + $contact['url_home'] = $value['resource']; + } + } + if ($values) + { + error_log(__METHOD__."() more then 2 email addresses --> ignored"); + } + return $contact; + } + + /** + * Parse emails object + * + * @param array $emails id => object with attribute "email" and optional "context" + * @return array + */ + protected static function parseEmails(array $emails) + { + $contact = []; + foreach($emails as $id => $value) + { + if (!is_array($value) || !is_string($value['email'])) + { + throw new \InvalidArgumentException("Invalid email object: ".json_encode($value)); + } + if (!isset($contact['email']) && $id !== 'private' && empty($value['context']['private'])) + { + $contact['email'] = $value['email']; + } + elseif (!isset($contact['email_home'])) + { + $contact['email_home'] = $value['email']; + } + else + { + error_log(__METHOD__."() can not store more then 2 email addresses currently --> ignored"); + } + } + return $contact; + } + + /** + * Return id => photo objects of a contact pairs + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.3.4 + * @param array $contact + * @return array + */ + protected static function photos(array $contact) + { + return []; + } + + /** + * Parse photos object + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.3.4 + * @param array $photos id => photo objects of a contact pairs + * @return array + */ + protected static function parsePhotos(array $photos) + { + return []; + } + + /** + * @var string[] name-component type => attribute-name pairs + */ + protected static $nameType2attribute = [ + 'prefix' => 'n_prefix', + 'personal' => 'n_given', + 'additional' => 'n_middle', + 'surname' => 'n_family', + 'suffix' => 'n_suffix', + ]; + + /** + * Return name-components objects with "type" and "value" attributes + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.2.1 + * @param array $contact + * @return array[] + */ + protected static function nameComponents(array $contact) + { + $components = array_filter(array_map(function($attr) use ($contact) + { + return $contact[$attr]; + }, self::$nameType2attribute)); + return array_map(function($type, $value) + { + return ['type' => $type, 'value' => $value]; + }, array_keys($components), array_values($components)); + } + + /** + * parse nameComponents + * + * @param array $values + * @return array + */ + protected static function parseNameComponents(array $values) + { + $contact = array_combine(array_values(self::$nameType2attribute), + array_fill(0, count(self::$nameType2attribute), null)); + + foreach($values as $value) + { + if (empty($value['type']) || isset($value) && !is_string($value['value'])) + { + throw new \InvalidArgumentException("Invalid nameComponent!"); + } + $contact[self::$nameType2attribute[$value['type']]] = $value['value']; + } + return $contact; + } + + /** + * Return anniversaries / birthday + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.1 + * @param array $contact + * @return array + */ + protected static function anniversaries(array $contact) + { + return empty($contact['bday']) ? [] : ['bday' => [ + 'type' => 'birth', + 'date' => $contact['bday'], + //'place' => '', + ]]; + } + + /** + * Parse anniversaries / birthday + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.5.1 + * @param array $anniversaries id => object with attribute date and optional type + * @return array + */ + protected static function parseAnniversaries(array $anniversaries) + { + $contact = []; + foreach($anniversaries as $id => $anniversary) + { + if (!is_array($anniversary) || !is_string($anniversary['date']) || + !preg_match('/^\d{4}-\d{2}-\d{2}$/', $anniversary['date']) || + (!list($year, $month, $day) = explode('-', $anniversary['date'])) || + !(1 <= $month && $month <= 12 && 1 <= $day && $day <= 31)) + { + throw new \InvalidArgumentException("Invalid anniversary object with id '$id': ".json_encode($anniversary)); + } + if (!isset($contact['bday']) && ($id === 'bday' || $anniversary['type'] === 'birth')) + { + $contact['bday'] = $anniversary['date']; + } + else + { + error_log(__METHOD__."() only one birtday is supported, ignoring aniversary: ".json_encode($anniversary)); + } + } + return $contact; + } + + /** + * Return a localized string + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-1.5.3 + * @param string $value + * @param ?string $language + * @param string[] $localications map with extra language => value pairs + * @return array[] with values for keys "value", "language" and "localizations" + */ + protected static function localizedString($value, string $language=null, array $localications=[]) + { + if (empty($value) && !$localications) + { + return null; + } + return array_filter([ + 'value' => $value, + 'language' => $language, + 'localizations' => $localications, + ]); + } + + /** + * Parse localized string + * + * We're not currently storing/allowing any localization --> they get ignored/thrown away! + * + * @param array $value object with attribute "value" + * @return string + */ + protected static function parseLocalizedString(array $value) + { + if (!is_string($value['value'])) + { + throw new \InvalidArgumentException("Invalid localizedString: ".json_encode($value)); + } + return $value['value']; + } + + /** + * Return a date-time value + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-1.5.5 + * @param null|string|\DateTime $date + * @return string|null + */ + protected static function UTCDateTime($date) + { + static $utc=null; + if (!isset($utc)) $utc = new \DateTimeZone('UTC'); + + if (!isset($date)) + { + return null; + } + $date = Api\DateTime::to($date, 'object'); + $date->setTimezone($utc); + + // we need to use "Z", not "+00:00" + return substr($date->format(Api\DateTime::RFC3339), 0, -6).'Z'; + } + + /** + * Get jsCardGroup for given group + * + * @param int|array $group + * @return string + * @throws Api\Exception\NotFound + */ + public static function getJsCardGroup($group) + { + if (is_scalar($group) && !($group = self::getContacts()->read_lists($group))) + { + throw new Api\Exception\NotFound(); + } + /* + $vCard = new Horde_Icalendar_Vcard($version); + $vCard->setAttribute('PRODID','-//EGroupware//NONSGML EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'].'//'. + strtoupper($GLOBALS['egw_info']['user']['preferences']['common']['lang'])); + + $vCard->setAttribute('N',$list['list_name'],array(),true,array($list['list_name'],'','','','')); + $vCard->setAttribute('FN',$list['list_name']); + + $vCard->setAttribute('X-ADDRESSBOOKSERVER-KIND','group'); + foreach($list['members'] as $uid) + { + $vCard->setAttribute('X-ADDRESSBOOKSERVER-MEMBER','urn:uuid:'.$uid); + } + $vCard->setAttribute('REV',Api\DateTime::to($list['list_modified'],'Y-m-d\TH:i:s\Z')); + $vCard->setAttribute('UID',$list['list_uid']); + */ + + return json_encode($group, self::JSON_OPTIONS); + } + + /** + * @return Api\Contacts + */ + protected static function getContacts() + { + static $contacts=null; + if (!isset($contacts)) + { + $contacts = new Api\Contacts(); + } + return $contacts; + } +} \ No newline at end of file