From 38e21671662f55a554220920f597465087d6eb4c Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Wed, 15 Sep 2021 18:45:32 +0200 Subject: [PATCH 01/29] 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 From fc3aba9c3948056fb16ff99feb1d48a6d384a2a8 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Thu, 16 Sep 2021 09:08:50 +0200 Subject: [PATCH 02/29] missing ajax=true for mail site-config --- mail/inc/class.mail_hooks.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mail/inc/class.mail_hooks.inc.php b/mail/inc/class.mail_hooks.inc.php index 1760576e58..2d6eeb1eb5 100644 --- a/mail/inc/class.mail_hooks.inc.php +++ b/mail/inc/class.mail_hooks.inc.php @@ -516,7 +516,7 @@ class mail_hooks $profileID = (int)$GLOBALS['egw_info']['user']['preferences']['mail']['ActiveProfileID']; $file = Array( - 'Site Configuration' => Egw::link('/index.php',array('menuaction'=>'admin.uiconfig.index','appname'=>'mail')), + 'Site Configuration' => Egw::link('/index.php',array('menuaction'=>'admin.uiconfig.index','appname'=>'mail','ajax'=>'true')), ); display_section($appname,$title,$file); } From 0768f5fadf434b78efb76eadd8e1e6d96628f0cd Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Thu, 16 Sep 2021 20:53:43 +0200 Subject: [PATCH 03/29] WIP REST Api for contacts --- .../inc/class.addressbook_groupdav.inc.php | 31 +-- api/src/CalDAV.php | 185 ++++++++++++++++++ api/src/CalDAV/Handler.php | 19 +- api/src/Contacts/JsContact.php | 52 +++-- 4 files changed, 239 insertions(+), 48 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index caeb42d7e1..f3d2db8406 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -73,9 +73,14 @@ class addressbook_groupdav extends Api\CalDAV\Handler $this->bo = new Api\Contacts(); + if (Api\CalDAV::isJSON()) + { + self::$path_attr = 'id'; + self::$path_extension = ''; + } // since 1.9.007 we allow clients to specify the URL when creating a new contact, as specified by CardDAV // LDAP does NOT have a carddav_name attribute --> stick with id mapped to LDAP attribute uid - if (version_compare($GLOBALS['egw_info']['apps']['api']['version'], '1.9.007', '<') || + elseif (version_compare($GLOBALS['egw_info']['apps']['api']['version'], '1.9.007', '<') || $this->bo->contact_repository != 'sql' || $this->bo->account_repository != 'sql' && strpos($_SERVER['REQUEST_URI'].'/','/addressbook-accounts/') !== false) { @@ -173,12 +178,12 @@ class addressbook_groupdav extends Api\CalDAV\Handler if ($options['root']['name'] == 'sync-collection' && $this->bo->total > $nresults) { --$this->sync_collection_token; - $files['sync-token-params'][] = true; // tel get_sync_collection_token that we have more entries + $files['sync-token-params'][] = true; // tell get_sync_collection_token that we have more entries } } else { - // return iterator, calling ourself to return result in chunks + // return iterator, calling ourselves to return result in chunks $files['files'] = new Api\CalDAV\PropfindIterator($this,$path,$filter,$files['files']); } return true; @@ -270,6 +275,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler } } + $is_jscontact = Api\CalDAV::isJSON(); foreach($contacts as &$contact) { // remove contact from requested multiget ids, to be able to report not found urls @@ -284,15 +290,16 @@ class addressbook_groupdav extends Api\CalDAV\Handler continue; } $props = array( - 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'text/vcard'), + 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', $is_jscontact ? JsContact::MIME_TYPE_JSCARD : 'text/vcard'), 'getlastmodified' => $contact['modified'], 'displayname' => $contact['n_fn'], ); if ($address_data) { - $content = $handler->getVCard($contact['id'],$this->charset,false); + $content = $is_jscontact ? JsContact::getJsCard($contact['id'], false) : + $handler->getVCard($contact['id'],$this->charset,false); $props['getcontentlength'] = bytes($content); - $props[] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); + $props['address-data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); } $files[] = $this->add_resource($path, $contact, $props); } @@ -351,16 +358,16 @@ class addressbook_groupdav extends Api\CalDAV\Handler $etag .= ':'.implode('-',$filter['owner']); } $props = array( - 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', 'text/vcard'), + 'getcontenttype' => Api\CalDAV::mkprop('getcontenttype', $is_jscontact ? JsContact::MIME_TYPE_JSCARDGROUP : 'text/vcard'), 'getlastmodified' => Api\DateTime::to($list['list_modified'],'ts'), 'displayname' => $list['list_name'], 'getetag' => '"'.$etag.'"', ); if ($address_data) { - $content = $handler->getGroupVCard($list); + $content = $is_jscontact ? JsContact::getJsCardGroup($list) : $handler->getGroupVCard($list); $props['getcontentlength'] = bytes($content); - $props[] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); + $props['address-data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); } $files[] = $this->add_resource($path, $list, $props); @@ -452,7 +459,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler } else { - switch($filter['attrs']['collation']) // todo: which other collations allowed, we are allways unicode + switch($filter['attrs']['collation']) // todo: which other collations allowed, we are always unicode { case 'i;unicode-casemap': default: @@ -590,7 +597,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $contact; } // jsContact or vCard - if (JsContact::isJsContact()) + if (Api\CalDAV::isJSON()) { $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : JsContact::getJsCard($contact); $options['mimetype'] = $contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD; @@ -628,7 +635,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $oldContact; } - if (JsContact::isJsContact()) + if (Api\CalDAV::isJSON()) { $contact = JsContact::parseJsCard($options['content']); // just output it again for now diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index ccf7885eed..9073b17c4b 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -18,6 +18,8 @@ use EGroupware\Api\CalDAV\Principals; // explicit import non-namespaced classes require_once(__DIR__.'/WebDAV/Server.php'); + +use EGroupware\Api\Contacts\JsContact; use HTTP_WebDAV_Server; use calendar_hooks; @@ -976,6 +978,21 @@ class CalDAV extends HTTP_WebDAV_Server parent::http_PROPFIND('REPORT'); } + /** + * Check if clients want's or sends JSON + * + * @return bool + */ + public static function isJSON(string $type=null) + { + if (!isset($type)) + { + $type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST', 'PROPPATCH']) ? + $_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT']; + } + return (bool)preg_match('#application/([^+ ;]+\+)?json#', $type); + } + /** * GET method handler * @@ -989,6 +1006,10 @@ class CalDAV extends HTTP_WebDAV_Server $id = $app = $user = null; if (!$this->_parse_path($options['path'],$id,$app,$user) || $app == 'principals') { + if (self::isJSON()) + { + return $this->jsonIndex($options); + } return $this->autoindex($options); } if (($handler = self::app_handler($app))) @@ -999,6 +1020,170 @@ class CalDAV extends HTTP_WebDAV_Server return '501 Not Implemented'; } + const JSON_OPTIONS = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR; + const JSON_OPTIONS_PRETTY = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR; + + /** + * JSON encode incl. modified pretty-print + * + * @param $data + * @return array|string|string[]|null + */ + public static function json_encode($data, $pretty = true) + { + if (!$pretty) + { + return self::json_encode($data, self::JSON_OPTIONS); + } + return preg_replace('/: {\n\s*(.*?)\n\s*(},?\n)/', ': { $1 $2', + json_encode($data, self::JSON_OPTIONS_PRETTY)); + } + + /** + * PROPFIND/REPORT like output for GET request on collection with Accept: application/(.*+)?json + * + * For addressbook-collections we give a REST-like output without any other properties + * { + * "/addressbook/ID": { + * JsContact-data + * }, + * ... + * } + * + * @param array $options + * @return bool|string|void + */ + protected function jsonIndex(array $options) + { + header('Content-Type: application/json; charset=utf-8'); + $is_addressbook = strpos($options['path'], '/addressbook') !== false; + $propfind_options = array( + 'path' => $options['path'], + 'depth' => 1, + 'props' => $is_addressbook ? [ + 'address-data' => self::mkprop(self::CARDDAV, 'address-data', '') + ] : 'all', + 'other' => [], + ); + + // sync-collection report via GET parameter sync-token + if (isset($_GET['sync-token'])) + { + $propfind_options['root'] = ['name' => 'sync-collection']; + $propfind_options['other'][] = ['name' => 'sync-token', 'data' => $_GET['sync-token']]; + $propfind_options['other'][] = ['name' => 'sync-level', 'data' => $_GET['sync-level'] ?? 1]; + + // clients want's pagination + if (isset($_GET['nresults'])) + { + $propfind_options['other'][] = ['name' => 'nresults', 'data' => (int)$_GET['nresults']]; + } + } + + // ToDo: client want data filtered + if (isset($_GET['filters'])) + { + + } + + // properties to NOT get the default address-data for addressbook-collections and "all" for the rest + if (isset($_GET['props'])) + { + $propfind_options['props'] = []; + foreach((array)$_GET['props'] as $value) + { + $parts = explode(':', $value); + $name = array_pop($parts); + $ns = $parts ? implode(':', $parts) : 'DAV:'; + $propfind_options['props'][$name] = self::mkprop($ns, $name, ''); + } + } + $files = array(); + if (($ret = $this->REPORT($propfind_options,$files)) !== true) + { + return $ret; // no collection + } + + echo "{\n"; + $prefix = " "; + foreach($files['files'] as $resource) + { + $path = $resource['path']; + echo $prefix.json_encode($path, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE).': '; + if (!isset($resource['props'])) + { + echo 'null'; // deleted in sync-report + } + /*elseif (isset($resource['props']['address-data'])) + { + echo $resource['props']['address-data']['val']; + }*/ + else + { + $props = $propfind_options['props'] === 'all' ? $resource['props'] : + array_intersect_key($resource['props'], $propfind_options['props']); + + if (count($props) > 1) + { + $props = self::jsonProps($props); + } + else + { + $props = current($props)['val']; + } + echo self::json_encode($props); + } + $prefix = ",\n "; + } + // add sync-token to response + if (isset($files['sync-token'])) + { + echo $prefix.'"sync-token": '.json_encode(!is_callable($files['sync-token']) ? $files['sync-token'] : + call_user_func_array($files['sync-token'], (array)$files['sync-token-params']), JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); + } + echo "\n}\n"; + + // exit now, so WebDAV::GET does NOT add Content-Type: application/octet-stream + exit; + } + + /** + * Nicer way to display/encode DAV properties + * + * @param array $props + * @return array + */ + protected function jsonProps(array $props) + { + $json = []; + foreach($props as $key => $prop) + { + if (is_scalar($prop['val'])) + { + $value = is_int($key) && $prop['val'] === '' ? + /*$prop['ns'].':'.*/$prop['name'] : $prop['val']; + } + // check if this is a property-object + elseif (count($prop) === 3 && isset($prop['name']) && isset($prop['ns']) && isset($prop['val'])) + { + $value = $prop['name'] === 'address-data' ? $prop['val'] : self::jsonProps($prop['val']); + } + else + { + $value = $prop; + } + if (is_int($key)) + { + $json[] = $value; + } + else + { + $json[/*($prop['ns'] === 'DAV:' ? '' : $prop['ns'].':').*/$prop['name']] = $value; + } + } + return $json; + } + /** * Display an automatic index (listing and properties) for a collection * diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 54a89940fa..092c995d83 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -712,16 +712,23 @@ abstract class Handler //error_log(__METHOD__."('$path', $user, more_results=$more_results) this->sync_collection_token=".$this->sync_collection_token); if ($more_results) { - $error = -' - '.htmlspecialchars($this->caldav->base_uri.$this->caldav->path).' + if (Api\CalDAV::isJSON()) + { + $error = ",\n".' "more-results": true'; + } + else + { + $error = + ' + ' . htmlspecialchars($this->caldav->base_uri . $this->caldav->path) . ' HTTP/1.1 507 Insufficient Storage '; - if ($this->caldav->crrnd) - { - $error = str_replace(array('caldav->crrnd) + { + $error = str_replace(array('read($contact))) { throw new Api\Exception\NotFound(); } - return json_encode(array_filter([ + $data = array_filter([ 'uid' => $contact['uid'], 'prodId' => 'EGroupware Addressbook '.$GLOBALS['egw_info']['apps']['api']['version'], 'created' => self::UTCDateTime($contact['created']), @@ -90,7 +72,12 @@ class JsContact 'egroupware.org/customfields' => self::customfields($contact), 'egroupware.org/assistant' => $contact['assistent'], 'egroupware.org/fileAs' => $contact['fileas'], - ]), self::JSON_OPTIONS); + ]); + if ($encode) + { + return Api\CalDAV::json_encode($data); + } + return $data; } /** @@ -526,14 +513,19 @@ class JsContact * @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) + protected static function streetComponents(?string $street, ?string $street2=null) { - $components = [['type' => 'name', 'value' => $street]]; - - if (!empty($street2)) + $components = []; + foreach(func_get_args() as $street) { - $components[] = ['type' => 'separator', 'value' => "\n"]; - $components[] = ['type' => 'name', 'value' => $street2]; + if ($components) + { + $components[] = ['type' => 'separator', 'value' => "\n"]; + } + if (!empty($street)) + { + $components[] = ['type' => 'name', 'value' => $street]; + } } return $components; } @@ -968,7 +960,7 @@ class JsContact $vCard->setAttribute('UID',$list['list_uid']); */ - return json_encode($group, self::JSON_OPTIONS); + return Api\CalDAV::json_encode($group); } /** From 8793f7895bfc240cb9a8878556c8372abdda3843 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Fri, 17 Sep 2021 11:58:39 +0200 Subject: [PATCH 04/29] fix excess separator, if not 2nd street-line --- api/src/Contacts/JsContact.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index f7b71d17bf..ac2be3a756 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -518,12 +518,12 @@ class JsContact $components = []; foreach(func_get_args() as $street) { - if ($components) - { - $components[] = ['type' => 'separator', 'value' => "\n"]; - } if (!empty($street)) { + if ($components) + { + $components[] = ['type' => 'separator', 'value' => "\n"]; + } $components[] = ['type' => 'name', 'value' => $street]; } } From 7617aae9d0424f907e68f7b91585262a648da2ee Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Fri, 17 Sep 2021 20:15:36 +0200 Subject: [PATCH 05/29] got POST, PUT and DELETE request to add, update and delete contacts working added JSON exception handler with nicer JsCalendar parse errors --- .../inc/class.addressbook_groupdav.inc.php | 25 +- api/src/CalDAV.php | 63 ++++- api/src/Contacts/JsContact.php | 244 +++++++++++------- api/src/Contacts/JsContactParseException.php | 27 ++ 4 files changed, 244 insertions(+), 115 deletions(-) create mode 100644 api/src/Contacts/JsContactParseException.php diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index f3d2db8406..43936b4928 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -599,8 +599,10 @@ class addressbook_groupdav extends Api\CalDAV\Handler // jsContact or vCard if (Api\CalDAV::isJSON()) { - $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : JsContact::getJsCard($contact); - $options['mimetype'] = $contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD; + $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : + JsContact::getJsCard($contact); + $options['mimetype'] = ($contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : + JsContact::MIME_TYPE_JSCARD).';charset=utf-8'; } else { @@ -638,10 +640,12 @@ class addressbook_groupdav extends Api\CalDAV\Handler if (Api\CalDAV::isJSON()) { $contact = JsContact::parseJsCard($options['content']); - // just output it again for now + + /* uncomment to return parsed data for testing header('Content-Type: application/json'); echo json_encode($contact, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); return "200 Ok"; + */ } else { @@ -751,7 +755,8 @@ class addressbook_groupdav extends Api\CalDAV\Handler } if ($this->http_if_match) $contact['etag'] = self::etag2value($this->http_if_match); - $contact['photo_unchanged'] = false; // photo needs saving + // ignore photo for JSON/REST, it's not yet supported + $contact['photo_unchanged'] = Api\CalDAV::isJSON(); //false; // photo needs saving if (!($save_ok = $is_group ? $this->save_group($contact, $oldContact) : $this->bo->save($contact))) { if ($this->debug) error_log(__METHOD__."(,$id) save(".array2string($contact).") failed, Ok=$save_ok"); @@ -1051,7 +1056,17 @@ class addressbook_groupdav extends Api\CalDAV\Handler unset($tids[Api\Contacts::DELETED_TYPE]); $non_deleted_tids = array_keys($tids); } - $contact = $this->bo->read(array(self::$path_attr => $id, 'tid' => $non_deleted_tids)); + $keys = ['tid' => $non_deleted_tids]; + // with REST/JSON we only use our id, but DELETE request has neither Accept nor Content-Type header to detect JSON request + if ((string)$id === (string)(int)$id) + { + $keys['id'] = $id; + } + else + { + $keys[self::$path_attr] = $id; + } + $contact = $this->bo->read($keys); // if contact not found and accounts stored NOT like contacts, try reading it without path-extension as id if (is_null($contact) && $this->bo->so_accounts && ($c = $this->bo->read($test=basename($id, '.vcf')))) diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index 9073b17c4b..74efce702c 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -19,16 +19,16 @@ use EGroupware\Api\CalDAV\Principals; // explicit import non-namespaced classes require_once(__DIR__.'/WebDAV/Server.php'); -use EGroupware\Api\Contacts\JsContact; +use EGroupware\Api\Contacts\JsContactParseException; use HTTP_WebDAV_Server; use calendar_hooks; /** - * EGroupware: GroupDAV access + * EGroupware: CalDAV/CardDAV server * * Using a modified PEAR HTTP/WebDAV/Server class from API! * - * One can use the following url's releative (!) to http://domain.com/egroupware/groupdav.php + * One can use the following URLs relative (!) to https://example.org/egroupware/groupdav.php * * - / base of Cal|Card|GroupDAV tree, only certain clients (KDE, Apple) can autodetect folders from here * - /principals/ principal-collection-set for WebDAV ACL @@ -51,10 +51,25 @@ use calendar_hooks; * - /(resources|locations)//calendar calendar of a resource/location, if user has rights to view * - //(resource|location)- shared calendar from a resource/location * - * Shared addressbooks or calendars are only shown in in users home-set, if he subscribed to it via his CalDAV preferences! + * Shared addressbooks or calendars are only shown in the users home-set, if he subscribed to it via his CalDAV preferences! * * Calling one of the above collections with a GET request / regular browser generates an automatic index - * from the data of a allprop PROPFIND, allow to browse CalDAV/CardDAV/GroupDAV tree with a regular browser. + * from the data of a allprop PROPFIND, allow browsing CalDAV/CardDAV tree with a regular browser. + * + * Using EGroupware CalDAV/CardDAV as REST API: currently only for contacts + * =========================================== + * GET requests to collections with an "Accept: application/json" header return a JSON response similar to a PROPFIND + * following GET parameters are supported to customize the returned properties: + * - props[]= eg. props[]=getetag to return only the ETAG (multiple DAV properties can be specified) + * Default for addressbook collections is to only return address-data (JsContact), other collections return all props. + * - sync-token= to only request change since last sync-token, like rfc6578 sync-collection REPORT + * - nresults=N limit number of responses (only for sync-collection / given sync-token parameter!) + * this will return a "more-results"=true attribute and a new "sync-token" attribute to query for the next chunk + * POST requests to collection with a "Content-Type: application/json" header add new entries in addressbook or calendar collections + * (Location header in response gives URL of new resource) + * GET requests with an "Accept: application/json" header can be used to retrieve single resources / JsContact or JsCalendar schema + * PUT requests with a "Content-Type: application/json" header allow modifying single resources + * DELETE requests delete single resources * * Permanent error_log() calls should use groupdav->log($str) instead, to be send to PHP error_log() * and our request-log (prefixed with "### " after request and response, like exceptions). @@ -1403,7 +1418,8 @@ class CalDAV extends HTTP_WebDAV_Server { // for some reason OS X Addressbook (CFNetwork user-agent) uses now (DAV:add-member given with collection URL+"?add-member") // POST to the collection URL plus a UID like name component (like for regular PUT) to create new entrys - if (isset($_GET['add-member']) || Handler::get_agent() == 'cfnetwork') + if (isset($_GET['add-member']) || Handler::get_agent() == 'cfnetwork' || + substr($options['path'], -1) === '/' && self::isJSON()) { $_GET['add-member'] = ''; // otherwise we give no Location header return $this->PUT($options); @@ -2421,16 +2437,37 @@ class CalDAV extends HTTP_WebDAV_Server $headline = null; _egw_log_exception($e,$headline); - // exception handler sending message back to the client as basic auth message - $error = str_replace(array("\r", "\n"), array('', ' | '), $e->getMessage()); - header('WWW-Authenticate: Basic realm="'.$headline.': '.$error.'"'); - header('HTTP/1.1 401 Unauthorized'); - header('X-WebDAV-Status: 401 Unauthorized', true); - + if (self::isJSON()) + { + header('Content-Type: application/json; charset=utf-8'); + if (is_a($e, JsContactParseException::class)) + { + $status = '422 Unprocessable Entity'; + } + else + { + $status = '500 Internal Server Error'; + } + http_response_code((int)$status); + echo self::json_encode([ + 'error' => $e->getCode() ?: (int)$status, + 'message' => $e->getMessage(), + ]+($e->getPrevious() ? [ + 'original' => get_class($e->getPrevious()).': '.$e->getPrevious()->getMessage(), + ] : []), JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + } + else + { + // exception handler sending message back to the client as basic auth message + $error = str_replace(array("\r", "\n"), array('', ' | '), $e->getMessage()); + header('WWW-Authenticate: Basic realm="' . $headline . ': ' . $error . '"'); + header('HTTP/1.1 401 Unauthorized'); + header('X-WebDAV-Status: 401 Unauthorized', true); + } // if our own logging is active, log the request plus a trace, if enabled in server-config if (self::$request_starttime && isset(self::$instance)) { - self::$instance->_http_status = '401 Unauthorized'; // to correctly log it + self::$instance->_http_status = self::isJSON() ? $status : '401 Unauthorized'; // to correctly log it if ($GLOBALS['egw_info']['server']['exception_show_trace']) { self::$instance->log_request("\n".$e->getTraceAsString()."\n"); diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index ac2be3a756..66599fcb86 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -53,8 +53,15 @@ class JsContact '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']], + 'work' => empty($contact['email']) ? null : [ + 'email' => $contact['email'], + 'contexts' => ['work' => true], + 'pref' => 1, // as it's the more prominent in our UI + ], + 'private' => empty($contact['email_home']) ? null : [ + 'email' => $contact['email_home'], + 'contexts' => ['private' => true], + ], ]), 'phones' => self::phones($contact), 'online' => array_filter([ @@ -62,7 +69,7 @@ class JsContact 'url_home' => !empty($contact['url_home']) ? ['resource' => $contact['url_home'], 'type' => 'uri', 'contexts' => ['private' => true]] : null, ]), 'addresses' => [ - 'work' => self::address($contact, 'work', 1), + 'work' => self::address($contact, 'work', 1), // as it's the more prominent in our UI 'home' => self::address($contact, 'home'), ], 'photos' => self::photos($contact), @@ -75,7 +82,7 @@ class JsContact ]); if ($encode) { - return Api\CalDAV::json_encode($data); + return Api\CalDAV::json_encode($data, self::JSON_OPTIONS_ERROR); } return $data; } @@ -88,97 +95,126 @@ class JsContact */ 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) + try { - switch($name) + $data = json_decode($json, true, 10, 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) { - case 'uid': - if (!is_string($value) || empty($value)) - { - throw new \InvalidArgumentException("Invalid uid value!"); - } - $contact['uid'] = $value; - break; + switch ($name) + { + case 'uid': + if (!is_string($value) || empty($value)) + { + throw new \InvalidArgumentException("Missing or invalid uid value!"); + } + $contact['uid'] = $value; + break; - case 'name': - $contact += self::parseNameComponents($value); - break; + case 'name': + $contact += self::parseNameComponents($value); + break; - case 'fullName': - $contact['n_fn'] = self::parseLocalizedString($value); - break; + case 'fullName': + $contact['n_fn'] = self::parseLocalizedString($value); + break; - case 'organizations': - $contact += self::parseOrganizations($value); - break; + case 'organizations': + $contact += self::parseOrganizations($value); + break; - case 'titles': - $contact += self::parseTitles($value); - break; + case 'titles': + $contact += self::parseTitles($value); + break; - case 'emails': - $contact += self::parseEmails($value); - break; + case 'emails': + $contact += self::parseEmails($value); + break; - case 'phones': - $contact += self::parsePhones($value); - break; + case 'phones': + $contact += self::parsePhones($value); + break; - case 'online': - $contact += self::parseOnline($value); - break; + case 'online': + $contact += self::parseOnline($value); + break; - case 'addresses': - $contact += self::parseAddresses($value); - break; + case 'addresses': + $contact += self::parseAddresses($value); + break; - case 'photos': - $contact += self::parsePhotos($value); - break; + case 'photos': + $contact += self::parsePhotos($value); + break; - case 'anniversaries': - $contact += self::parseAnniversaries($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 '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 'categories': + $contact['cat_id'] = self::parseCategories($value); + break; - case 'egroupware.org/customfields': - $contact += self::parseCustomfields($value); - break; + case 'egroupware.org/customfields': + $contact += self::parseCustomfields($value); + break; - case 'egroupware.org/assistant': - $contact['assistent'] = $value; - break; + case 'egroupware.org/assistant': + $contact['assistent'] = $value; + break; - case 'egroupware.org/fileAs': - $contact['fileas'] = $value; - break; + case 'egroupware.org/fileAs': + $contact['fileas'] = $value; + break; - case 'prodId': case 'created': case 'updated': case 'kind': - break; + case 'prodId': + case 'created': + case 'updated': + case 'kind': + break; - default: - error_log(__METHOD__."() $name=".json_encode($value).' --> ignored'); - break; + default: + error_log(__METHOD__ . "() $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored'); + break; + } } } + catch (\JsonException $e) { + throw new JsContactParseException("Error parsing JSON: ".$e->getMessage(), 422, $e); + } + catch (\InvalidArgumentException $e) { + throw new JsContactParseException("Error parsing JsContact field '$name': ". + str_replace('"', "'", $e->getMessage()), 422); + } + catch (\TypeError $e) { + $message = $e->getMessage(); + if (preg_match('/must be of the type ([^ ]+), ([^ ]+) given/', $message, $matches)) + { + $message = "$matches[1] expected, but got $matches[2]: ". + str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR)); + } + throw new JsContactParseException("Error parsing JsContact field '$name': $message", 422, $e); + } + catch (\Throwable $e) { + throw new JsContactParseException("Error parsing JsContact field '$name': ". $e->getMessage(), 422, $e); + } return $contact; } + /** + * JSON options for errors thrown as exceptions + */ + const JSON_OPTIONS_ERROR = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE; + /** * Return organisation * @@ -317,6 +353,7 @@ class JsContact * Parse custom fields * * Not defined custom fields are ignored! + * Not send custom fields are set to null! * * @param array $cfs name => object with attribute data and optional type, label, values * @return array @@ -324,17 +361,18 @@ class JsContact protected static function parseCustomfields(array $cfs) { $contact = []; - $definition = Api\Storage\Customfields::get('addressbook'); + $definitions = Api\Storage\Customfields::get('addressbook'); - foreach($cfs as $name => $data) + foreach($definitions as $name => $definition) { - if (!is_array($data) || !array_key_exists('value', $data)) + $data = $cfs[$name]; + if (isset($data[$name])) { - throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data)); - } - if (isset($definition[$name])) - { - switch($definition[$name]['type']) + if (!is_array($data) || !array_key_exists('value', $data)) + { + throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data, self::JSON_OPTIONS_ERROR)); + } + switch($definition['type']) { case 'date-time': $data['value'] = Api\DateTime::to($data['value'], 'object'); @@ -347,12 +385,22 @@ class JsContact 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'] = array_intersect(array_keys($definition['values']), $data['value']); $data['value'] = $data['value'] ? implode(',', (array)$data['value']) : null; break; } $contact['#'.$name] = $data['value']; } + // set not return cfs to null + else + { + $contact['#'.$name] = null; + } + } + // report not existing cfs to log + if (($not_existing=array_diff(array_keys($cfs), array_keys($definitions)))) + { + error_log(__METHOD__."() not existing/ignored custom fields: ".implode(', ', $not_existing)); } return $contact; } @@ -454,7 +502,7 @@ class JsContact $contact += ($values=self::parseAddress($address, $id, $last_type)); if (++$n > 2) { - error_log(__METHOD__."() Ignoring $n. address id=$id: ".json_encode($address)); + error_log(__METHOD__."() Ignoring $n. address id=$id: ".json_encode($address, self::JSON_OPTIONS_ERROR)); break; } } @@ -481,7 +529,8 @@ class JsContact */ 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'; + $type = !isset($last_type) && (empty($address['contexts']['private']) || $id === 'work') || + $last_type === 'home' ? 'work' : 'home'; $last_type = $type; $prefix = $type === 'work' ? 'adr_one_' : 'adr_two_'; @@ -547,7 +596,7 @@ class JsContact { if (!is_array($component) || !is_string($component['value'])) { - throw new \InvalidArgumentException("Invalid streetComponents!"); + throw new \InvalidArgumentException("Invalid street-component: ".json_encode($component, self::JSON_OPTIONS_ERROR)); } if ($street && $last_type !== 'separator') // if we have no separator, we add a space { @@ -614,7 +663,7 @@ class JsContact { if (!is_array($phone) || !is_string($phone['phone'])) { - throw new \InvalidArgumentException("Invalid phone: " . json_encode($phone)); + throw new \InvalidArgumentException("Invalid phone: " . json_encode($phone, self::JSON_OPTIONS_ERROR)); } // first check for "our" id's if (isset(self::$phone2jscard[$id]) && !isset($contact[$id])) @@ -687,7 +736,7 @@ class JsContact { if (!is_array($value) || !is_string($value['resource'])) { - throw new \InvalidArgumentException("Invalid online resource with id '$id': ".json_encode($value)); + throw new \InvalidArgumentException("Invalid online resource with id '$id': ".json_encode($value, self::JSON_OPTIONS_ERROR)); } // check for "our" id's if (in_array($id, ['url', 'url_home'])) @@ -717,6 +766,7 @@ class JsContact /** * Parse emails object * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.3.1 * @param array $emails id => object with attribute "email" and optional "context" * @return array */ @@ -727,7 +777,7 @@ class JsContact { if (!is_array($value) || !is_string($value['email'])) { - throw new \InvalidArgumentException("Invalid email object: ".json_encode($value)); + throw new \InvalidArgumentException("Invalid email object (requires email attribute): ".json_encode($value, self::JSON_OPTIONS_ERROR)); } if (!isset($contact['email']) && $id !== 'private' && empty($value['context']['private'])) { @@ -802,21 +852,21 @@ class JsContact /** * parse nameComponents * - * @param array $values + * @param array $components * @return array */ - protected static function parseNameComponents(array $values) + protected static function parseNameComponents(array $components) { $contact = array_combine(array_values(self::$nameType2attribute), array_fill(0, count(self::$nameType2attribute), null)); - foreach($values as $value) + foreach($components as $component) { - if (empty($value['type']) || isset($value) && !is_string($value['value'])) + if (empty($component['type']) || isset($component) && !is_string($component['value'])) { - throw new \InvalidArgumentException("Invalid nameComponent!"); + throw new \InvalidArgumentException("Invalid name-component (must have type and value attributes): ".json_encode($component, self::JSON_OPTIONS_ERROR)); } - $contact[self::$nameType2attribute[$value['type']]] = $value['value']; + $contact[self::$nameType2attribute[$component['type']]] = $component['value']; } return $contact; } @@ -854,7 +904,7 @@ class JsContact (!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)); + throw new \InvalidArgumentException("Invalid anniversary object with id '$id': ".json_encode($anniversary, self::JSON_OPTIONS_ERROR)); } if (!isset($contact['bday']) && ($id === 'bday' || $anniversary['type'] === 'birth')) { @@ -862,7 +912,7 @@ class JsContact } else { - error_log(__METHOD__."() only one birtday is supported, ignoring aniversary: ".json_encode($anniversary)); + error_log(__METHOD__."() only one birtday is supported, ignoring aniversary: ".json_encode($anniversary, self::JSON_OPTIONS_ERROR)); } } return $contact; @@ -902,7 +952,7 @@ class JsContact { if (!is_string($value['value'])) { - throw new \InvalidArgumentException("Invalid localizedString: ".json_encode($value)); + throw new \InvalidArgumentException("Invalid localizedString: ".json_encode($value, self::JSON_OPTIONS_ERROR)); } return $value['value']; } @@ -960,7 +1010,7 @@ class JsContact $vCard->setAttribute('UID',$list['list_uid']); */ - return Api\CalDAV::json_encode($group); + return Api\CalDAV::json_encode($group, self::JSON_OPTIONS_ERROR); } /** @@ -975,4 +1025,4 @@ class JsContact } return $contacts; } -} \ No newline at end of file +} diff --git a/api/src/Contacts/JsContactParseException.php b/api/src/Contacts/JsContactParseException.php new file mode 100644 index 0000000000..bf5e6acf38 --- /dev/null +++ b/api/src/Contacts/JsContactParseException.php @@ -0,0 +1,27 @@ + + * @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 Throwable; + +/** + * Error parsing JsContact format + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07 + */ +class JsContactParseException extends \InvalidArgumentException +{ + public function __construct($message = "", $code = 422, Throwable $previous = null) + { + parent::__construct($message, $code ?: 422, $previous); + } +} \ No newline at end of file From 3a88aedce165bca361e9e681282f7bead77df500 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Sat, 18 Sep 2021 09:44:46 +0200 Subject: [PATCH 06/29] fix PHP Deprecated: stripos(): Non-string needles will be interpreted as strings in the future --- api/src/Storage.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/Storage.php b/api/src/Storage.php index e461391c47..6695b9b95b 100644 --- a/api/src/Storage.php +++ b/api/src/Storage.php @@ -585,9 +585,9 @@ class Storage extends Storage\Base $col = $this->table_name .'.'.array_search($col, $this->db_cols).' AS '.$col; } // Check to make sure our order by doesn't have aliases that won't work - else if (stripos($col, 'AS') !== false && $order_by) + else if (stripos($col, ' AS ') !== false && $order_by) { - list($value, $alias) = explode(' AS ', $col); + list($value, $alias) = preg_split('/ AS /i', $col); if(stripos($order_by, $alias) !== FALSE && stripos($value, $this->table_name) === FALSE) { $order_by = str_replace($alias, $value, $order_by); From e9998161a58c99db5298a1629421ee0603945b72 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Sun, 19 Sep 2021 11:09:44 +0200 Subject: [PATCH 07/29] finished REST API for contacts modulo docu and bugs ;) - JsCardGroup now used for distribution lists - responses are not in "responses" attribute (no longer in root of object) - fix sometimes empty / different members between PROPFIND/REPORT/JSON-GET and GET of group (caused by wrongly implemented limit to given AB) - JSON pretty-print only if requested by Accept: application/pretty+json - fix invalid JSON for errors (caused by opening {"responses": already sent --- .../inc/class.addressbook_groupdav.inc.php | 33 +++++++---- api/src/CalDAV.php | 42 +++++++------- api/src/CalDAV/Handler.php | 2 +- api/src/Contacts/JsContact.php | 55 ++++++++++--------- api/src/Contacts/Sql.php | 6 +- api/src/Contacts/Storage.php | 3 +- 6 files changed, 79 insertions(+), 62 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 43936b4928..75f7bd2804 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -350,7 +350,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler { foreach($lists as $list) { - $list[self::$path_attr] = $list['list_carddav_name']; + $list[self::$path_attr] = $is_jscontact ? 'list-'.$list['list_id'] : $list['list_carddav_name']; $etag = $list['list_id'].':'.$list['list_etag']; // for all-in-one addressbook, add selected ABs to etag if (isset($filter['owner']) && is_array($filter['owner'])) @@ -365,7 +365,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler ); if ($address_data) { - $content = $is_jscontact ? JsContact::getJsCardGroup($list) : $handler->getGroupVCard($list); + $content = $is_jscontact ? JsContact::getJsCardGroup($list, false) : $handler->getGroupVCard($list); $props['getcontentlength'] = bytes($content); $props['address-data'] = Api\CalDAV::mkprop(Api\CalDAV::CARDDAV, 'address-data', $content); } @@ -597,10 +597,10 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $contact; } // jsContact or vCard - if (Api\CalDAV::isJSON()) + if (($type=Api\CalDAV::isJSON())) { - $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact) : - JsContact::getJsCard($contact); + $options['data'] = $contact['list_id'] ? JsContact::getJsCardGroup($contact, $type) : + JsContact::getJsCard($contact, $type); $options['mimetype'] = ($contact['list_id'] ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD).';charset=utf-8'; } @@ -1057,16 +1057,24 @@ class addressbook_groupdav extends Api\CalDAV\Handler $non_deleted_tids = array_keys($tids); } $keys = ['tid' => $non_deleted_tids]; + // with REST/JSON we only use our id, but DELETE request has neither Accept nor Content-Type header to detect JSON request - if ((string)$id === (string)(int)$id) + if (preg_match('/^(list-)?(\d+)$/', $id, $matches)) { - $keys['id'] = $id; + if (!empty($matches[1])) + { + $keys = ['list_id' => $matches[2]]; + } + else + { + $keys['id'] = $id; + } } else { $keys[self::$path_attr] = $id; } - $contact = $this->bo->read($keys); + $contact = isset($keys['list_id']) ? false: $this->bo->read($keys); // if contact not found and accounts stored NOT like contacts, try reading it without path-extension as id if (is_null($contact) && $this->bo->so_accounts && ($c = $this->bo->read($test=basename($id, '.vcf')))) @@ -1086,12 +1094,13 @@ class addressbook_groupdav extends Api\CalDAV\Handler $limit_in_ab[] = $GLOBALS['egw_info']['user']['account_id']; } /* we are currently not syncing distribution-lists/groups to /addressbook/ as - * Apple clients use that only as directory gateway - elseif ($account_lid == 'addressbook') // /addressbook/ contains all readably contacts + * Apple clients use that only as directory gateway*/ + elseif (Api\CalDAV::isJSON() && $account_lid == 'addressbook') // /addressbook/ contains all readably contacts { $limit_in_ab = array_keys($this->bo->grants); - }*/ - if (!$contact && ($contact = $this->bo->read_lists(array('list_'.self::$path_attr => $id),'contact_uid',$limit_in_ab))) + } + if (!$contact && ($contact = $this->bo->read_lists(isset($keys['list_id']) ? $keys : + ['list_'.self::$path_attr => $id],'contact_uid',$limit_in_ab))) { $contact = array_shift($contact); $contact['n_fn'] = $contact['n_family'] = $contact['list_name']; diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index 74efce702c..a9e97289e4 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -994,9 +994,9 @@ class CalDAV extends HTTP_WebDAV_Server } /** - * Check if clients want's or sends JSON + * Check if client want or sends JSON * - * @return bool + * @return bool|string false: no json, true: application/json, string: application/(string)+json */ public static function isJSON(string $type=null) { @@ -1005,7 +1005,8 @@ class CalDAV extends HTTP_WebDAV_Server $type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST', 'PROPPATCH']) ? $_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT']; } - return (bool)preg_match('#application/([^+ ;]+\+)?json#', $type); + return preg_match('#application/(([^+ ;]+)\+)?json#', $type, $matches) ? + (empty($matches[1]) ? true : $matches[2]) : false; } /** @@ -1021,9 +1022,9 @@ class CalDAV extends HTTP_WebDAV_Server $id = $app = $user = null; if (!$this->_parse_path($options['path'],$id,$app,$user) || $app == 'principals') { - if (self::isJSON()) + if (($json = self::isJSON())) { - return $this->jsonIndex($options); + return $this->jsonIndex($options, $json === 'pretty'); } return $this->autoindex($options); } @@ -1048,7 +1049,7 @@ class CalDAV extends HTTP_WebDAV_Server { if (!$pretty) { - return self::json_encode($data, self::JSON_OPTIONS); + return json_encode($data, self::JSON_OPTIONS); } return preg_replace('/: {\n\s*(.*?)\n\s*(},?\n)/', ': { $1 $2', json_encode($data, self::JSON_OPTIONS_PRETTY)); @@ -1066,9 +1067,10 @@ class CalDAV extends HTTP_WebDAV_Server * } * * @param array $options + * @param bool $pretty =false true: pretty-print JSON * @return bool|string|void */ - protected function jsonIndex(array $options) + protected function jsonIndex(array $options, bool $pretty) { header('Content-Type: application/json; charset=utf-8'); $is_addressbook = strpos($options['path'], '/addressbook') !== false; @@ -1118,9 +1120,8 @@ class CalDAV extends HTTP_WebDAV_Server { return $ret; // no collection } - - echo "{\n"; - $prefix = " "; + // set start as prefix, to no have it in front of exceptions + $prefix = "{\n\t\"responses\": {\n"; foreach($files['files'] as $resource) { $path = $resource['path']; @@ -1129,10 +1130,6 @@ class CalDAV extends HTTP_WebDAV_Server { echo 'null'; // deleted in sync-report } - /*elseif (isset($resource['props']['address-data'])) - { - echo $resource['props']['address-data']['val']; - }*/ else { $props = $propfind_options['props'] === 'all' ? $resource['props'] : @@ -1146,17 +1143,24 @@ class CalDAV extends HTTP_WebDAV_Server { $props = current($props)['val']; } - echo self::json_encode($props); + echo self::json_encode($props, $pretty); } - $prefix = ",\n "; + $prefix = ",\n"; } - // add sync-token to response + // happens with an empty response + if ($prefix !== ",\n") + { + echo $prefix; + $prefix = ",\n"; + } + echo "\n\t}"; + // add sync-token and more-results to response if (isset($files['sync-token'])) { - echo $prefix.'"sync-token": '.json_encode(!is_callable($files['sync-token']) ? $files['sync-token'] : + echo $prefix."\t".'"sync-token": '.json_encode(!is_callable($files['sync-token']) ? $files['sync-token'] : call_user_func_array($files['sync-token'], (array)$files['sync-token-params']), JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE); } - echo "\n}\n"; + echo "\n}"; // exit now, so WebDAV::GET does NOT add Content-Type: application/octet-stream exit; diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 092c995d83..440984de0f 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -714,7 +714,7 @@ abstract class Handler { if (Api\CalDAV::isJSON()) { - $error = ",\n".' "more-results": true'; + $error = ",\n\t".'"more-results": true'; } else { diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index 66599fcb86..b6b0feeb94 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -30,7 +30,7 @@ class JsContact * Get jsCard for given contact * * @param int|array $contact - * @param bool $encode=true true: JSON encode, false: return raw data eg. from listing + * @param bool|"pretty" $encode=true true: JSON encode, "pretty": JSON encode with pretty-print, false: return raw data eg. from listing * @return string|array * @throws Api\Exception\NotFound */ @@ -68,13 +68,13 @@ class JsContact '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' => [ + 'addresses' => array_filter([ 'work' => self::address($contact, 'work', 1), // as it's the more prominent in our UI 'home' => self::address($contact, 'home'), - ], + ]), 'photos' => self::photos($contact), 'anniversaries' => self::anniversaries($contact), - 'notes' => [self::localizedString($contact['note'])], + 'notes' => empty($contact['note']) ? null : [self::localizedString($contact['note'])], 'categories' => self::categories($contact['cat_id']), 'egroupware.org/customfields' => self::customfields($contact), 'egroupware.org/assistant' => $contact['assistent'], @@ -82,7 +82,7 @@ class JsContact ]); if ($encode) { - return Api\CalDAV::json_encode($data, self::JSON_OPTIONS_ERROR); + return Api\CalDAV::json_encode($data, $encode === "pretty"); } return $data; } @@ -474,16 +474,17 @@ class JsContact $js2attr = self::$jsAddress2attr; if ($type === 'work') $js2attr += self::$jsAddress2workAttr; - $address = array_map(static function($attr) use ($contact, $prefix) + $address = array_filter(array_map(static function($attr) use ($contact, $prefix) { return $contact[$prefix.$attr]; }, $js2attr) + [ 'street' => self::streetComponents($contact[$prefix.'street'], $contact[$prefix.'street2']), + ]); + // only add contexts and preference to non-empty address + return !$address ? [] : $address+[ 'contexts' => [$type => true], 'pref' => $preference, ]; - - return array_filter($address); } /** @@ -984,33 +985,35 @@ class JsContact * Get jsCardGroup for given group * * @param int|array $group - * @return string + * @param bool|"pretty" $encode=true true: JSON, "pretty": JSON pretty-print, false: array + * @return array|string * @throws Api\Exception\NotFound */ - public static function getJsCardGroup($group) + public static function getJsCardGroup($group, $encode=true) { 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) + $data = array_filter([ + 'uid' => $group['list_uid'], + 'name' => $group['list_name'], + 'card' => self::getJsCard([ + 'uid' => $group['list_uid'], + 'n_fn' => $group['list_name'], // --> fullName + 'modified' => $group['list_modified'], // no other way to send modification date + ], false), + 'members' => [], + ]); + foreach($group['members'] as $uid) { - $vCard->setAttribute('X-ADDRESSBOOKSERVER-MEMBER','urn:uuid:'.$uid); + $data['members'][$uid] = true; } - $vCard->setAttribute('REV',Api\DateTime::to($list['list_modified'],'Y-m-d\TH:i:s\Z')); - $vCard->setAttribute('UID',$list['list_uid']); - */ - - return Api\CalDAV::json_encode($group, self::JSON_OPTIONS_ERROR); + if ($encode) + { + $data = Api\CalDAV::json_encode($data, $encode === 'pretty'); + } + return $data; } /** diff --git a/api/src/Contacts/Sql.php b/api/src/Contacts/Sql.php index 0714ea806b..481d2b9fb0 100644 --- a/api/src/Contacts/Sql.php +++ b/api/src/Contacts/Sql.php @@ -810,14 +810,14 @@ class Sql extends Api\Storage { if ($limit_in_ab) { - $in_ab_join = " JOIN $this->lists_table ON $this->lists_table.list_id=$this->ab2list_table.list_id AND $this->lists_table."; + $in_ab_join = " JOIN $this->lists_table ON $this->lists_table.list_id=$this->ab2list_table.list_id AND "; if (!is_bool($limit_in_ab)) { - $in_ab_join .= $this->db->expression($this->lists_table, array('list_owner'=>$limit_in_ab)); + $in_ab_join .= $this->db->expression($this->table_name, $this->table_name.'.', ['contact_owner' => $limit_in_ab]); } else { - $in_ab_join .= "list_owner=$this->table_name.contact_owner"; + $in_ab_join .= "$this->lists_table.list_owner=$this->table_name.contact_owner"; } } foreach($this->db->select($this->ab2list_table,"$this->ab2list_table.list_id,$this->table_name.$member_attr", diff --git a/api/src/Contacts/Storage.php b/api/src/Contacts/Storage.php index 5ea1b43f3a..0cb66f4b6d 100755 --- a/api/src/Contacts/Storage.php +++ b/api/src/Contacts/Storage.php @@ -1209,7 +1209,8 @@ class Storage * * @param array $keys column-name => value(s) pairs, eg. array('list_uid'=>$uid) * @param string $member_attr ='contact_uid' null: no members, 'contact_uid', 'contact_id', 'caldav_name' return members as that attribute - * @param boolean $limit_in_ab =false if true only return members from the same owners addressbook + * @param boolean|int|array $limit_in_ab =false if true only return members from the same owners addressbook, + * if int|array only return members from the given owners addressbook(s) * @return array with list_id => array(list_id,list_name,list_owner,...) pairs */ function read_lists($keys,$member_attr=null,$limit_in_ab=false) From 4bb8b6d4a42a8711cd516be1e9768376a3433640 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Sun, 19 Sep 2021 12:42:23 +0200 Subject: [PATCH 08/29] Initial docu for REST API --- doc/REST-CalDAV-CardDAV/README.md | 260 ++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 doc/REST-CalDAV-CardDAV/README.md diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md new file mode 100644 index 0000000000..74b6e27cf4 --- /dev/null +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -0,0 +1,260 @@ +# EGroupware CalDAV/CardDAV server and REST API + +CalDAV/CardDAV is build on HTTP and WebDAV, implementing the following additional RFCs containing documentation of the protocol: +* [rfc4791: CalDAV: Calendaring Extensions to WebDAV](https://datatracker.ietf.org/doc/html/rfc4791) +* [rfc6638: Scheduling Extensions to CalDAV](https://datatracker.ietf.org/doc/html/rfc6638) +* [rfc6352: CardDAV: vCard Extensions to WebDAV](https://datatracker.ietf.org/doc/html/rfc6352) +* [rfc6578: Collection Synchronization for WebDAV](https://datatracker.ietf.org/doc/html/rfc6578) +* many additional extensions from former Apple Calendaring Server used by Apple clients and others + +## Path / URL layout for CalDAV/CardDAV and REST is identical + +One can use the following URLs relative (!) to https://example.org/egroupware/groupdav.php + +- ```/``` base of Cal|Card|GroupDAV tree, only certain clients (KDE, Apple) can autodetect folders from here +- ```/principals/``` principal-collection-set for WebDAV ACL +- ```/principals/users//``` +- ```/principals/groups//``` +- ```//``` users home-set with +- ```//addressbook/``` addressbook of user or group given the user has rights to view it +- ```//addressbook-/``` shared addressbooks from other user or group +- ```//addressbook-accounts/``` all accounts current user has rights to see +- ```//calendar/``` calendar of user given the user has rights to view it +- ```//calendar/?download``` download whole calendar as .ics file (GET request!) +- ```//calendar-/``` shared calendar from other user or group (only current !) +- ```//inbox/``` scheduling inbox of user +- ```//outbox/``` scheduling outbox of user +- ```//infolog/``` InfoLog's of user given the user has rights to view it +- ```/addressbook/``` all addressbooks current user has rights to, announced as directory-gateway now +- ```/addressbook-accounts/``` all accounts current user has rights to see +- ```/calendar/``` calendar of current user +- ```/infolog/``` infologs of current user +- ```/(resources|locations)//calendar``` calendar of a resource/location, if user has rights to view +- ```//(resource|location)-``` shared calendar from a resource/location + +Shared addressbooks or calendars are only shown in the users home-set, if he subscribed to it via his CalDAV preferences! + +Calling one of the above collections with a GET request / regular browser generates an automatic index +from the data of a allprop PROPFIND, allow browsing CalDAV/CardDAV tree with a regular browser. + +## REST API: using EGroupware CalDAV/CardDAV server with JSON +> currently implemented only for contacts! + +Following RFCs / drafts used/planned for JSON encoding of ressources +* [draft-ietf-jmap-jscontact: JSContact: A JSON representation of contact data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) +* [rfc8984: JSCalendar: A JSON Representation of Calendar Data](https://datatracker.ietf.org/doc/html/rfc8984) + +### Supported request methods and examples + +* **GET** to collections with an ```Accept: application/json``` header return all resources (similar to WebDAV PROPFIND) +
+ Getting all entries of a given users addessbook + +``` +curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Accept: application/pretty+json" --user +{ + "responses": { + "//addressbook/1833": { + "uid": "5638-8623c4830472a8ede9f9f8b30d435ea4", + "prodId": "EGroupware Addressbook 21.1.001", + "created": "2010-10-21T09:55:42Z", + "updated": "2014-06-02T14:45:24Z", + "name": [ + { "type": "personal", "value": "Default" }, + { "type": "surname", "value": "Tester" } + ], + "fullName": { "value": "Default Tester" }, + "organizations": { + "org": { + "name": { "value": "default.org" }, + "units": { + "org_unit": { "value": "department.default.org" } + } + } + }, + "emails": { + "work": { "email": "test@test.com", "contexts": { "work": true }, "pref": 1 } + }, + "phones": { + "tel_work": { "phone": "+49 123 4567890", "pref": 1, "features": { "voice": true }, "contexts": { "work": true } }, + "tel_cell": { "phone": "012 3723567", "features": { "cell": true }, "contexts": { "work": true } } + }, + "online": { + "url": { "resource": "https://www.test.com/", "type": "uri", "contexts": { "work": true } } + }, + "notes": [ + { "value": "Test test TEST\n\\server\\share\n\\\nother\nblah" } + ], + }, + "//addressbook/list-36": { + "uid": "dfa5cac5-987b-448b-85d7-6c8b529a835c", + "name": "Example distribution list", + "card": { + "uid": "dfa5cac5-987b-448b-85d7-6c8b529a835c", + "prodId": "EGroupware Addressbook 21.1.001", + "updated": "2018-04-11T14:46:43Z", + "fullName": { "value": "Example distribution list" } + }, + "members": { + "5638-8623c4830472a8ede9f9f8b30d435ea4": true + } + } + } +} +``` +
+ + following GET parameters are supported to customize the returned properties: + - props[]= eg. props[]=getetag to return only the ETAG (multiple DAV properties can be specified) + Default for addressbook collections is to only return address-data (JsContact), other collections return all props. + - sync-token= to only request change since last sync-token, like rfc6578 sync-collection REPORT + - nresults=N limit number of responses (only for sync-collection / given sync-token parameter!) + this will return a "more-results"=true attribute and a new "sync-token" attribute to query for the next chunk + +
+ Getting just ETAGs and displayname of all contacts in a given AB + +``` +curl -i 'https://example.org/egroupware/groupdav.php//addressbook/?props[]=getetag&props[]=displayname' -H "Accept: application/pretty+json" --user +{ + "responses": { + "/addressbook/1833": { + "displayname": "Default Tester", + "getetag": "\"1833:24\"" + }, + "/addressbook/1838": { + "displayname": "Test Tester", + "getetag": "\"1838:19\"" + } + } +} +``` +
+ +
+ Start using a sync-token to get only changed entries since last sync + +#### Initial request with empty sync-token and only requesting 10 entries per chunk: +``` +curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=&nresults=10&props[]=displayname' -H "Accept: application/pretty+json" --user +{ + "responses": { + "/addressbook/2050": "Frau Margot Test-Notifikation", + "/addressbook/2384": "Test Tester", + "/addressbook/5462": "Margot Testgedöns", + "/addressbook/2380": "Frau Test Defaulterin", + "/addressbook/5474": "Noch ein Neuer", + "/addressbook/5575": "Mr New Name", + "/addressbook/5461": "Herr Hugo Kurt Müller Senior", + "/addressbook/5601": "Steve Jobs", + "/addressbook/5603": "Ralf Becker", + "/addressbook/1838": "Test Tester" + }, + "more-results": true, + "sync-token": "https://example.org/egroupware/groupdav.php/addressbook/1400867824" +} +``` +#### Requesting next chunk: +``` +curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=https://example.org/egroupware/groupdav.php/addressbook/1400867824&nresults=10&props[]=displayname' -H "Accept: application/pretty+json" --user +{ + "responses": { + "/addressbook/1833": "Default Tester", + "/addressbook/5597": "Neuer Testschnuffi", + "/addressbook/5593": "Muster Max", + "/addressbook/5628": "2. Test Contact", + "/addressbook/5629": "Testen Tester", + "/addressbook/5630": "Testen Tester", + "/addressbook/5633": "Testen Tester", + "/addressbook/5635": "Test4 Tester", + "/addressbook/5638": "Test Kontakt", + "/addressbook/5636": "Test Default" + }, + "more-results": true, + "sync-token": "https://example.org/egroupware/groupdav.php/addressbook/1427103057" +} +``` +
+ +
+ Requesting only changes since last sync + +#### ```sync-token``` from last sync need to be specified (note the null for deleted entries!) +``` +curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=https://example.org/egroupware/groupdav.php/addressbook/1400867824' -H "Accept: application/pretty+json" --user +{ + "responses": { + "/addressbook/5597": null, + "/addressbook/5593": { + "uid": "5638-8623c4830472a8ede9f9f8b30d435ea4", + "prodId": "EGroupware Addressbook 21.1.001", + "created": "2010-10-21T09:55:42Z", + "updated": "2014-06-02T14:45:24Z", + "name": [ + { "type": "personal", "value": "Default" }, + { "type": "surname", "value": "Tester" } + ], + "fullName": { "value": "Default Tester" }, +.... + } + }, + "sync-token": "https://example.org/egroupware/groupdav.php/addressbook/1427103057" +} +``` +
+ +* **GET** requests with an "Accept: application/json" header can be used to retrieve single resources / JsContact or JsCalendar schema +
+ Example GET request + +``` +curl 'https://example.org/egroupware/groupdav.php/addressbook/5593' -H "Accept: application/pretty+json" --user +{ + "uid": "5638-8623c4830472a8ede9f9f8b30d435ea4", + "prodId": "EGroupware Addressbook 21.1.001", + "created": "2010-10-21T09:55:42Z", + "updated": "2014-06-02T14:45:24Z", + "name": [ + { "type": "personal", "value": "Default" }, + { "type": "surname", "value": "Tester" } + ], + "fullName": { "value": "Default Tester" }, +.... +} +``` +
+ +* **POST** requests to collection with a "Content-Type: application/json" header add new entries in addressbook or calendar collections + (Location header in response gives URL of new resource) +
+ Example POST request + +``` +cat </addressbook/' -X POST -d @- -H "Content-Type: application/json" --user +{ + "uid": "5638-8623c4830472a8ede9f9f8b30d435ea4", + "prodId": "EGroupware Addressbook 21.1.001", + "created": "2010-10-21T09:55:42Z", + "updated": "2014-06-02T14:45:24Z", + "name": [ + { "type": "personal", "value": "Default" }, + { "type": "surname", "value": "Tester" } + ], + "fullName": { "value": "Default Tester" }, +.... +} +EOF + +HTTP/1.1 201 Created +Location: https://example.org/egroupware/groupdav.php//addressbook/1234 +``` +
+ +* **PUT** requests with a "Content-Type: application/json" header allow modifying single resources + +* **DELETE** requests delete single resources + +* one can use ```Accept: application/pretty+json``` to receive pretty-printed JSON eg. for debugging and exploring the API + +Permanent error_log() calls should use groupdav->log($str) instead, to be send to PHP error_log() +and our request-log (prefixed with "### " after request and response, like exceptions). From 2512cffe3bff805bc0e2e3faf2cf22d6b418fce2 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Mon, 20 Sep 2021 08:34:41 +0200 Subject: [PATCH 09/29] Update README.md --- doc/REST-CalDAV-CardDAV/README.md | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 74b6e27cf4..228f5f8061 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -41,14 +41,14 @@ from the data of a allprop PROPFIND, allow browsing CalDAV/CardDAV tree with a r > currently implemented only for contacts! Following RFCs / drafts used/planned for JSON encoding of ressources -* [draft-ietf-jmap-jscontact: JSContact: A JSON representation of contact data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) +* [draft-ietf-jmap-jscontact: JSContact: A JSON Representation of Contact Data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) * [rfc8984: JSCalendar: A JSON Representation of Calendar Data](https://datatracker.ietf.org/doc/html/rfc8984) ### Supported request methods and examples * **GET** to collections with an ```Accept: application/json``` header return all resources (similar to WebDAV PROPFIND)
- Getting all entries of a given users addessbook + Example: Getting all entries of a given users addessbook ``` curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Accept: application/pretty+json" --user @@ -112,7 +112,7 @@ curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Acc this will return a "more-results"=true attribute and a new "sync-token" attribute to query for the next chunk
- Getting just ETAGs and displayname of all contacts in a given AB + Example: Getting just ETAGs and displayname of all contacts in a given AB ``` curl -i 'https://example.org/egroupware/groupdav.php//addressbook/?props[]=getetag&props[]=displayname' -H "Accept: application/pretty+json" --user @@ -132,7 +132,7 @@ curl -i 'https://example.org/egroupware/groupdav.php//addressbook/?pro
- Start using a sync-token to get only changed entries since last sync + Example: Start using a sync-token to get only changed entries since last sync #### Initial request with empty sync-token and only requesting 10 entries per chunk: ``` @@ -177,9 +177,9 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=https:
- Requesting only changes since last sync + Example: Requesting only changes since last sync -#### ```sync-token``` from last sync need to be specified (note the null for deleted entries!) +#### ```sync-token``` from last sync need to be specified (note the null for a deleted resource!) ``` curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=https://example.org/egroupware/groupdav.php/addressbook/1400867824' -H "Accept: application/pretty+json" --user { @@ -203,9 +203,9 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=https: ```
-* **GET** requests with an "Accept: application/json" header can be used to retrieve single resources / JsContact or JsCalendar schema +* **GET** requests with an ```Accept: application/json``` header can be used to retrieve single resources / JsContact or JsCalendar schema
- Example GET request + Example: GET request for a single resource ``` curl 'https://example.org/egroupware/groupdav.php/addressbook/5593' -H "Accept: application/pretty+json" --user @@ -227,7 +227,7 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/5593' -H "Accept: * **POST** requests to collection with a "Content-Type: application/json" header add new entries in addressbook or calendar collections (Location header in response gives URL of new resource)
- Example POST request + Example: POST request to create a new resource ``` cat </addressbook/' -X POST -d @- -H "Content-Type: application/json" --user @@ -250,11 +250,8 @@ Location: https://example.org/egroupware/groupdav.php//addressbook/123 ```
-* **PUT** requests with a "Content-Type: application/json" header allow modifying single resources +* **PUT** requests with a ```Content-Type: application/json``` header allow modifying single resources * **DELETE** requests delete single resources * one can use ```Accept: application/pretty+json``` to receive pretty-printed JSON eg. for debugging and exploring the API - -Permanent error_log() calls should use groupdav->log($str) instead, to be send to PHP error_log() -and our request-log (prefixed with "### " after request and response, like exceptions). From 7399cca9fe9d380db55ea09cfb7b70a74d6be577 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Mon, 20 Sep 2021 08:35:31 +0200 Subject: [PATCH 10/29] Update README.md --- doc/REST-CalDAV-CardDAV/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 228f5f8061..695f21ae86 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -224,7 +224,7 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/5593' -H "Accept: ```
-* **POST** requests to collection with a "Content-Type: application/json" header add new entries in addressbook or calendar collections +* **POST** requests to collection with a ```Content-Type: application/json``` header add new entries in addressbook or calendar collections (Location header in response gives URL of new resource)
Example: POST request to create a new resource From 0a75520a01ad06a795bb464e2e97b8749299daf6 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Mon, 20 Sep 2021 13:27:31 +0200 Subject: [PATCH 11/29] * Filemanager/Sharing: create different share-token for different recipients (before recipients where added to the token) - allows to individually remove the token, otherwise new token for the other recipients need to be redistributed - sending one email to multiple recipients still creates a single share-token for all the recipients (send multiple mails if that's not desired) --- api/src/Sharing.php | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/api/src/Sharing.php b/api/src/Sharing.php index a71002aae5..7962a0ab4b 100644 --- a/api/src/Sharing.php +++ b/api/src/Sharing.php @@ -603,6 +603,8 @@ class Sharing /** * Create a new share * + * Only for shares with identical attributes AND recipients an existing share-token is returned. + * * @param string $action_id Specific type of share being created, default '' * @param string $path either path in temp_dir or vfs with optional vfs scheme * @param string $mode self::LINK: copy file in users tmp-dir or self::READABLE share given vfs file, @@ -626,35 +628,17 @@ class Sharing // Check if path is mounted somewhere that needs a password static::path_needs_password($path); - // check if file has been shared before, with identical attributes + // check if file has been shared before, with identical attributes AND recipients if (($share = static::$db->select(static::TABLE, '*', $extra+array( 'share_path' => $path, 'share_owner' => Vfs::$user, 'share_expires' => null, 'share_passwd' => null, 'share_writable'=> false, + 'share_with' => implode(',', (array)$recipients), ), __LINE__, __FILE__, Db::API_APPNAME)->fetch())) { - // if yes, just add additional recipients - $share['share_with'] = $share['share_with'] ? explode(',', $share['share_with']) : array(); - $need_save = false; - foreach((array)$recipients as $recipient) - { - if (!in_array($recipient, $share['share_with'])) - { - $share['share_with'][] = $recipient; - $need_save = true; - } - } - $share['share_with'] = implode(',', $share['share_with']); - if ($need_save) - { - static::$db->update(static::TABLE, array( - 'share_with' => $share['share_with'], - ), array( - 'share_id' => $share['share_id'], - ), __LINE__, __FILE__, Db::API_APPNAME); - } + // if yes, nothing to do } else { From 82c8ed51d2f4ca61632df3055a77a0db46aa8d1b Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Mon, 20 Sep 2021 16:01:22 +0200 Subject: [PATCH 12/29] Contacts REST API: - implement missing PUT/POST of distribution list / CardGroups - implement changes from next JsContact draft: + string plus extra localizations attribute instead of localizedString object + use ":" for vendor attributes - add/parse urn:uuid: prefix if UID is a UUID --- .../inc/class.addressbook_groupdav.inc.php | 63 +++-- api/src/CalDAV.php | 3 +- api/src/CalDAV/Handler.php | 9 +- api/src/Contacts/JsContact.php | 225 ++++++++++++++---- doc/REST-CalDAV-CardDAV/README.md | 19 +- 5 files changed, 243 insertions(+), 76 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 75f7bd2804..aecac673ef 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -61,6 +61,11 @@ class addressbook_groupdav extends Api\CalDAV\Handler */ var $home_set_pref; + /** + * Prefix for JsCardGroup id + */ + const JS_CARDGROUP_ID_PREFIX = 'list-'; + /** * Constructor * @@ -350,7 +355,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler { foreach($lists as $list) { - $list[self::$path_attr] = $is_jscontact ? 'list-'.$list['list_id'] : $list['list_carddav_name']; + $list[self::$path_attr] = $is_jscontact ? self::JS_CARDGROUP_ID_PREFIX.$list['list_id'] : $list['list_carddav_name']; $etag = $list['list_id'].':'.$list['list_etag']; // for all-in-one addressbook, add selected ABs to etag if (isset($filter['owner']) && is_array($filter['owner'])) @@ -637,9 +642,33 @@ class addressbook_groupdav extends Api\CalDAV\Handler return $oldContact; } - if (Api\CalDAV::isJSON()) + $type = null; + if (($is_json=Api\CalDAV::isJSON($type))) { - $contact = JsContact::parseJsCard($options['content']); + if (strpos($type, JsContact::MIME_TYPE_JSCARD) === false && strpos($type, JsContact::MIME_TYPE_JSCARDGROUP) === false) + { + if (!empty($id)) + { + $type = strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0 ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD; + } + else + { + $json = json_decode($options['content'], true); + $type = is_array($json['members']) ? JsContact::MIME_TYPE_JSCARDGROUP : JsContact::MIME_TYPE_JSCARD; + } + } + $contact = $type === JsContact::MIME_TYPE_JSCARD ? + JsContact::parseJsCard($options['content']) : JsContact::parseJsCardGroup($options['content']); + + if (!empty($id) && strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0) + { + $id = substr($id, strlen(self::JS_CARDGROUP_ID_PREFIX)); + } + elseif (empty($id)) + { + $contact['cardav_name'] = $contact['uid'].'.vcf'; + $contact['owner'] = $user; + } /* uncomment to return parsed data for testing header('Content-Type: application/json'); @@ -687,7 +716,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler $contactId = -1; $retval = '201 Created'; } - $is_group = $contact['##X-ADDRESSBOOKSERVER-KIND'] == 'group'; + $is_group = isset($type) && $type === JsContact::MIME_TYPE_JSCARDGROUP || $contact['##X-ADDRESSBOOKSERVER-KIND'] === 'group'; if ($oldContact && $is_group !== isset($oldContact['list_id'])) { throw new Api\Exception\AssertionFailed(__METHOD__."(,'$id',$user,'$prefix') can contact into group or visa-versa!"); @@ -756,7 +785,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler if ($this->http_if_match) $contact['etag'] = self::etag2value($this->http_if_match); // ignore photo for JSON/REST, it's not yet supported - $contact['photo_unchanged'] = Api\CalDAV::isJSON(); //false; // photo needs saving + $contact['photo_unchanged'] = $is_json; //false; // photo needs saving if (!($save_ok = $is_group ? $this->save_group($contact, $oldContact) : $this->bo->save($contact))) { if ($this->debug) error_log(__METHOD__."(,$id) save(".array2string($contact).") failed, Ok=$save_ok"); @@ -775,7 +804,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler { if (($contact = $this->bo->read_list($save_ok))) { - // re-read group to get correct etag (not dublicate etag code here) + // re-read group to get correct etag (not duplicate etag code here) $contact = $this->read($contact['list_'.self::$path_attr], $options['path']); } } @@ -787,14 +816,17 @@ class addressbook_groupdav extends Api\CalDAV\Handler } // send evtl. necessary response headers: Location, etag, ... - $this->put_response_headers($contact, $options['path'], $retval, self::$path_attr != 'id'); + $this->put_response_headers($contact, $options['path'], $retval, + // JSON uses 'id', while CardDAV uses carddav_name !== 'id' + (self::$path_attr !== 'id') === !$is_json, null, + $is_group && $is_json ? self::JS_CARDGROUP_ID_PREFIX : ''); if ($this->debug > 1) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($retval)); return $retval; } /** - * Save distribition-list / group + * Save distribution-list / group * * @param array $contact * @param array|false $oldContact @@ -813,18 +845,21 @@ class addressbook_groupdav extends Api\CalDAV\Handler $contact['owner'], null, $data))) { // update members given in $contact['##X-ADDRESSBOOKSERVER-MEMBER'] - $new_members = $contact['##X-ADDRESSBOOKSERVER-MEMBER']; - if ($new_members[1] == ':' && ($n = unserialize($new_members))) + $new_members = $contact['members'] ?: $contact['##X-ADDRESSBOOKSERVER-MEMBER']; + if (is_string($new_members) && $new_members[1] === ':' && ($n = unserialize($new_members))) { $new_members = $n['values']; } else { - $new_members = array($new_members); + $new_members = (array)$new_members; } foreach($new_members as &$uid) { - $uid = substr($uid,9); // cut off "urn:uuid:" prefix + if (substr($uid, 0, 9) === 'urn:uuid:') + { + $uid = substr($uid,9); // cut off "urn:uuid:" prefix + } } if ($oldContact) { @@ -861,7 +896,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler // reread as update of list-members updates etag and modified if (($contact = $this->bo->read_list($list_id))) { - // re-read group to get correct etag (not dublicate etag code here) + // re-read group to get correct etag (not duplicate etag code here) $contact = $this->read($contact['list_'.self::$path_attr]); } } @@ -1059,7 +1094,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler $keys = ['tid' => $non_deleted_tids]; // with REST/JSON we only use our id, but DELETE request has neither Accept nor Content-Type header to detect JSON request - if (preg_match('/^(list-)?(\d+)$/', $id, $matches)) + if (preg_match('/^('.self::JS_CARDGROUP_ID_PREFIX.')?(\d+)$/', $id, $matches)) { if (!empty($matches[1])) { diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index a9e97289e4..5a0f0dc62d 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -996,9 +996,10 @@ class CalDAV extends HTTP_WebDAV_Server /** * Check if client want or sends JSON * + * @param string &$type=null * @return bool|string false: no json, true: application/json, string: application/(string)+json */ - public static function isJSON(string $type=null) + public static function isJSON(string &$type=null) { if (!isset($type)) { diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 440984de0f..6c360a769a 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -570,8 +570,9 @@ abstract class Handler * @param int|string $retval * @param boolean $path_attr_is_name =true true: path_attr is ca(l|rd)dav_name, false: id (GroupDAV needs Location header) * @param string $etag =null etag, to not calculate it again (if != null) + * @param string $prefix='' prefix for id */ - function put_response_headers($entry, $path, $retval, $path_attr_is_name=true, $etag=null) + function put_response_headers($entry, $path, $retval, $path_attr_is_name=true, $etag=null, string $prefix='') { //error_log(__METHOD__."(".array2string($entry).", '$path', ".array2string($retval).", path_attr_is_name=$path_attr_is_name, etag=".array2string($etag).")"); // we should not return an etag here, as EGroupware never stores ical/vcard byte-by-byte @@ -589,9 +590,9 @@ abstract class Handler // send Location header only on success AND if we dont use caldav_name as path-attribute or if ((is_bool($retval) ? $retval : $retval[0] === '2') && (!$path_attr_is_name || // POST with add-member query parameter - $_SERVER['REQUEST_METHOD'] == 'POST' && isset($_GET['add-member'])) || - // in case we choose to use a different name for the resourece, give the client a hint - basename($path) !== $this->new_id) + $_SERVER['REQUEST_METHOD'] == 'POST') || + // in case we choose to use a different name for the resource, give the client a hint + basename($path) !== $prefix.$this->new_id) { $path = preg_replace('|(.*)/[^/]*|', '\1/', $path); header('Location: '.$this->base_uri.$path.$this->new_id); diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index b6b0feeb94..a5d71b647e 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -41,14 +41,14 @@ class JsContact throw new Api\Exception\NotFound(); } $data = array_filter([ - 'uid' => $contact['uid'], + 'uid' => self::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']), + 'fullName' => $contact['n_fn'], //'nickNames' => [], 'organizations' => array_filter(['org' => self::organization($contact)]), 'titles' => self::titles($contact), @@ -74,11 +74,11 @@ class JsContact ]), 'photos' => self::photos($contact), 'anniversaries' => self::anniversaries($contact), - 'notes' => empty($contact['note']) ? null : [self::localizedString($contact['note'])], + 'notes' => empty($contact['note']) ? null : [$contact['note']], 'categories' => self::categories($contact['cat_id']), - 'egroupware.org/customfields' => self::customfields($contact), - 'egroupware.org/assistant' => $contact['assistent'], - 'egroupware.org/fileAs' => $contact['fileas'], + 'egroupware.org:customfields' => self::customfields($contact), + 'egroupware.org:assistant' => $contact['assistent'], + 'egroupware.org:fileAs' => $contact['fileas'], ]); if ($encode) { @@ -119,7 +119,7 @@ class JsContact break; case 'fullName': - $contact['n_fn'] = self::parseLocalizedString($value); + $contact['n_fn'] = self::parseString($value); break; case 'organizations': @@ -156,7 +156,7 @@ class JsContact case 'notes': $contact['note'] = implode("\n", array_map(static function ($note) { - return self::parseLocalizedString($note); + return self::parseString($note); }, $value)); break; @@ -164,15 +164,15 @@ class JsContact $contact['cat_id'] = self::parseCategories($value); break; - case 'egroupware.org/customfields': + case 'egroupware.org:customfields': $contact += self::parseCustomfields($value); break; - case 'egroupware.org/assistant': + case 'egroupware.org:assistant': $contact['assistent'] = $value; break; - case 'egroupware.org/fileAs': + case 'egroupware.org:fileAs': $contact['fileas'] = $value; break; @@ -188,28 +188,47 @@ class JsContact } } } - catch (\JsonException $e) { - throw new JsContactParseException("Error parsing JSON: ".$e->getMessage(), 422, $e); - } - catch (\InvalidArgumentException $e) { - throw new JsContactParseException("Error parsing JsContact field '$name': ". - str_replace('"', "'", $e->getMessage()), 422); - } - catch (\TypeError $e) { - $message = $e->getMessage(); - if (preg_match('/must be of the type ([^ ]+), ([^ ]+) given/', $message, $matches)) - { - $message = "$matches[1] expected, but got $matches[2]: ". - str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR)); - } - throw new JsContactParseException("Error parsing JsContact field '$name': $message", 422, $e); - } catch (\Throwable $e) { - throw new JsContactParseException("Error parsing JsContact field '$name': ". $e->getMessage(), 422, $e); + self::handleExceptions($e, 'JsContact Card', $name, $value); } return $contact; } + const URN_UUID_PREFIX = 'urn:uuid:'; + const UUID_PREG = '/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i'; + + /** + * Get UID with either "urn:uuid:" prefix for UUIDs or just the text + * + * @param string $uid + * @return string + */ + protected static function uid(string $uid) + { + return preg_match(self::UUID_PREG, $uid) ? self::URN_UUID_PREFIX.$uid : $uid; + } + + /** + * Parse and optionally generate UID + * + * @param string|null $uid + * @param bool $generate_when_empty true: generate UID if empty, false: throw error + * @return string without urn:uuid: prefix + * @throws \InvalidArgumentException + */ + protected static function parseUid(string $uid=null, $generate_when_empty=false) + { + if (empty($uid) || strlen($uid) < 12) + { + if (!$generate_when_empty) + { + throw new \InvalidArgumentException("Invalid or missing UID: ".json_encode($uid)); + } + $uid = \HTTP_WebDAV_Server::_new_uuid(); + } + return strpos($uid, self::URN_UUID_PREFIX) === 0 ? substr($uid, 9) : $uid; + } + /** * JSON options for errors thrown as exceptions */ @@ -229,8 +248,8 @@ class JsContact 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'])], + 'name' => $contact['org_name'], + 'units' => empty($contact['org_unit']) ? null : ['org_unit' => $contact['org_unit']], ]); } @@ -247,10 +266,10 @@ class JsContact $contact = []; foreach($orgas as $orga) { - $contact['org_name'] = self::parseLocalizedString($orga['name']); + $contact['org_name'] = self::parseString($orga['name']); $contact['org_unit'] = implode(' ', array_map(static function($unit) { - return self::parseLocalizedString($unit); + return self::parseString($unit); }, $orga['units'])); break; } @@ -270,8 +289,8 @@ class JsContact protected static function titles(array $contact) { return array_filter([ - 'title' => self::localizedString($contact['title']), - 'role' => self::localizedString($contact['role']), + 'title' => $contact['title'], + 'role' => $contact['role'], ]); } @@ -286,21 +305,21 @@ class JsContact $contact = []; if (isset($titles[$id='title']) || isset($contact[$id='jobTitle'])) { - $contact['title'] = self::parseLocalizedString($titles[$id]); + $contact['title'] = self::parseString($titles[$id]); unset($titles[$id]); } if (isset($titles[$id='role'])) { - $contact['role'] = self::parseLocalizedString($titles[$id]); + $contact['role'] = self::parseString($titles[$id]); unset($titles[$id]); } if (!isset($contact['title']) && $titles) { - $contact['title'] = self::parseLocalizedString(array_shift($titles)); + $contact['title'] = self::parseString(array_shift($titles)); } if (!isset($contact['role']) && $titles) { - $contact['role'] = self::parseLocalizedString(array_shift($titles)); + $contact['role'] = self::parseString(array_shift($titles)); } if (count($titles)) { @@ -481,10 +500,10 @@ class JsContact 'street' => self::streetComponents($contact[$prefix.'street'], $contact[$prefix.'street2']), ]); // only add contexts and preference to non-empty address - return !$address ? [] : $address+[ + return !$address ? [] : array_filter($address+[ 'contexts' => [$type => true], 'pref' => $preference, - ]; + ]); } /** @@ -946,16 +965,12 @@ class JsContact * * We're not currently storing/allowing any localization --> they get ignored/thrown away! * - * @param array $value object with attribute "value" + * @param string $value =null * @return string */ - protected static function parseLocalizedString(array $value) + protected static function parseString(string $value=null) { - if (!is_string($value['value'])) - { - throw new \InvalidArgumentException("Invalid localizedString: ".json_encode($value, self::JSON_OPTIONS_ERROR)); - } - return $value['value']; + return $value; } /** @@ -996,10 +1011,10 @@ class JsContact throw new Api\Exception\NotFound(); } $data = array_filter([ - 'uid' => $group['list_uid'], + 'uid' => self::uid($group['list_uid']), 'name' => $group['list_name'], 'card' => self::getJsCard([ - 'uid' => $group['list_uid'], + 'uid' => self::uid($group['list_uid']), 'n_fn' => $group['list_name'], // --> fullName 'modified' => $group['list_modified'], // no other way to send modification date ], false), @@ -1007,7 +1022,7 @@ class JsContact ]); foreach($group['members'] as $uid) { - $data['members'][$uid] = true; + $data['members'][self::uid($uid)] = true; } if ($encode) { @@ -1016,6 +1031,116 @@ class JsContact return $data; } + /** + * Parse JsCard + * + * @param string $json + * @return array + */ + public static function parseJsCardGroup(string $json) + { + try + { + $data = json_decode($json, true, 10, JSON_THROW_ON_ERROR); + + if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist + + // make sure missing mandatory members give an error + $data += ['uid' => null, 'members' => null]; + $group = []; + foreach ($data as $name => $value) + { + switch ($name) + { + case 'uid': + $group['uid'] = self::parseUid($value); + break; + + case 'name': + $group['n_fn'] = $value; + break; + + case 'card': + $card = self::parseJsCard(json_encode($value, self::JSON_OPTIONS_ERROR)); + // prefer name over card-fullName + if (!empty($card['n_fn']) && empty($group['n_fn'])) + { + $group['n_fn'] = $card['n_fn']; + } + break; + + case 'members': + $group['members'] = self::parseMembers($value); + break; + + default: + error_log(__METHOD__ . "() $name=" . json_encode($value, self::JSON_OPTIONS_ERROR) . ' --> ignored'); + break; + } + } + } + catch (\Throwable $e) { + self::handleExceptions($e, 'JsContact CardGroup', $name, $value); + } + return $group; + } + + /** + * Parse members object + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-3.1.2 + * @param array $values uid => true pairs + * @return array of uid's + */ + protected static function parseMembers(array $values) + { + $members = []; + foreach($values as $uid => $value) + { + if (!is_string($uid) || $value !== true) + { + throw new \InvalidArgumentException('Invalid members object: '.json_encode($values, self::JSON_OPTIONS_ERROR)); + } + $members[] = self::parseUid($uid); + } + return $members; + } + + /** + * Map all kind of exceptions while parsing to a JsContactParseException + * + * @param \Throwable $e + * @param string $type + * @param string $name + * @param mixed $value + * @throws JsContactParseException + */ + protected static function handleExceptions(\Throwable $e, $type='JsContact', string $name, $value) + { + try { + throw $e; + } + catch (\JsonException $e) { + throw new JsContactParseException("Error parsing JSON: ".$e->getMessage(), 422, $e); + } + catch (\InvalidArgumentException $e) { + throw new JsContactParseException("Error parsing $type attribute '$name': ". + str_replace('"', "'", $e->getMessage()), 422); + } + catch (\TypeError $e) { + $message = $e->getMessage(); + if (preg_match('/must be of the type ([^ ]+( or [^ ]+)*), ([^ ]+) given/', $message, $matches)) + { + $message = "$matches[1] expected, but got $matches[3]: ". + str_replace('"', "'", json_encode($value, self::JSON_OPTIONS_ERROR)); + } + throw new JsContactParseException("Error parsing $type attribute '$name': $message", 422, $e); + } + catch (\Throwable $e) { + throw new JsContactParseException("Error parsing $type attribute '$name': ". $e->getMessage(), 422, $e); + } + } + /** * @return Api\Contacts */ diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 695f21ae86..9100633cf1 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -41,7 +41,8 @@ from the data of a allprop PROPFIND, allow browsing CalDAV/CardDAV tree with a r > currently implemented only for contacts! Following RFCs / drafts used/planned for JSON encoding of ressources -* [draft-ietf-jmap-jscontact: JSContact: A JSON Representation of Contact Data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) +* [draft-ietf-jmap-jscontact: JSContact: A JSON Representation of Contact Data](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) ([*](#implemented-changes-to-jscontact-draft-07-from-next-draft)) +* [draft-ietf-jmap-jscontact-vcard-06: JSContact: Converting from and to vCard](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-vcard/) * [rfc8984: JSCalendar: A JSON Representation of Calendar Data](https://datatracker.ietf.org/doc/html/rfc8984) ### Supported request methods and examples @@ -66,9 +67,9 @@ curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Acc "fullName": { "value": "Default Tester" }, "organizations": { "org": { - "name": { "value": "default.org" }, + "name": "default.org", "units": { - "org_unit": { "value": "department.default.org" } + "org_unit": "department.default.org" } } }, @@ -83,7 +84,7 @@ curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Acc "url": { "resource": "https://www.test.com/", "type": "uri", "contexts": { "work": true } } }, "notes": [ - { "value": "Test test TEST\n\\server\\share\n\\\nother\nblah" } + "Test test TEST\n\\server\\share\n\\\nother\nblah" ], }, "//addressbook/list-36": { @@ -96,7 +97,7 @@ curl https://example.org/egroupware/groupdav.php//addressbook/ -H "Acc "fullName": { "value": "Example distribution list" } }, "members": { - "5638-8623c4830472a8ede9f9f8b30d435ea4": true + "urn:uuid:5638-8623c4830472a8ede9f9f8b30d435ea4": true } } } @@ -194,7 +195,7 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/?sync-token=https: { "type": "personal", "value": "Default" }, { "type": "surname", "value": "Tester" } ], - "fullName": { "value": "Default Tester" }, + "fullName": "Default Tester", .... } }, @@ -218,7 +219,7 @@ curl 'https://example.org/egroupware/groupdav.php/addressbook/5593' -H "Accept: { "type": "personal", "value": "Default" }, { "type": "surname", "value": "Tester" } ], - "fullName": { "value": "Default Tester" }, + "fullName": "Default Tester", .... } ``` @@ -255,3 +256,7 @@ Location: https://example.org/egroupware/groupdav.php//addressbook/123 * **DELETE** requests delete single resources * one can use ```Accept: application/pretty+json``` to receive pretty-printed JSON eg. for debugging and exploring the API + +#### Implemented changes to [JsContact draft 07](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07) from next draft: +* localizedString type / object is removed in favor or regular String type and a [localizations object like in JsCalendar](https://datatracker.ietf.org/doc/html/rfc8984#section-4.6.1) +* [Vendor-specific Property Extensions and Values](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-1.3) use ```:``` like in JsCalendar \ No newline at end of file From 1e1b5ce93574c6185c1c179d85e841c1b7472f31 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Mon, 20 Sep 2021 18:50:51 +0200 Subject: [PATCH 13/29] fix function signature for put_response_headers --- calendar/inc/class.calendar_groupdav.inc.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/calendar/inc/class.calendar_groupdav.inc.php b/calendar/inc/class.calendar_groupdav.inc.php index d627d0fe7d..c169d7b720 100644 --- a/calendar/inc/class.calendar_groupdav.inc.php +++ b/calendar/inc/class.calendar_groupdav.inc.php @@ -1550,8 +1550,9 @@ class calendar_groupdav extends Api\CalDAV\Handler * @param int|string $retval * @param boolean $path_attr_is_name =true true: path_attr is ca(l|rd)dav_name, false: id (GroupDAV needs Location header) * @param string $etag =null etag, to not calculate it again (if != null) + * @param string $prefix ='' */ - function put_response_headers($entry, $path, $retval, $path_attr_is_name=true, $etag=null) + function put_response_headers($entry, $path, $retval, $path_attr_is_name=true, $etag=null, $prefix='') { $schedule_tag = null; if (!isset($etag)) $etag = $this->get_etag($entry, $schedule_tag); @@ -1560,7 +1561,7 @@ class calendar_groupdav extends Api\CalDAV\Handler { header('Schedule-Tag: "'.$schedule_tag.'"'); } - parent::put_response_headers($entry, $path, $retval, $path_attr_is_name, $etag); + parent::put_response_headers($entry, $path, $retval, $path_attr_is_name, $etag, $prefix); } /** From 13198e12c9f49b9c24a4b0ee88fd41ae1433b8f9 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Mon, 20 Sep 2021 18:52:25 +0200 Subject: [PATCH 14/29] Use EGW_(USER|PASSWORD) from doc/phpunix.xml instead of hardcoded demo/guest --- api/tests/CalDAVTest.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/api/tests/CalDAVTest.php b/api/tests/CalDAVTest.php index ffb32ff5fc..80fade36c8 100644 --- a/api/tests/CalDAVTest.php +++ b/api/tests/CalDAVTest.php @@ -67,15 +67,15 @@ abstract class CalDAVTest extends TestCase /** * Get HTTP client for tests * - * It will use by default the always existing user "demo" with password "guest" (use [] to NOT authenticate). + * It will use by default the user configured in phpunit.xml: demo/guest (use [] to NOT authenticate). * Additional users need to be created with $this->createUser("name"). * - * @param string|array $user_or_options ='demo' string with account_lid of user for authentication or array of options + * @param string|array $user_or_options =null string with account_lid of user for authentication or array of options * @return Client * @see http://docs.guzzlephp.org/en/v6/request-options.html * @see http://docs.guzzlephp.org/en/v6/quickstart.html */ - protected function getClient($user_or_options='demo') + protected function getClient($user_or_options=null) { if (!is_array($user_or_options)) { @@ -160,14 +160,15 @@ abstract class CalDAVTest extends TestCase /** * Get authentication information for given user to use * - * @param string $_account_lid ='demo' + * @param string $_account_lid =null default EGW_USER configured in phpunit.xml * @return array */ - protected function auth($_account_lid='demo') + protected function auth($_account_lid=null) { - if ($_account_lid === 'demo') + if (!isset($_account_lid) || $_account_lid === $GLOBALS['EGW_USER']) { - $password = 'guest'; + $_account_lid = $GLOBALS['EGW_USER']; + $password = $GLOBALS['EGW_PASSWORD']; } elseif (!isset(self::$created_users[$_account_lid])) { From 29bd7399553e1f514e704bf47a32f42a430e6370 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 20 Sep 2021 14:58:02 -0600 Subject: [PATCH 15/29] W.I.P on collabora placeholder insert --- api/js/etemplate/et2_widget_placeholder.ts | 271 ++++++++++++++++++ api/js/etemplate/etemplate2.ts | 1 + api/src/Contacts/Merge.php | 64 ++++- api/src/Etemplate/Widget/Placeholder.php | 207 +++++++++++++ api/src/Storage/Merge.php | 21 +- .../default/insert_merge_placeholder.xet | 68 +++++ 6 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 api/js/etemplate/et2_widget_placeholder.ts create mode 100644 api/src/Etemplate/Widget/Placeholder.php create mode 100644 api/templates/default/insert_merge_placeholder.xet diff --git a/api/js/etemplate/et2_widget_placeholder.ts b/api/js/etemplate/et2_widget_placeholder.ts new file mode 100644 index 0000000000..189a827d10 --- /dev/null +++ b/api/js/etemplate/et2_widget_placeholder.ts @@ -0,0 +1,271 @@ +/** + * EGroupware eTemplate2 - JS Placeholder widgets + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package etemplate + * @subpackage api + * @link https://www.egroupware.org + * @author Nathan Gray + * @copyright Nathan Gray 2021 + */ + +/*egw:uses + et2_core_inputWidget; + et2_core_valueWidget; + et2_widget_description; +*/ + +import {et2_valueWidget} from "./et2_core_valueWidget"; +import {et2_createWidget, et2_register_widget, WidgetConfig} from "./et2_core_widget"; +import {ClassWithAttributes} from "./et2_core_inheritance"; +import {et2_dialog} from "./et2_widget_dialog"; +import {et2_inputWidget} from "./et2_core_inputWidget"; +import type {egw} from "../jsapi/egw_global"; +import {et2_selectbox} from "./et2_widget_selectbox"; +import {et2_description} from "./et2_widget_description"; +import {et2_link_entry} from "./et2_widget_link"; + + +/** + * Display a dialog to choose a placeholder + */ +export class et2_placeholder_select extends et2_inputWidget +{ + static readonly _attributes : any = { + insert_callback: { + "name": "Insert callback", + "description": "Method called with the selected placeholder text", + "type": "js" + } + }; + + static placeholders : Object | null = null; + + button : JQuery; + submit_callback : any; + dialog : et2_dialog; + protected value : any; + + /** + * Constructor + * + * @param _parent + * @param _attrs + * @memberOf et2_vfsSelect + */ + constructor(_parent, _attrs? : WidgetConfig, _child? : object) + { + // Call the inherited constructor + super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_placeholder_select._attributes, _child || {})); + + // Allow no child widgets + this.supportedWidgetClasses = []; + } + + _content(_content, _callback) + { + let self = this; + if(this.dialog && this.dialog.div) + { + this.dialog.div.dialog('close'); + } + + var callback = _callback || this._buildDialog; + if(et2_placeholder_select.placeholders === null) + { + this.egw().loading_prompt('placeholder_select', true, '', 'body'); + this.egw().json( + 'EGroupware\\Api\\Etemplate\\Widget\\Placeholder::ajax_get_placeholders', + [], + function(_content) + { + this.egw().loading_prompt('placeholder_select', false); + et2_placeholder_select.placeholders = _content; + callback.apply(self, arguments); + }.bind(this) + ).sendRequest(true); + } + else + { + this._buildDialog(et2_placeholder_select.placeholders); + } + } + + /** + * Builds file navigator dialog + * + * @param {object} _data content + */ + private _buildDialog(_data) + { + + let self = this; + let buttons = [ + { + text: this.egw().lang("Insert"), + id: "submit", + } + ]; + let extra_buttons_action = {}; + + if(this.options.extra_buttons && this.options.method) + { + for(let i = 0; i < this.options.extra_buttons.length; i++) + { + delete (this.options.extra_buttons[i]['click']); + buttons.push(this.options.extra_buttons[i]); + extra_buttons_action[this.options.extra_buttons[i]['id']] = this.options.extra_buttons[i]['id']; + } + + } + buttons.push({text: this.egw().lang("Cancel"), id: "cancel", image: "cancel"}); + + let data = { + content: {app: '', group: ''}, + sel_options: {app: [], group: []} + }; + + Object.keys(_data).map((key) => + { + data.sel_options.app.push( + { + value: key, + label: this.egw().lang(key) + }); + }); + data.sel_options.group = this._get_group_options(Object.keys(_data)[0]); + data.content.app = data.sel_options.app[0].value; + data.content.group = data.sel_options.group[0].value; + + // callback for dialog + this.submit_callback = function(submit_button_id, submit_value, savemode) + { + if((submit_button_id == 'submit' || (extra_buttons_action && extra_buttons_action[submit_button_id])) && submit_value) + { + this.options.insert_callback(submit_value.placeholder_list); + return true; + } + }.bind(this); + + this.dialog = et2_createWidget("dialog", + { + callback: this.submit_callback, + title: this.options.dialog_title || this.egw().lang("Insert Placeholder"), + buttons: buttons, + minWidth: 500, + minHeight: 400, + width: 400, + value: data, + template: this.egw().webserverUrl + '/api/templates/default/insert_merge_placeholder.xet?1', + resizable: true + }, et2_dialog._create_parent('api')); + this.dialog.template.uniqueId = 'api.insert_merge_placeholder'; + + // Keep the dialog always at the top + this.dialog.div.parent().css({"z-index": 100000}); + + this.dialog.div.on('load', function(e) + { + console.log(this); + + let app = this.dialog.template.widgetContainer.getDOMWidgetById("app"); + let group = this.dialog.template.widgetContainer.getDOMWidgetById("group"); + let placeholder_list = this.dialog.template.widgetContainer.getDOMWidgetById("placeholder_list"); + let preview = this.dialog.template.widgetContainer.getDOMWidgetById("preview_placeholder"); + let entry = this.dialog.template.widgetContainer.getDOMWidgetById("entry"); + + + placeholder_list.set_select_options(this._get_placeholders(app.get_value(), group.get_value())); + + // Further setup / styling that can't be done in etemplate + this.dialog.template.DOMContainer.style.display = "flex"; + this.dialog.template.DOMContainer.firstChild.style.display = "flex"; + group.getDOMNode().size = 5; + placeholder_list.getDOMNode().size = 5; + + // Bind some handlers + group.onchange = (select_node, select_widget) => + { + console.log(this, arguments); + placeholder_list.set_select_options(this._get_placeholders(app.get_value(), group.get_value())); + preview.set_value(""); + } + placeholder_list.onchange = this._on_placeholder_select.bind(this); + entry.onchange = this._on_placeholder_select.bind(this); + }.bind(this)); + } + + doLoadingFinished() + { + this._content.call(this, null); + return true; + } + + _on_placeholder_select(node, widget : et2_selectbox | et2_link_entry) + { + let app = this.dialog.template.widgetContainer.getDOMWidgetById("app"); + let entry = this.dialog.template.widgetContainer.getDOMWidgetById("entry"); + let placeholder_list = this.dialog.template.widgetContainer.getDOMWidgetById("placeholder_list"); + let preview = this.dialog.template.widgetContainer.getDOMWidgetById("preview_placeholder"); + let preview_content = this.dialog.template.widgetContainer.getDOMWidgetById("preview_content"); + console.log(this, arguments); + preview.set_value(placeholder_list.get_value()); + if(placeholder_list.get_value() && entry.get_value() && entry.get_value().app && entry.get_value().id) + { + // Show the selected placeholder replaced with value from the selected entry + this.egw().json( + 'EGroupware\\Api\\Etemplate\\Widget\\Placeholder::ajax_fill_placeholders', + [entry.get_value().app, placeholder_list.get_value(), entry.get_value().id], + function(_content) + { + preview_content.set_value(_content); + preview_content.getDOMNode().parentNode.style.visibility = _content.trim() ? null : 'hidden'; + }.bind(this) + ).sendRequest(true); + } + else + { + preview_content.getDOMNode().parentNode.style.visibility = 'hidden'; + } + } + + _get_group_options(appname) + { + let options = []; + Object.keys(et2_placeholder_select.placeholders[appname]).map((key) => + { + options.push( + { + value: key, + label: this.egw().lang(key) + }); + }); + return options; + } + + _get_placeholders(appname, group) + { + let options = []; + Object.keys(et2_placeholder_select.placeholders[appname][group]).map((key) => + { + options.push( + { + value: key, + label: et2_placeholder_select.placeholders[appname][group][key] + }); + }); + return options; + } + + set_value(value) + { + this.value = value; + } + + getValue() + { + return this.value; + } +}; +et2_register_widget(et2_placeholder_select, ["placeholder-select"]); + diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 3ec44666a9..43b417a31a 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -62,6 +62,7 @@ import './et2_widget_image'; import './et2_widget_iframe'; import './et2_widget_file'; import './et2_widget_link'; +import './et2_widget_placeholder'; import './et2_widget_progress'; import './et2_widget_portlet'; import './et2_widget_selectAccount'; diff --git a/api/src/Contacts/Merge.php b/api/src/Contacts/Merge.php index 796e767135..4681067995 100644 --- a/api/src/Contacts/Merge.php +++ b/api/src/Contacts/Merge.php @@ -246,14 +246,21 @@ class Merge extends Api\Storage\Merge 'owner' => lang('Owner'), ) as $name => $label) { - if (in_array($name,array('start','end')) && $n&1) // main values, which should be in the first column + if(in_array($name, array('start', + 'end')) && $n & 1) // main values, which should be in the first column { echo "\n"; $n++; } - if (!($n&1)) echo ''; - echo '{{calendar/#/'.$name.'}}'.$label.''; - if ($n&1) echo "\n"; + if(!($n & 1)) + { + echo ''; + } + echo '{{calendar/#/' . $name . '}}' . $label . ''; + if($n & 1) + { + echo "\n"; + } $n++; } echo "\n"; @@ -261,6 +268,55 @@ class Merge extends Api\Storage\Merge $GLOBALS['egw']->framework->render(ob_get_clean()); } + /** + * Get a list of placeholders provided. + * + * Placeholders are grouped logically. Group key should have a user-friendly translation. + */ + public function get_placeholder_list() + { + $placeholders = []; + $group = 'contact'; + foreach($this->contacts->contact_fields as $name => $label) + { + if(in_array($name, array('tid', 'label', 'geo'))) + { + continue; + } // dont show them, as they are not used in the UI atm. + + switch($name) + { + case 'adr_one_street': + $group = 'business'; + break; + case 'adr_two_street': + $group = 'private'; + break; + case 'tel_work': + $group = 'phone'; + break; + case 'email': + case 'email_home': + $group = 'email'; + break; + case 'url': + $group = 'details'; + } + $placeholders[$group]["{{" . $name . "}}"] = $label; + if($name == 'cat_id') + { + $placeholders[$group]["{{" . $name . "}}"] = lang('Category path'); + } + } + + $group = 'customfields'; + foreach($this->contacts->customfields as $name => $field) + { + $placeholders[$group]["{{" . $name . "}}"] = $field['label']; + } + return $placeholders; + } + /** * Get insert-in-document action with optional default document on top * diff --git a/api/src/Etemplate/Widget/Placeholder.php b/api/src/Etemplate/Widget/Placeholder.php new file mode 100644 index 0000000000..6fc67d2144 --- /dev/null +++ b/api/src/Etemplate/Widget/Placeholder.php @@ -0,0 +1,207 @@ + true, + 'ajax_fill_placeholder' => true + ); + + /** + * Constructor + * + * @param string|\XMLReader $xml string with xml or XMLReader positioned on the element to construct + * @throws Api\Exception\WrongParameter + */ + public function __construct($xml = '') + { + if($xml) + { + parent::__construct($xml); + } + } + + /** + * Set up what we know on the server side. + * + * Set the options for the application select. + * + * @param string $cname + * @param array $expand values for keys 'c', 'row', 'c_', 'row_', 'cont' + */ + public function beforeSendToClient($cname, array $expand = null) + { + } + + /** + * Get the placeholders that match the given parameters. + * Default options will get all placeholders in a single request. + */ + public static function ajax_get_placeholders($apps = null, $group = null) + { + $placeholders = []; + + if(is_null($apps)) + { + $apps = ['addressbook']; + } + + foreach($apps as $appname) + { + $merge = Api\Storage\Merge::get_app_class($appname); + switch($appname) + { + case 'user': + $list = $merge->get_user_replacement_list(); + break; + default: + $list = $merge->get_placeholder_list(); + break; + } + if(!is_null($group)) + { + $list = array_intersect_key($list, $group); + } + $placeholders[$appname] = $list; + } + + $response = Api\Json\Response::get(); + $response->data($placeholders); + } + + public function ajax_fill_placeholders($app, $content, $entry) + { + $merge = Api\Storage\Merge::get_app_class($app); + $err = ""; + + switch($app) + { + case 'addressbook': + default: + $merged = $merge->merge_string($content, [$entry['id']], $err, 'text/plain'); + } + $response = Api\Json\Response::get(); + $response->data($merged); + } + + /** + * Validate input + * + * Following attributes get checked: + * - needed: value must NOT be empty + * - min, max: int and float widget only + * - maxlength: maximum length of string (longer strings get truncated to allowed size) + * - preg: perl regular expression incl. delimiters (set by default for int, float and colorpicker) + * - int and float get casted to their type + * + * @param string $cname current namespace + * @param array $expand values for keys 'c', 'row', 'c_', 'row_', 'cont' + * @param array $content + * @param array &$validated =array() validated content + */ + public function validate($cname, array $expand, array $content, &$validated = array()) + { + $form_name = self::form_name($cname, $this->id, $expand); + + if(!$this->is_readonly($cname, $form_name)) + { + $value = $value_in =& self::get_array($content, $form_name); + + // keep values added into request by other ajax-functions, eg. files draged into htmlarea (Vfs) + if((!$value || is_array($value) && !$value['to_id']) && is_array($expand['cont'][$this->id]) && !empty($expand['cont'][$this->id]['to_id'])) + { + if(!is_array($value)) + { + $value = array( + 'to_app' => $expand['cont'][$this->id]['to_app'], + ); + } + $value['to_id'] = $expand['cont'][$this->id]['to_id']; + } + + // Link widgets can share IDs, make sure to preserve values from others + $already = self::get_array($validated, $form_name); + if($already != null) + { + $value = array_merge($value, $already); + } + // Automatically do link if user selected entry but didn't click 'Link' button + $link = self::get_array($content, self::form_name($cname, $this->id . '_link_entry')); + if($this->type == 'link-to' && is_array($link) && $link['app'] && $link['id']) + { + // Do we have enough information to link automatically? + if(is_array($value) && $value['to_id']) + { + Api\Link::link($value['to_app'], $value['to_id'], $link['app'], $link['id']); + } + else + { + // Not enough information, leave it to the application + if(!is_array($value['to_id'])) + { + $value['to_id'] = array(); + } + $value['to_id'][] = $link; + } + } + + // Look for files - normally handled by ajax + $files = self::get_array($content, self::form_name($cname, $this->id . '_file')); + if(is_array($files) && !(is_array($value) && $value['to_id'])) + { + $value = array(); + if(is_dir($GLOBALS['egw_info']['server']['temp_dir']) && is_writable($GLOBALS['egw_info']['server']['temp_dir'])) + { + $path = $GLOBALS['egw_info']['server']['temp_dir'] . '/'; + } + else + { + $path = ''; + } + foreach($files as $name => $attrs) + { + if(!is_array($value['to_id'])) + { + $value['to_id'] = array(); + } + $value['to_id'][] = array( + 'app' => Api\Link::VFS_APPNAME, + 'id' => array( + 'name' => $attrs['name'], + 'type' => $attrs['type'], + 'tmp_name' => $path . $name + ) + ); + } + } + $valid =& self::get_array($validated, $form_name, true); + if(true) + { + $valid = $value; + } + //error_log($this); + //error_log(" " . array2string($valid)); + } + } +} diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index b7bd67868a..97e51e18d1 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -1572,6 +1572,25 @@ abstract class Merge return $app; } + /** + * Get the correct class for the given app + * + * @param $appname + */ + public static function get_app_class($appname) + { + if(class_exists($appname) && is_subclass_of($appname, 'EGroupware\\Api\\Storage\\Merge')) + { + $classname = "{$appname}_merge"; + $document_merge = new $classname(); + } + else + { + $document_merge = new Api\Contacts\Merge(); + } + return $document_merge; + } + /** * Get the replacements for any entry specified by app & id * @@ -1580,7 +1599,7 @@ abstract class Merge * @param string $content * @return array */ - public function get_app_replacements($app, $id, $content, $prefix='') + public function get_app_replacements($app, $id, $content, $prefix = '') { $replacements = array(); if($app == 'addressbook') diff --git a/api/templates/default/insert_merge_placeholder.xet b/api/templates/default/insert_merge_placeholder.xet new file mode 100644 index 0000000000..1dbdd7f5b4 --- /dev/null +++ b/api/templates/default/insert_merge_placeholder.xet @@ -0,0 +1,68 @@ + + + + + + From 6884902d938a9dea64db844a274cecd40fafac87 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 20 Sep 2021 16:28:20 -0600 Subject: [PATCH 16/29] W.I.P on collabora placeholder insert - better button styling - make entry select app = selected app --- api/js/etemplate/et2_widget_placeholder.ts | 40 ++++++++++++++----- .../default/insert_merge_placeholder.xet | 21 ++++++++-- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/api/js/etemplate/et2_widget_placeholder.ts b/api/js/etemplate/et2_widget_placeholder.ts index 189a827d10..8a9abc161d 100644 --- a/api/js/etemplate/et2_widget_placeholder.ts +++ b/api/js/etemplate/et2_widget_placeholder.ts @@ -24,6 +24,7 @@ import type {egw} from "../jsapi/egw_global"; import {et2_selectbox} from "./et2_widget_selectbox"; import {et2_description} from "./et2_widget_description"; import {et2_link_entry} from "./et2_widget_link"; +import type {et2_button} from "./et2_widget_button"; /** @@ -104,6 +105,7 @@ export class et2_placeholder_select extends et2_inputWidget { text: this.egw().lang("Insert"), id: "submit", + image: "export" } ]; let extra_buttons_action = {}; @@ -121,8 +123,9 @@ export class et2_placeholder_select extends et2_inputWidget buttons.push({text: this.egw().lang("Cancel"), id: "cancel", image: "cancel"}); let data = { - content: {app: '', group: ''}, - sel_options: {app: [], group: []} + content: {app: '', group: '', entry: {}}, + sel_options: {app: [], group: []}, + modifications: {outer_box: {entry: {}}} }; Object.keys(_data).map((key) => @@ -136,9 +139,11 @@ export class et2_placeholder_select extends et2_inputWidget data.sel_options.group = this._get_group_options(Object.keys(_data)[0]); data.content.app = data.sel_options.app[0].value; data.content.group = data.sel_options.group[0].value; + data.content.entry = data.modifications.outer_box.entry.only_app = data.content.app; + data.modifications.outer_box.entry.application_list = Object.keys(_data); // callback for dialog - this.submit_callback = function(submit_button_id, submit_value, savemode) + this.submit_callback = function(submit_button_id, submit_value) { if((submit_button_id == 'submit' || (extra_buttons_action && extra_buttons_action[submit_button_id])) && submit_value) { @@ -161,8 +166,6 @@ export class et2_placeholder_select extends et2_inputWidget }, et2_dialog._create_parent('api')); this.dialog.template.uniqueId = 'api.insert_merge_placeholder'; - // Keep the dialog always at the top - this.dialog.div.parent().css({"z-index": 100000}); this.dialog.div.on('load', function(e) { @@ -184,6 +187,11 @@ export class et2_placeholder_select extends et2_inputWidget placeholder_list.getDOMNode().size = 5; // Bind some handlers + app.onchange = (node, widget) => + { + group.set_select_options(this._get_group_options(widget.get_value())); + entry.set_value({app: widget.get_value()}); + } group.onchange = (select_node, select_widget) => { console.log(this, arguments); @@ -192,6 +200,16 @@ export class et2_placeholder_select extends et2_inputWidget } placeholder_list.onchange = this._on_placeholder_select.bind(this); entry.onchange = this._on_placeholder_select.bind(this); + (this.dialog.template.widgetContainer.getDOMWidgetById("insert_placeholder")).onclick = () => + { + this.options.insert_callback(this.dialog.template.widgetContainer.getDOMWidgetById("preview_placeholder").getDOMNode().textContent); + }; + (this.dialog.template.widgetContainer.getDOMWidgetById("insert_content")).onclick = () => + { + this.options.insert_callback(this.dialog.template.widgetContainer.getDOMWidgetById("preview_content").getDOMNode().textContent); + }; + + this._on_placeholder_select(); }.bind(this)); } @@ -203,19 +221,22 @@ export class et2_placeholder_select extends et2_inputWidget _on_placeholder_select(node, widget : et2_selectbox | et2_link_entry) { - let app = this.dialog.template.widgetContainer.getDOMWidgetById("app"); + let app = this.dialog.template.widgetContainer.getDOMWidgetById("app"); let entry = this.dialog.template.widgetContainer.getDOMWidgetById("entry"); let placeholder_list = this.dialog.template.widgetContainer.getDOMWidgetById("placeholder_list"); let preview = this.dialog.template.widgetContainer.getDOMWidgetById("preview_placeholder"); let preview_content = this.dialog.template.widgetContainer.getDOMWidgetById("preview_content"); - console.log(this, arguments); + + // Show the selected placeholder preview.set_value(placeholder_list.get_value()); - if(placeholder_list.get_value() && entry.get_value() && entry.get_value().app && entry.get_value().id) + preview.getDOMNode().parentNode.style.visibility = placeholder_list.get_value().trim() ? null : 'hidden'; + + if(placeholder_list.get_value() && entry.get_value()) { // Show the selected placeholder replaced with value from the selected entry this.egw().json( 'EGroupware\\Api\\Etemplate\\Widget\\Placeholder::ajax_fill_placeholders', - [entry.get_value().app, placeholder_list.get_value(), entry.get_value().id], + [app.get_value(), placeholder_list.get_value(), entry.get_value()], function(_content) { preview_content.set_value(_content); @@ -225,6 +246,7 @@ export class et2_placeholder_select extends et2_inputWidget } else { + // No value, hide the row preview_content.getDOMNode().parentNode.style.visibility = 'hidden'; } } diff --git a/api/templates/default/insert_merge_placeholder.xet b/api/templates/default/insert_merge_placeholder.xet index 1dbdd7f5b4..59d358cd20 100644 --- a/api/templates/default/insert_merge_placeholder.xet +++ b/api/templates/default/insert_merge_placeholder.xet @@ -12,14 +12,15 @@