forked from extern/egroupware
WIP REST API for contacts using JsContacts draft
This commit is contained in:
parent
bd2a4a0752
commit
38c07d7f69
@ -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));
|
||||
|
986
api/src/Contacts/JsContact.php
Normal file
986
api/src/Contacts/JsContact.php
Normal file
@ -0,0 +1,986 @@
|
||||
<?php
|
||||
/**
|
||||
* EGroupware API - JsContact
|
||||
*
|
||||
* @link https://www.egroupware.org
|
||||
* @author Ralf Becker <RalfBecker-AT-outdoor-training.de>
|
||||
* @package addressbook
|
||||
* @copyright (c) 2021 by Ralf Becker <rb@egroupware.org>
|
||||
* @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;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user