diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 313fdb4ab9..aecac673ef 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 @@ -60,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 * @@ -72,9 +78,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) { @@ -172,12 +183,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; @@ -269,6 +280,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 @@ -283,15 +295,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); } @@ -342,7 +355,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 ? 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'])) @@ -350,16 +363,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, false) : $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); @@ -451,7 +464,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: @@ -588,11 +601,22 @@ 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 (($type=Api\CalDAV::isJSON())) + { + $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'; + } + 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 +642,68 @@ 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'])) + $type = null; + if (($is_json=Api\CalDAV::isJSON($type))) { - $content_type = explode(';', $options['content_type']); - if (count($content_type) > 1) + if (strpos($type, JsContact::MIME_TYPE_JSCARD) === false && strpos($type, JsContact::MIME_TYPE_JSCARDGROUP) === false) { - array_shift($content_type); - foreach ($content_type as $attribute) + if (!empty($id)) { - trim($attribute); - list($key, $value) = explode('=', $attribute); - switch (strtolower($key)) + $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'); + 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'])) + { + $content_type = explode(';', $options['content_type']); + if (count($content_type) > 1) + { + 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'])))) { @@ -655,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!"); @@ -723,7 +784,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'] = $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"); @@ -742,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']); } } @@ -753,15 +815,18 @@ 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, ... - $this->put_response_headers($contact, $options['path'], $retval, self::$path_attr != 'id'); + // send evtl. necessary response headers: Location, etag, ... + $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 @@ -780,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) { @@ -828,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]); } } @@ -1023,7 +1091,25 @@ 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 (preg_match('/^('.self::JS_CARDGROUP_ID_PREFIX.')?(\d+)$/', $id, $matches)) + { + if (!empty($matches[1])) + { + $keys = ['list_id' => $matches[2]]; + } + else + { + $keys['id'] = $id; + } + } + else + { + $keys[self::$path_attr] = $id; + } + $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')))) @@ -1043,12 +1129,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/js/etemplate/et2_widget_placeholder.ts b/api/js/etemplate/et2_widget_placeholder.ts new file mode 100644 index 0000000000..06d42a3245 --- /dev/null +++ b/api/js/etemplate/et2_widget_placeholder.ts @@ -0,0 +1,488 @@ +/** + * 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_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"; +import type {et2_button} from "./et2_widget_button"; + + +/** + * 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" + }, + dialog_title: { + "name": "Dialog title", + "type": "string", + "default": "Insert Placeholder" + } + }; + + static placeholders : Object | null = null; + + button : JQuery; + submit_callback : any; + dialog : et2_dialog; + protected value : any; + + protected LIST_URL = 'EGroupware\\Api\\Etemplate\\Widget\\Placeholder::ajax_get_placeholders'; + protected TEMPLATE = '/api/templates/default/insert_merge_placeholder.xet?1'; + + /** + * 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( + this.LIST_URL, + [], + 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 placeholder selection dialog + * + * @param {object} _data content + */ + protected _buildDialog(_data) + { + + let self = this; + let buttons = [ + { + text: this.egw().lang("Insert"), + id: "submit", + image: "export" + } + ]; + 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: '', entry: {}}, + sel_options: {app: [], group: []}, + modifications: {outer_box: {entry: {}}} + }; + + 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; + 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) + { + if((submit_button_id == 'submit' || (extra_buttons_action && extra_buttons_action[submit_button_id])) && submit_value) + { + this._do_insert_callback(submit_value); + 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 + this.TEMPLATE, + resizable: true + }, et2_dialog._create_parent('api')); + this.dialog.template.uniqueId = 'api.insert_merge_placeholder'; + + + this.dialog.div.on('load', this._on_template_load.bind(this)); + } + + doLoadingFinished() + { + this._content.call(this, null); + return true; + } + + /** + * Post-load of the dialog + * Bind internal events, set some things that are difficult to do in the template + */ + _on_template_load() + { + 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 + 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); + 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); + (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(); + } + + /** + * User has selected a placeholder + * Update the UI, and if they have an entry selected do the replacement and show that. + */ + _on_placeholder_select() + { + 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"); + + // Show the selected placeholder + this.set_value(placeholder_list.get_value()); + preview.set_value(placeholder_list.get_value()); + 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', + [app.get_value(), placeholder_list.get_value(), entry.get_value()], + function(_content) + { + preview_content.set_value(_content); + preview_content.getDOMNode().parentNode.style.visibility = _content.trim() ? null : 'hidden'; + }.bind(this) + ).sendRequest(true); + } + else + { + // No value, hide the row + preview_content.getDOMNode().parentNode.style.visibility = 'hidden'; + } + } + + /** + * Get the list of placeholder groups under the selected application + * @param appname + * @returns {value:string, label:string}[] + */ + _get_group_options(appname : string) + { + let options = []; + Object.keys(et2_placeholder_select.placeholders[appname]).map((key) => + { + options.push( + { + value: key, + label: this.egw().lang(key) + }); + }); + return options; + } + + /** + * Get a list of placeholders under the given application + group + * + * @param appname + * @param group + * @returns {value:string, label:string}[] + */ + _get_placeholders(appname : string, group : string) + { + 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; + } + + /** + * Get the correct insert text call the insert callback with it + * + * @param dialog_values + */ + _do_insert_callback(dialog_values : Object) + { + this.options.insert_callback(this.get_value()); + } + + set_value(value) + { + this.value = value; + } + + getValue() + { + return this.value; + } +}; +et2_register_widget(et2_placeholder_select, ["placeholder-select"]); + +/** + * Display a dialog to choose from a set list of placeholder snippets + */ +export class et2_placeholder_snippet_select extends et2_placeholder_select +{ + static readonly _attributes : any = { + dialog_title: { + "default": "Insert address" + } + }; + static placeholders = { + "addressbook": { + "addresses": { + "{{n_fn}}\n{{adr_one_street}}{{NELF adr_one_street2}}\n{{adr_one_formatted}}": "Work address", + "{{n_fn}}\n{{adr_two_street}}{{NELF adr_two_street2}}\n{{adr_two_formatted}}": "Home address", + } + } + }; + + button : JQuery; + submit_callback : any; + dialog : et2_dialog; + protected value : any; + + protected LIST_URL = 'EGroupware\\Api\\Etemplate\\Widget\\Placeholder::ajax_get_placeholders'; + protected TEMPLATE = '/api/templates/default/placeholder_snippet.xet?1'; + + /** + * Post-load of the dialog + * Bind internal events, set some things that are difficult to do in the template + */ + _on_template_load() + { + let app = this.dialog.template.widgetContainer.getDOMWidgetById("app"); + let placeholder_list = this.dialog.template.widgetContainer.getDOMWidgetById("placeholder_list"); + let preview = this.dialog.template.widgetContainer.getDOMWidgetById("preview_content"); + let entry = this.dialog.template.widgetContainer.getDOMWidgetById("entry"); + + + placeholder_list.set_select_options(this._get_placeholders("addressbook", "addresses")); + + // Further setup / styling that can't be done in etemplate + app.getInputNode().setAttribute("readonly", true); + this.dialog.template.DOMContainer.style.display = "flex"; + this.dialog.template.DOMContainer.firstChild.style.display = "flex"; + placeholder_list.getDOMNode().size = 5; + + // Bind some handlers + app.onchange = (node, widget) => + { + entry.set_value({app: widget.get_value()}); + placeholder_list.set_select_options(this._get_placeholders(app.get_value(), "addresses")); + } + placeholder_list.onchange = this._on_placeholder_select.bind(this); + entry.onchange = this._on_placeholder_select.bind(this); + + this._on_placeholder_select(); + } + + /** + * User has selected a placeholder + * Update the UI, and if they have an entry selected do the replacement and show that. + */ + _on_placeholder_select() + { + 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"); + + 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', + [app.get_value(), placeholder_list.get_value(), entry.get_value()], + function(_content) + { + this.set_value(_content); + preview_content.set_value(_content); + preview_content.getDOMNode().parentNode.style.visibility = _content.trim() ? null : 'hidden'; + }.bind(this) + ).sendRequest(true); + } + else + { + // No value, hide the row + preview_content.getDOMNode().parentNode.style.visibility = 'hidden'; + } + if(!entry.get_value()) + { + entry.search.get(0).focus(); + } + } + + /** + * Get the list of placeholder groups under the selected application + * @param appname + * @returns {value:string, label:string}[] + */ + _get_group_options(appname : string) + { + let options = []; + Object.keys(et2_placeholder_select.placeholders[appname]).map((key) => + { + options.push( + { + value: key, + label: this.egw().lang(key) + }); + }); + return options; + } + + /** + * Get a list of placeholders under the given application + group + * + * @param appname + * @param group + * @returns {value:string, label:string}[] + */ + _get_placeholders(appname : string, group : string) + { + let options = []; + Object.keys(et2_placeholder_snippet_select.placeholders[appname][group]).map((key) => + { + options.push( + { + value: key, + label: et2_placeholder_snippet_select.placeholders[appname][group][key] + }); + }); + return options; + } + + /** + * Get the correct insert text call the insert callback with it + * + * @param dialog_values + */ + _do_insert_callback(dialog_values : Object) + { + this.options.insert_callback(this.get_value()); + } + + set_value(value) + { + this.value = value; + } + + getValue() + { + return this.value; + } +}; +et2_register_widget(et2_placeholder_snippet_select, ["placeholder-snippet"]); diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 18f6db6c1a..4750ffae04 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -67,6 +67,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/js/framework/fw_base.js b/api/js/framework/fw_base.js index 9a20f39f43..65d3f6a59d 100644 --- a/api/js/framework/fw_base.js +++ b/api/js/framework/fw_base.js @@ -837,6 +837,18 @@ window.fw_base = (function(){ "use strict"; return Class.extend( window.location = _url; }, + /** + * This method only used for status app when it tries to broadcast data to users + * avoiding throwing exceptions for users whom might have no status app access + * + * @param {type} _data + * @returns {undefined} + */ + execPushBroadcastAppStatus: function(_data) + { + if (app.status) app.status.mergeContent(_data, true); + }, + /** * Sets the active framework application to the application specified by _app * diff --git a/api/js/framework/fw_desktop.js b/api/js/framework/fw_desktop.js index 4326852ec7..ee1bc87aee 100644 --- a/api/js/framework/fw_desktop.js +++ b/api/js/framework/fw_desktop.js @@ -519,18 +519,6 @@ import "sortablejs/Sortable.min.js"; } }, - /** - * This method only used for status app when it tries to broadcast data to users - * avoiding throwing exceptions for users whom might have no status app access - * - * @param {type} _data - * @returns {undefined} - */ - execPushBroadcastAppStatus: function(_data) - { - if (app.status) app.status.mergeContent(_data, true); - }, - /** * Get color scheme * @return {string|null} returns active color scheme mode or null in case browser not supporting it diff --git a/api/setup/setup.inc.php b/api/setup/setup.inc.php index ab8a48d38b..a27b31dba5 100644 --- a/api/setup/setup.inc.php +++ b/api/setup/setup.inc.php @@ -14,7 +14,7 @@ $setup_info['api']['title'] = 'EGroupware API'; $setup_info['api']['version'] = '21.1.001'; $setup_info['api']['versions']['current_header'] = '1.29'; // maintenance release in sync with changelog in doc/rpm-build/debian.changes -$setup_info['api']['versions']['maintenance_release'] = '21.1.20210723'; +$setup_info['api']['versions']['maintenance_release'] = '21.1.20210923'; $setup_info['api']['enable'] = 3; $setup_info['api']['app_order'] = 1; $setup_info['api']['license'] = 'GPL'; diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index ccf7885eed..5a0f0dc62d 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -18,15 +18,17 @@ use EGroupware\Api\CalDAV\Principals; // explicit import non-namespaced classes require_once(__DIR__.'/WebDAV/Server.php'); + +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 @@ -49,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). @@ -976,6 +993,23 @@ class CalDAV extends HTTP_WebDAV_Server parent::http_PROPFIND('REPORT'); } + /** + * 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) + { + if (!isset($type)) + { + $type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST', 'PROPPATCH']) ? + $_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT']; + } + return preg_match('#application/(([^+ ;]+)\+)?json#', $type, $matches) ? + (empty($matches[1]) ? true : $matches[2]) : false; + } + /** * GET method handler * @@ -989,6 +1023,10 @@ class CalDAV extends HTTP_WebDAV_Server $id = $app = $user = null; if (!$this->_parse_path($options['path'],$id,$app,$user) || $app == 'principals') { + if (($json = self::isJSON())) + { + return $this->jsonIndex($options, $json === 'pretty'); + } return $this->autoindex($options); } if (($handler = self::app_handler($app))) @@ -999,6 +1037,173 @@ 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 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 + * @param bool $pretty =false true: pretty-print JSON + * @return bool|string|void + */ + protected function jsonIndex(array $options, bool $pretty) + { + 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 + } + // 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']; + echo $prefix.json_encode($path, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE).': '; + if (!isset($resource['props'])) + { + echo 'null'; // deleted in sync-report + } + 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, $pretty); + } + $prefix = ",\n"; + } + // 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."\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}"; + + // 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 * @@ -1218,7 +1423,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); @@ -2236,16 +2442,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/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 54a89940fa..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); @@ -712,16 +713,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\t".'"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(' + * @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"; + + /** + * Get jsCard for given contact + * + * @param int|array $contact + * @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 + */ + public static function getJsCard($contact, $encode=true) + { + if (is_scalar($contact) && !($contact = self::getContacts()->read($contact))) + { + throw new Api\Exception\NotFound(); + } + $data = array_filter([ + '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' => !empty($contact['n_family']) || !empty($contact['n_given']) ? 'individual' : + (!empty($contact['org_name']) ? 'org' : null), + //'relatedTo' => [], + 'name' => self::nameComponents($contact), + 'fullName' => $contact['n_fn'], + //'nickNames' => [], + 'organizations' => self::organizations($contact), + 'titles' => self::titles($contact), + 'emails' => self::emails($contact), + 'phones' => self::phones($contact), + 'online' => self::online($contact), + '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' => 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'], + ]); + if ($encode) + { + return Api\CalDAV::json_encode($data, $encode === "pretty"); + } + return $data; + } + + /** + * Parse JsCard + * + * @param string $json + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return array + */ + public static function parseJsCard(string $json, bool $check_at_type=true) + { + 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 + + $contact = []; + foreach ($data as $name => $value) + { + switch ($name) + { + case 'uid': + $contact['uid'] = self::parseUid($value); + break; + + case 'name': + $contact += self::parseNameComponents($value, $check_at_type); + break; + + case 'fullName': + $contact['n_fn'] = self::parseString($value); + break; + + case 'organizations': + $contact += self::parseOrganizations($value, $check_at_type); + break; + + case 'titles': + $contact += self::parseTitles($value, $check_at_type); + break; + + case 'emails': + $contact += self::parseEmails($value, $check_at_type); + break; + + case 'phones': + $contact += self::parsePhones($value, $check_at_type); + break; + + case 'online': + $contact += self::parseOnline($value, $check_at_type); + break; + + case 'addresses': + $contact += self::parseAddresses($value, $check_at_type); + break; + + case 'photos': + $contact += self::parsePhotos($value, $check_at_type); + break; + + case 'anniversaries': + $contact += self::parseAnniversaries($value); + break; + + case 'notes': + $contact['note'] = implode("\n", array_map(static function ($note) { + return self::parseString($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, self::JSON_OPTIONS_ERROR) . ' --> ignored'); + break; + } + } + } + catch (\Throwable $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 + */ + const JSON_OPTIONS_ERROR = JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE; + + const AT_TYPE = '@type'; + const TYPE_ORGANIZATION = 'Organization'; + + /** + * Return organizations + * + * @link https://datatracker.ietf.org/doc/html/draft-ietf-jmap-jscontact-07#section-2.2.4 + * @param array $contact + * @return array + */ + protected static function organizations(array $contact) + { + $org = array_filter([ + 'name' => $contact['org_name'], + 'units' => empty($contact['org_unit']) ? null : ['org_unit' => $contact['org_unit']], + ]); + if (!$org || empty($contact['org_name'])) + { + return null; // name is mandatory + } + return ['org' => [self::AT_TYPE => self::TYPE_ORGANIZATION]+$org]; + } + + /** + * Parse Organizations + * + * As we store only one organization, the rest get lost, multiple units get concatenated by space. + * + * @param array $orgas + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return array + */ + protected static function parseOrganizations(array $orgas, bool $check_at_type=true) + { + $contact = []; + foreach($orgas as $orga) + { + if ($check_at_type && $orga[self::AT_TYPE] !== self::TYPE_ORGANIZATION) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($orga, self::JSON_OPTIONS_ERROR)); + } + $contact['org_name'] = self::parseString($orga['name']); + $contact['org_unit'] = implode(' ', array_map(static function($unit) + { + return self::parseString($unit); + }, (array)$orga['units'])); + break; + } + if (count($orgas) > 1) + { + error_log(__METHOD__."() more then 1 organization --> ignored"); + } + return $contact; + } + + const TYPE_TITLE = 'Title'; + + /** + * 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) + { + $titles = []; + foreach([ + 'title' => $contact['title'], + 'role' => $contact['role'], + ] as $id => $value) + { + if (!empty($value)) + { + $titles[$id] = [ + self::AT_TYPE => self::TYPE_TITLE, + 'title' => $value, + 'organization' => 'org', // the single organization we support use "org" as Id + ]; + } + } + return $titles; + } + + /** + * Parse titles, thought we only have "title" and "role" available for storage. + * + * @param array $titles + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return array + */ + protected static function parseTitles(array $titles, bool $check_at_type=true) + { + $contact = []; + foreach($titles as $id => $title) + { + if ($check_at_type && $title[self::AT_TYPE] !== self::TYPE_TITLE) + { + throw new \InvalidArgumentException("Missing or invalid @type: " . json_encode($title[self::AT_TYPE])); + } + if (empty($title['title']) || !is_string($title['title'])) + { + throw new \InvalidArgumentException("Missing or invalid title attribute in title with id '$id': " . json_encode($title)); + } + // put first title as "title", unless we have an Id "title" + if (!isset($contact['title']) && ($id === 'title' || !isset($titles['title']))) + { + $contact['title'] = $title['title']; + } + // put second title as "role", unless we have an Id "role" + elseif (!isset($contact['role']) && ($id === 'role' || !isset($titles['role']))) + { + $contact['role'] = $title['title']; + } + else + { + 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! + * Not send custom fields are set to null! + * + * @param array $cfs name => object with attribute data and optional type, label, values + * @return array + */ + protected static function parseCustomfields(array $cfs) + { + $contact = []; + $definitions = Api\Storage\Customfields::get('addressbook'); + + foreach($definitions as $name => $definition) + { + $data = $cfs[$name]; + if (isset($data[$name])) + { + 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'); + 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['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; + } + + /** + * 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', + ]; + + const TYPE_ADDRESS = 'Address'; + + /** + * 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_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 ? [] : array_filter([ + self::AT_TYPE => self::TYPE_ADDRESS, + ]+$address+[ + 'contexts' => [$type => true], + 'pref' => $preference, + ]); + } + + /** + * Parse addresses object containing multiple addresses + * + * @param array $addresses + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return array + */ + protected static function parseAddresses(array $addresses, bool $check_at_type=true) + { + $n = 0; + $last_type = null; + $contact = []; + foreach($addresses as $id => $address) + { + if ($check_at_type && $address[self::AT_TYPE] !== self::TYPE_ADDRESS) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($address)); + } + $contact += ($values=self::parseAddress($address, $id, $last_type)); + + if (++$n > 2) + { + error_log(__METHOD__."() Ignoring $n. address id=$id: ".json_encode($address, self::JSON_OPTIONS_ERROR)); + 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['contexts']['private']) || $id === 'work') || + $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; + } + + const TYPE_STREET_COMPONENT = 'StreetComponent'; + + /** + * 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 = []; + foreach(func_get_args() as $street) + { + if (!empty($street)) + { + if ($components) + { + $components[] = [ + self::AT_TYPE => self::TYPE_STREET_COMPONENT, + 'type' => 'separator', + 'value' => "\n", + ]; + } + $components[] = [ + self::AT_TYPE => self::TYPE_STREET_COMPONENT, + 'type' => 'name', + 'value' => $street, + ]; + } + } + 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 + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return string[] street and street2 values + */ + protected static function parseStreetComponents(array $components, bool $check_at_type=true) + { + $street = []; + $last_type = null; + foreach($components as $component) + { + if (!is_array($component) || !is_string($component['value'])) + { + throw new \InvalidArgumentException("Invalid street-component: ".json_encode($component, self::JSON_OPTIONS_ERROR)); + } + if ($check_at_type && $component[self::AT_TYPE] !== self::TYPE_STREET_COMPONENT) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($component, self::JSON_OPTIONS_ERROR)); + } + 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]], + ]; + + const TYPE_PHONE = 'Phone'; + + /** + * 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([ + self::AT_TYPE => self::TYPE_PHONE, + '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" + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return array + */ + protected static function parsePhones(array $phones, bool $check_at_type=true) + { + $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, self::JSON_OPTIONS_ERROR)); + } + if ($check_at_type && $phone[self::AT_TYPE] !== self::TYPE_PHONE) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($phone, self::JSON_OPTIONS_ERROR)); + } + // 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; + } + + const TYPE_RESOURCE = 'Resource'; + + /** + * Get online resources + * + * @param array $contact + * @return mixed + */ + protected static function online(array $contact) + { + return array_filter([ + 'url' => !empty($contact['url']) ? [ + self::AT_TYPE => self::TYPE_RESOURCE, + 'resource' => $contact['url'], + 'type' => 'uri', + 'contexts' => ['work' => true], + ] : null, + 'url_home' => !empty($contact['url_home']) ? [ + self::AT_TYPE => self::TYPE_RESOURCE, + 'resource' => $contact['url_home'], + 'type' => 'uri', + 'contexts' => ['private' => true], + ] : null, + ]); + } + + /** + * Parse online resource objects + * + * We currently only support 2 URLs, rest get's ignored! + * + * @param array $values + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return array + */ + protected static function parseOnline(array $values, bool $check_at_type) + { + $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, self::JSON_OPTIONS_ERROR)); + } + if ($check_at_type && $value[self::AT_TYPE] !== self::TYPE_RESOURCE) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($value, self::JSON_OPTIONS_ERROR)); + } + // 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; + } + + const TYPE_EMAIL = 'EmailAddress'; + + /** + * Return emails + * + * @param array $contact + * @return array + */ + protected static function emails(array $contact) + { + return array_filter([ + 'work' => empty($contact['email']) ? null : [ + self::AT_TYPE => self::TYPE_EMAIL, + 'email' => $contact['email'], + 'contexts' => ['work' => true], + 'pref' => 1, // as it's the more prominent in our UI + ], + 'private' => empty($contact['email_home']) ? null : [ + self::AT_TYPE => self::TYPE_EMAIL, + 'email' => $contact['email_home'], + 'contexts' => ['private' => true], + ], + ]); + } + + /** + * 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" + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return array + */ + protected static function parseEmails(array $emails, bool $check_at_type=true) + { + $contact = []; + foreach($emails as $id => $value) + { + if ($check_at_type && $value[self::AT_TYPE] !== self::TYPE_EMAIL) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($value, self::JSON_OPTIONS_ERROR)); + } + if (!is_array($value) || !is_string($value['email'])) + { + throw new \InvalidArgumentException("Invalid email object (requires email attribute): ".json_encode($value, self::JSON_OPTIONS_ERROR)); + } + if (!isset($contact['email']) && $id === 'work' && 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; + } + + const TYPE_FILE = 'File'; + + /** + * Return id => photo objects + * + * @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) + { + $photos = []; + if (!empty($contact['photo'])) + { + $photos['photo'] = [ + self::AT_TYPE => self::TYPE_FILE, + 'href' => $contact['photo'], + 'mediaType' => 'image/jpeg', + //'size' => '' + ]; + } + return $photos; + } + + /** + * 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 + * @ToDo + */ + protected static function parsePhotos(array $photos, bool $check_at_type) + { + foreach($photos as $id => $photo) + { + if ($check_at_type && $photo[self::AT_TYPE] !== self::TYPE_FILE) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($photo, self::JSON_OPTIONS_ERROR)); + } + error_log(__METHOD__."() importing attribute photos not yet implemented / ignored!"); + } + 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', + ]; + + const TYPE_NAME_COMPONENT = 'NameComponent'; + + /** + * 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 [ + self::AT_TYPE => self::TYPE_NAME_COMPONENT, + 'type' => $type, + 'value' => $value, + ]; + }, array_keys($components), array_values($components)); + } + + /** + * parse nameComponents + * + * @param array $components + * @return array + */ + protected static function parseNameComponents(array $components, bool $check_at_type=true) + { + $contact = array_combine(array_values(self::$nameType2attribute), + array_fill(0, count(self::$nameType2attribute), null)); + + foreach($components as $component) + { + if (empty($component['type']) || isset($component) && !is_string($component['value'])) + { + throw new \InvalidArgumentException("Invalid name-component (must have type and value attributes): ".json_encode($component, self::JSON_OPTIONS_ERROR)); + } + if ($check_at_type && $component[self::AT_TYPE] !== self::TYPE_NAME_COMPONENT) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($component, self::JSON_OPTIONS_ERROR)); + } + $contact[self::$nameType2attribute[$component['type']]] = $component['value']; + } + return $contact; + } + + const TYPE_ANNIVERSARY = 'Anniversary'; + + /** + * 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' => [ + self::AT_TYPE => self::TYPE_ANNIVERSARY, + '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 + * @param bool $check_at_type true: check if objects have their proper @type attribute + * @return array + */ + protected static function parseAnniversaries(array $anniversaries, bool $check_at_type=true) + { + $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, self::JSON_OPTIONS_ERROR)); + } + if ($check_at_type && $anniversary[self::AT_TYPE] !== self::TYPE_ANNIVERSARY) + { + throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($anniversary, self::JSON_OPTIONS_ERROR)); + } + 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, self::JSON_OPTIONS_ERROR)); + } + } + 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 string $value =null + * @return string + */ + protected static function parseString(string $value=null) + { + return $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 + * @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, $encode=true) + { + if (is_scalar($group) && !($group = self::getContacts()->read_lists($group))) + { + throw new Api\Exception\NotFound(); + } + $data = array_filter([ + 'uid' => self::uid($group['list_uid']), + 'name' => $group['list_name'], + 'card' => self::getJsCard([ + 'uid' => self::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) + { + $data['members'][self::uid($uid)] = true; + } + if ($encode) + { + $data = Api\CalDAV::json_encode($data, $encode === 'pretty'); + } + 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 + */ + protected static function getContacts() + { + static $contacts=null; + if (!isset($contacts)) + { + $contacts = new Api\Contacts(); + } + return $contacts; + } +} 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 diff --git a/api/src/Contacts/Merge.php b/api/src/Contacts/Merge.php index 796e767135..0e9a496813 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,59 @@ 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($prefix = '') + { + $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]["{{" . ($prefix ? $prefix . '/' : '') . $name . "}}"] = $label; + if($name == 'cat_id') + { + $placeholders[$group]["{{" . ($prefix ? $prefix . '/' : '') . $name . "}}"] = lang('Category path'); + } + } + + // Correctly formatted address by country / preference + $placeholders['business']['{{' . ($prefix ? $prefix . '/' : '') . 'adr_one_formatted}}'] = "Formatted business address"; + $placeholders['private']['{{' . ($prefix ? $prefix . '/' : '') . 'adr_two_formatted}}'] = "Formatted private address"; + + $group = 'customfields'; + foreach($this->contacts->customfields as $name => $field) + { + $placeholders[$group]["{{" . ($prefix ? $prefix . '/' : '') . $name . "}}"] = $field['label']; + } + return $placeholders; + } + /** * Get insert-in-document action with optional default document on top * 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) diff --git a/api/src/Etemplate/Widget/Placeholder.php b/api/src/Etemplate/Widget/Placeholder.php new file mode 100644 index 0000000000..873ef30608 --- /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', 'user']; + } + + foreach($apps as $appname) + { + $merge = Api\Storage\Merge::get_app_class($appname); + switch($appname) + { + case 'user': + $list = $merge->get_user_placeholder_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], $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/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 { 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); diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index b7bd67868a..6eb823a98b 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -283,22 +283,43 @@ abstract class Merge } break; case 'account_id': - if ($value) + if($value) { - $replacements['$$'.($prefix ? $prefix.'/':'').'account_lid$$'] = $GLOBALS['egw']->accounts->id2name($value); + $replacements['$$' . ($prefix ? $prefix . '/' : '') . 'account_lid$$'] = $GLOBALS['egw']->accounts->id2name($value); } break; } - if ($name != 'photo') $replacements['$$'.($prefix ? $prefix.'/':'').$name.'$$'] = $value; + if($name != 'photo') + { + $replacements['$$' . ($prefix ? $prefix . '/' : '') . $name . '$$'] = $value; + } } + + // Formatted address, according to preference or country + foreach(['one', 'two'] as $adr) + { + switch($this->contacts->addr_format_by_country($contact["adr_{$adr}_countryname"])) + { + case 'city_state_postcode': + $formatted_placeholder = $contact["adr_{$adr}_locality"] . " " . + $contact["adr_{$adr}_region"] . " " . $contact["adr_{$adr}_postalcode"]; + break; + case 'postcode_city': + default: + $formatted_placeholder = $contact["adr_{$adr}_postalcode"] . ' ' . $contact["adr_{$adr}_locality"]; + break; + } + $replacements['$$adr_' . $adr . '_formatted$$'] = $formatted_placeholder; + } + // set custom fields, should probably go to a general method all apps can use // need to load all cfs for $ignore_acl=true foreach($ignore_acl ? Customfields::get('addressbook', true) : $this->contacts->customfields as $name => $field) { - $name = '#'.$name; + $name = '#' . $name; if(!array_key_exists($name, $contact) || !$contact[$name]) { - $replacements['$$'.($prefix ? $prefix.'/':'').$name.'$$'] = ''; + $replacements['$$' . ($prefix ? $prefix . '/' : '') . $name . '$$'] = ''; continue; } // Format date cfs per user Api\Preferences @@ -1572,6 +1593,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 +1620,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') @@ -2550,34 +2590,50 @@ abstract class Merge { return array( // Link to current entry - 'link' => lang('URL of current record'), - 'link/href' => lang('HTML link to the current record'), - 'link/title' => lang('Link title of current record'), + 'link' => lang('URL of current record'), + 'link/href' => lang('HTML link to the current record'), + 'link/title' => lang('Link title of current record'), // Link system - linked entries - 'links' => lang('Titles of any entries linked to the current record, excluding attached files'), - 'links/href' => lang('HTML links to any entries linked to the current record, excluding attached files'), - 'links/url' => lang('URLs of any entries linked to the current record, excluding attached files'), - 'attachments' => lang('List of files linked to the current record'), - 'links_attachments' => lang('Links and attached files'), - 'links/[appname]' => lang('Links to specified application. Example: {{links/infolog}}'), + 'links' => lang('Titles of any entries linked to the current record, excluding attached files'), + 'links/href' => lang('HTML links to any entries linked to the current record, excluding attached files'), + 'links/url' => lang('URLs of any entries linked to the current record, excluding attached files'), + 'attachments' => lang('List of files linked to the current record'), + 'links_attachments' => lang('Links and attached files'), + 'links/[appname]' => lang('Links to specified application. Example: {{links/infolog}}'), // General information - 'date' => lang('Date'), - 'user/n_fn' => lang('Name of current user, all other contact fields are valid too'), - 'user/account_lid' => lang('Username'), + 'date' => lang('Date'), + 'user/n_fn' => lang('Name of current user, all other contact fields are valid too'), + 'user/account_lid' => lang('Username'), // Merge control - 'pagerepeat' => lang('For serial letter use this tag. Put the content, you want to repeat between two Tags.'), - 'label' => lang('Use this tag for addresslabels. Put the content, you want to repeat, between two tags.'), - 'labelplacement' => lang('Tag to mark positions for address labels'), + 'pagerepeat' => lang('For serial letter use this tag. Put the content, you want to repeat between two Tags.'), + 'label' => lang('Use this tag for addresslabels. Put the content, you want to repeat, between two tags.'), + 'labelplacement' => lang('Tag to mark positions for address labels'), // Commands - 'IF fieldname' => lang('Example {{IF n_prefix~Mr~Hello Mr.~Hello Ms.}} - search the field "n_prefix", for "Mr", if found, write Hello Mr., else write Hello Ms.'), - 'NELF' => lang('Example {{NELF role}} - if field role is not empty, you will get a new line with the value of field role'), - 'NENVLF' => lang('Example {{NENVLF role}} - if field role is not empty, set a LF without any value of the field'), - 'LETTERPREFIX' => lang('Example {{LETTERPREFIX}} - Gives a letter prefix without double spaces, if the title is emty for example'), + 'IF fieldname' => lang('Example {{IF n_prefix~Mr~Hello Mr.~Hello Ms.}} - search the field "n_prefix", for "Mr", if found, write Hello Mr., else write Hello Ms.'), + 'NELF' => lang('Example {{NELF role}} - if field role is not empty, you will get a new line with the value of field role'), + 'NENVLF' => lang('Example {{NENVLF role}} - if field role is not empty, set a LF without any value of the field'), + 'LETTERPREFIX' => lang('Example {{LETTERPREFIX}} - Gives a letter prefix without double spaces, if the title is emty for example'), 'LETTERPREFIXCUSTOM' => lang('Example {{LETTERPREFIXCUSTOM n_prefix title n_family}} - Example: Mr Dr. James Miller'), ); } + + /** + * Get a list of placeholders for the current user + */ + public function get_user_placeholder_list($prefix = '') + { + $contacts = new Api\Contacts\Merge(); + $replacements = $contacts->get_placeholder_list(($prefix ? $prefix . '/' : '') . 'user'); + unset($replacements['details']['{{' . ($prefix ? $prefix . '/' : '') . 'user/account_id}}']); + $replacements['account'] = [ + '{{' . ($prefix ? $prefix . '/' : '') . 'user/account_id}}' => 'Account ID', + '{{' . ($prefix ? $prefix . '/' : '') . 'user/account_lid}}' => 'Login ID' + ]; + + return $replacements; + } } diff --git a/api/templates/default/etemplate2.css b/api/templates/default/etemplate2.css index 0d7aabc9e3..920bb88b02 100644 --- a/api/templates/default/etemplate2.css +++ b/api/templates/default/etemplate2.css @@ -2315,6 +2315,10 @@ div.et2_toolbar_more h.ui-accordion-header.header_list-short { height: 24px; margin-top: 0px; } +div.et2_toolbar_more h.ui-accordion-header.header_list-short span.ui-accordion-header-icon.ui-icon.ui-icon-triangle-1-e { + top: 0px; + left: auto; +} .et2_toolbar_more .ui-accordion-header-active.header_list-short.ui-state-active span.ui-accordion-header-icon { background-position: bottom !important; margin-top: 2px; diff --git a/api/templates/default/insert_merge_placeholder.xet b/api/templates/default/insert_merge_placeholder.xet new file mode 100644 index 0000000000..59d358cd20 --- /dev/null +++ b/api/templates/default/insert_merge_placeholder.xet @@ -0,0 +1,81 @@ + + + + + + diff --git a/api/templates/default/placeholder_snippet.xet b/api/templates/default/placeholder_snippet.xet new file mode 100644 index 0000000000..504c51c7d7 --- /dev/null +++ b/api/templates/default/placeholder_snippet.xet @@ -0,0 +1,72 @@ + + + + + + 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])) { 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); } /** diff --git a/calendar/lang/egw_it.lang b/calendar/lang/egw_it.lang index dd41c416cd..1a26a76c1d 100644 --- a/calendar/lang/egw_it.lang +++ b/calendar/lang/egw_it.lang @@ -91,6 +91,7 @@ calendar menu calendar it Menù Agenda calendar preferences calendar it Preferenze Agenda calendar settings admin it Impostazioni Agenda calendar-fieldname calendar it Campi-Agenda +can not send any notifications because notifications app is not installed! calendar it Impossibile inviare notifiche poiché l'app notifiche non è installata! can't add alarms in the past !!! calendar it Non si può aggiungere sveglie nel passato !!! can't aquire lock! calendar it Impossibile acquisire lo sblocco! canceled calendar it Cancellato @@ -109,7 +110,6 @@ close the window calendar it Chiudi la finestra compose a mail to all participants after the event is saved calendar it componi una email per tutti i partecipanti dopo che l'evento è stato salvato configuration settings calendar it Impostazioni di configurazione conflict calendar it Conflitto -copy of: calendar it Copia di: copy this event calendar it Copia questo evento copy your changes to the clipboard, %1reload the entry%2 and merge them. calendar it Copia le tue modifiche negli appunti, %1aggiorna l'inserimento%2 e uniscili. countries calendar it Nazioni @@ -159,6 +159,7 @@ delete series calendar it Cancella serie delete this alarm calendar it Cancella questa sveglia delete this event calendar it Cancella questo evento delete this exception calendar it Cancella questa eccezione +delete this meeting for all participants calendar it Elimina questa riunione per tutti i partecipanti delete this recurrence calendar it Elimina la ricorrenza delete this series of recurring events calendar it Cancella questa serie di eventi ricorrenti deleted calendar it Cancellati @@ -172,12 +173,15 @@ displays this calendar view on the home page (page you get when you enter egroup distribution list calendar it Lista di distribuzione do not import conflicting events calendar it Non importare eventi in conflitto do not include events of group members calendar it Non includere eventi di membri del gruppo +do not notify externals (non-users) about this event calendar it NON notificare agli esterni (non-utenti) quest'evento do you really want to change the start of this series? if you do, the original series will be terminated as of %1 and a new series for the future reflecting your changes will be created. calendar it Confermi di voler modificare l'inizio di questa serie? Se confermi allora la serie originale terminerà da %1 e verrà creata una nuova serie nel futuro, che riflette queste modifiche. do you want a weekview with or without weekend? calendar it Vuoi una vista settimanale con o senza weekend? do you want non-egroupware participants of events you created to be automatically notified about new or changed appointments? calendar it Vuoi notificare a partecipanti che non sono utenti di Egroupware circa i nuovi eventi oppure le modifiche degli appuntamenti? +do you want responses from events you created, but are not participating in? calendar it Vuoi ricevere risposte ad eventi che hai creato, ma ai quali non parteciperai? do you want to be notified about changes of appointments you modified? calendar it Vuoi ricevere notifiche sui cambiamenti in appuntamenti creati da te? do you want to be notified about new or changed appointments? you are not notified about changes you made yourself.
you can limit the notifications to certain changes only. each item includes all notifications listed above. all modifications include changes of title, description, participants, but no participant responses. if the owner of an event requested any notifcations, he will always get participant responses like acceptions or rejections too. calendar it Vuoi una notifica per i nuovi appuntamenti o per quelli cambiati? Sarai avvertito dei cambiamenti da te effettuati.
Puoi limitare la notifica su alcuni cambiamenti. TUTTE LE VOCI include tutte le notifiche elencate sopra di esso. TUTTE LE MODIFICHE include il cambiamento del titolo, della descrizione, dei partecipanti, ma non delle risposte dei partecipanti. Se il creatore dell'evento richiede ogni notifica, avrà sempre le risposte dei partecipanti sia gli accetti che i rifiuti. do you want to be notified about new or changed appointments? you be notified about changes you make yourself.
you can limit the notifications to certain changes only. each item includes all the notification listed above it. all modifications include changes of title, description, participants, but no participant responses. if the owner of an event requested any notifcations, he will always get the participant responses like acceptions and rejections too. calendar it Vuoi ricevere una notifica sugli appuntamenti nuovi o modificati? Sarai notificato anche delle modifiche che apporti tu. Puoi limitare le notifiche a solo alcuni cambiamenti. Ogni elemento include tutte le notifiche sopra di essa.Tutte le modifiche includono cambiamenti di titolo, descrizione, partecipanti, ma non le risposte dei partecipanti. Se il proprietario della scheda di un evento ha richiesto delle notifiche, riceverà sempre le risposte dei partecipanti. +do you want to be notified about participant responses from events you created, but are not participating in? calendar it Vuoi ricevere una notifica sulla risposta dei partecipanti ad eventi che hai creato, ma ai quali non parteciperai? do you want to edit this event as an exception or the whole series? calendar it Vuoi modificare questo evento come eccezione oppure l'intera ricorrenza? do you want to keep the series exceptions in your calendar? calendar it Vuoi mantenere le modifiche alla serie nell'agenda? do you want to receive a regularly summary of your appointments via email?
the summary is sent to your standard email-address on the morning of that day or on monday for weekly summarys.
it is only sent when you have any appointments on that day or week. calendar it Vuoi ricevere regolarmente il resoconto dei tuoi appuntamenti via e-mail?
Il resoconto sarà mandato al tuo indirizzo e-mail standard ogni mattina o ogni Lunedì per il resoconto settimanale.
Sarà mandato solo se avrai un appuntamento per quel giorno o per quella settimana. @@ -201,6 +205,7 @@ enddate / -time of the meeting, eg. for more then one day calendar it Data/ora f enddate of the export calendar it Data finale dell'esportazione ends calendar it finisce error adding the alarm calendar it Errore aggiungendo la sveglia +error notifying %1 calendar it Errore notificando %1 error saving the event! calendar it Errore durante il salvataggio dell'evento error: can't delete original series! calendar it Errore! Impossibile eliminare la serie originale! error: duration of event longer then recurrence interval! calendar it Errore! Durata dell'evento maggiore dell'intervallo di ricorrenza @@ -369,6 +374,7 @@ name of current user, all other contact fields are valid too calendar it Nome de name of the day of the week (ex: monday) calendar it Nome del giorno della settimana name of the week (ex: monday), available for the first entry inside each day of week or daily table inside the selected range. calendar it Nome della settimana, p.es. lunedì, disponibile per la prima voce in ogni giorno della settimana oppure tabella giornaliera entro l'intervallo selezionato. needs action calendar it Richiede azione +never notify externals (non-users) about events i create calendar it Non notificare mai agli esterni (non utenti) gli eventi che creo new calendar it Nuovo new event category calendar it Nuova categoria di evento new event participants calendar it Nuovi partecipanti all'evento @@ -395,8 +401,12 @@ notification messages for uninvited participants calendar it Messaggi di notific notification messages for your alarms calendar it Messaggi di notifica per i tuoi allarmi notification messages for your responses calendar it Messaggi di notifica per le tue risposte notification settings calendar it Impostazioni di notifica +notify calendar it Notifica +notify all externals (non-users) about this event calendar it Notifica a tutti gli esterni (non-utenti) quest'evento +notify externals calendar it Notifica agli esterni notify non-egroupware users about event updates calendar it Invia notifiche a utenti che non appartengono a EGroupware, circa gli aggiornamenti number of records to read (%1) calendar it Numero di record da leggere (%1) +number of resources to be booked calendar it Numero di risorse da prenotare number of weeks to show calendar it Numero di settimane da mostrare observance rule calendar it Regola da osservare occurence calendar it Occorrenze @@ -441,6 +451,7 @@ prevent deleting of entries admin it Previeni la eliminazione degli inserimenti previous calendar it precedente private and global public calendar it Privato e Pubblico Globale private and group public calendar it Private e Pubblico per il Gruppo +private event calendar it Evento Privato private only calendar it Solo privato quantity calendar it Quantità quick add calendar it Immissione rapida @@ -458,6 +469,7 @@ recurring event calendar it evento ricorrente regular edit calendar it Modifica regolare reject calendar it Rifiuta rejected calendar it Rifiutato +removes the event from my calendar calendar it Rimuove l'evento dal mio calendario repeat days calendar it Giorni di ripetizione repeat the event until which date (empty means unlimited) calendar it ripeti l'evento fino a che data (vuoto significa illimitato) repeat type calendar it Tipo di ripetizione @@ -499,6 +511,8 @@ select who should get the alarm calendar it Seleziona chi dovrà avere la svegli selected range calendar it Intervallo di selezione selected users/groups calendar it Utenti/Gruppi selezionati send meetingrequest to all participants after the event is saved calendar it Invia richiesta meeting a tutti i partecipanti a evento salvato. +send notifications calendar it Invia notifiche +send notifications to users right now calendar it Invia notifiche ai partecipanti adesso series deleted calendar it Serie cancellata set new events to private calendar it Imposta il nuovo evento come privato setting lock time calender admin it Impostazione di blocco di dati di orario per l'agenda (predefinito 1 sec.) @@ -510,6 +524,7 @@ should the number of weeks be shown on top of the calendar calendar it Il numero should the number of weeks be shown on top of the calendar (only if offset = 0) calendar it Il numero delle settimane dovrebbe essere visualizzato in cima all'agenda (solo se offset=0) should the planner display an empty row for users or categories without any appointment. calendar it Il pianificatore dovrebbe mostrare una riga vuota per gli utenti o le categorie senza appuntamenti. should the status of the event-participants (accept, reject, ...) be shown in brackets after each participants name ? calendar it Lo stato dei partecipanti agli eventi (accettato, rifiutato, ...) sarà visualizzato tra parentesi dopo il nome? +show %1 from %2 calendar it Mostra %1 da %2 show a calendar title calendar it Mostra titolo dell'agenda show all events, as if they were private calendar it Mostra tutti gli eventi come se fossero privati show all status incl. rejected events calendar it Mostra tutti gli stati compresi gli eventi rifiutati @@ -533,6 +548,7 @@ show this month calendar it mostra questo mese show this week calendar it mostra questa settimana show year and age calendar it Mostra anno e età single event calendar it singolo evento +single participant calendar it Singolo partecipante specify where url of the day links to calendar it Specifica l'URL del collegamento del giorno start calendar it Inizio start date/time calendar it Data/Ora Inizio @@ -542,6 +558,7 @@ startdate / -time calendar it Data/ora iniziale startdate and -time of the search calendar it Data/ora iniziale della ricerca startdate of the export calendar it Data iniziale dell'esportazione startrecord calendar it Primo Record +status calendar it Stato status already applied calendar it Stato già applicato status changed calendar it Stato modificato status for all future scheduled days changed calendar it Stato per tutti gli eventi futuri modificato @@ -610,6 +627,7 @@ update calendar view immediately when navigation calendar in sidebox is changed update timezones common it Aggiorna i fusi orari updated calendar it Aggiornato use end date calendar it Usa data di termine +use event tz calendar it Usa fuso orario dell'evento use given criteria: calendar it Utilizzare i criteri indicati: use quick add or full edit dialog when creating a new event calendar it Utilizzare l'immissione rapida o completa quando si crea un nuovo evento use range-views to optimise calendar queries? calendar it Usare le viste a intervalli per ottimizzare le interrogazioni dell'agenda? diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md new file mode 100644 index 0000000000..8ad841ad9c --- /dev/null +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -0,0 +1,268 @@ +# 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) +([* see at end of document](#implemented-changes-from-jscontact-draft-08)) +* [draft-ietf-jmap-jscontact-vcard: 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 + +* **GET** to collections with an ```Accept: application/json``` header return all resources (similar to WebDAV PROPFIND) +
+ Example: 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": "NameComponent", "type": "personal", "value": "Default" }, + { "@type": "NameComponent", "type": "surname", "value": "Tester" } + ], + "fullName": { "value": "Default Tester" }, + "organizations": { + "org": { + "@type": "Organization", + "name": "default.org", + "units": { + "org_unit": "department.default.org" + } + } + }, + "emails": { + "work": { "@type": "EmailAddress", "email": "test@test.com", "contexts": { "work": true }, "pref": 1 } + }, + "phones": { + "tel_work": { "@type": "Phone", "phone": "+49 123 4567890", "pref": 1, "features": { "voice": true }, "contexts": { "work": true } }, + "tel_cell": { "@type": "Phone", "phone": "012 3723567", "features": { "cell": true }, "contexts": { "work": true } } + }, + "online": { + "url": { "@type": "Resource", "resource": "https://www.test.com/", "type": "uri", "contexts": { "work": true } } + }, + "notes": [ + "Test test TEST\n\\server\\share\n\\\nother\nblah" + ], + }, + "//addressbook/list-36": { + "uid": "urn:uuid:dfa5cac5-987b-448b-85d7-6c8b529a835c", + "name": "Example distribution list", + "card": { + "uid": "urn:uuid: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 + +
+ 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 +{ + "responses": { + "/addressbook/1833": { + "displayname": "Default Tester", + "getetag": "\"1833:24\"" + }, + "/addressbook/1838": { + "displayname": "Test Tester", + "getetag": "\"1838:19\"" + } + } +} +``` +
+ +
+ 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: +``` +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" +} +``` +
+ +
+ Example: Requesting only changes since last sync + +#### ```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 +{ + "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": "NameComponent", "type": "personal", "value": "Default" }, + { "@type": "NameComponent", "type": "surname", "value": "Tester" } + ], + "fullName": "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 for a single resource + +``` +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": "NameComponent", "type": "personal", "value": "Default" }, + { "@type": "NameComponent", "type": "surname", "value": "Tester" } + ], + "fullName": "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 to create a new resource + +``` +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": "@type": "NameComponent", "personal", "value": "Default" }, + { "type": "@type": "NameComponent", "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 + +#### Implemented [changes from JsContact draft 08](https://github.com/rsto/draft-stepanek-jscontact/compare/draft-ietf-jmap-jscontact-08): +* 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 +* top-level objects need a ```@type``` attribute with one of the following values: +```NameComponent```, ```Organization```, ```Title```, ```Phone```, ```Resource```, ```File```, ```ContactLanguage```, +```Address```, ```StreetComponent```, ```Anniversary```, ```PersonalInformation``` diff --git a/doc/docker/development/Dockerfile b/doc/docker/development/Dockerfile index fc62584e29..5e3233f67d 100644 --- a/doc/docker/development/Dockerfile +++ b/doc/docker/development/Dockerfile @@ -64,7 +64,7 @@ else \ RESULT=$?; \ fi; \ rm composer-setup.php; \ -composer.phar self-update 1.10.22; \ +composer.phar self-update --1; \ exit $RESULT' \ # disable certificate checks for LDAP as most LDAP and AD servers have no "valid" cert && echo "TLS_REQCERT never" >> /etc/ldap/ldap.conf diff --git a/doc/docker/development/build.sh b/doc/docker/development/build.sh index 1d1e541d08..7c9b34d485 100755 --- a/doc/docker/development/build.sh +++ b/doc/docker/development/build.sh @@ -7,7 +7,7 @@ RECOMMENDED_PHP_VERSION=7.4 PHP_VERSION=${1:-7.4} -TAG=$(docker run --rm -i --entrypoint bash $REPO/$IMAGE:$PHP_VERSION -c "apt update && apt search php$PHP_VERSION-fpm" 2>/dev/null|grep php$PHP_VERSION-fpm|sed "s|^php$PHP_VERSION-fpm/focal.*\([78]\.[0-9]*\.[0-9]*\).*|\1|g") +TAG=$(docker run --rm -i --entrypoint bash $REPO/$IMAGE:$PHP_VERSION -c "apt update && apt search php$PHP_VERSION-fpm" 2>/dev/null|grep php$PHP_VERSION-fpm|sed "s|^php$PHP_VERSION-fpm/focal[^ ]* \([78]\.[0-9]*\.[0-9]*\).*|\1|g") test -z "$TAG" && { echo "Can't get new tag of $REPO/$IMAGE container --> existing" exit 1 diff --git a/doc/docker/fpm/Dockerfile b/doc/docker/fpm/Dockerfile index 8ba24b726f..fca1526dab 100644 --- a/doc/docker/fpm/Dockerfile +++ b/doc/docker/fpm/Dockerfile @@ -68,8 +68,8 @@ else \ fi; \ rm composer-setup.php; \ exit $RESULT' \ - # build EGroupware - && composer.phar self-update 1.10.22 \ + # build EGroupware (Horde using a PEAR repo requires Composer v1) + && composer.phar self-update --1 \ && cd /usr/share \ && [ $PHP_VERSION = "8.0" ] && COMPOSER_EXTRA=--ignore-platform-reqs || true \ && composer.phar create-project $COMPOSER_EXTRA --prefer-dist --no-scripts --no-dev egroupware/egroupware:$VERSION \ diff --git a/doc/rpm-build/checkout-build-archives.php b/doc/rpm-build/checkout-build-archives.php index 096756f1d0..99d4098783 100755 --- a/doc/rpm-build/checkout-build-archives.php +++ b/doc/rpm-build/checkout-build-archives.php @@ -359,11 +359,13 @@ function do_tag() update_composer_json_version($config['tag']); // might require more then one run, as pushed tags need to be picked up by packagist $output = $ret = null; - $timeout = $retries = 10; + $timeout = 30; + $try = 0; $cmd = $config['composer'].' update --ignore-platform-reqs --no-dev egroupware/\*'; - for($try=1; $try <= $retries && run_cmd($cmd, $output, $try < $retries ? 2 : null); ++$try) + while(run_cmd($cmd, $output, 2)) { - error_log("Retry $try/$retries in $timeout seconds ..."); + ++$try; + error_log("$try. retry in $timeout seconds ..."); sleep($timeout); } run_cmd($config['git'].' commit -m '.escapeshellarg('Updating dependencies for '.$config['tag']).' composer.{json,lock}'); diff --git a/doc/rpm-build/debian.changes b/doc/rpm-build/debian.changes index 26d1ca19ea..0921b8a732 100644 --- a/doc/rpm-build/debian.changes +++ b/doc/rpm-build/debian.changes @@ -1,3 +1,42 @@ +egroupware-docker (21.1.20210923) hardy; urgency=low + + * smallPART: many new features and improvements for the new semester: + - push changes in course, videos, participants and comments instantly to all online users + - new video-controls for speed, skip 10s forward/back, full width, speaker control + - add staff rolls: tutor (readonly teacher access), teacher and co-admin (identical to owner) + - split students in groups and limit visibility of comments to their group, staff can filter by group + - allow students to pick a nickname displayed to fellow students, always show staff and students to staff with full name + - videos are ordered now alphabetic, use eg. 1st, 2nd, ... as prefix to force videos to a desired order + - record date and time student subscribes or unsubscribes a course + - CSV comment export adds user retweeting in front of his comment + - fix questions with same start-time got identical question-numbers + - fix LTI automatic registration and interactive content-selection (LTI 1.3 eg. for Moodle 3.10+) + + content-selection shows all available courses, not just subscribed ones + + fix not working content selection if there is only a single 1.3 config (no 1.0 one) + + fix not working buttons to change between video, questions and scores + * Filemanager: added user-interface to mount WebDAV or SMB shares + * Filemanager/Sharing: create different share-token for different recipients (before recipients where added to the token) + * Kanban: Boards now remember collapsed columns & swimlanes + * Kanban: improve formatting for small columns + * Kanban: Fix Infolog field "Projectmanager" did not load in board edit Column & Listen dialogs after first being set. + * Calendar: Activate links in location & description in event tooltip + * Knowledge Base: fix pasting/dragging image into htmlarea editor does not work + * Addressbook/Mobile theme: fix opening contacts fails in mobile theme + * Tracker: Add configuration for defaulting group (all queues and queue specific) + * Mail: fix updating/deleting mail accounts does not refresh the mail tree no more + * Api: Fix some merge files were opened in browser instead of downloaded + * Api: Fix entry list stops scrolling if a row is updated while the tab is not visible + * Api: Fix changes in history log had a hash instead of user if the change was made after a share was opened. + * PostgreSQL/Addressbook: fix SQL error deleting a contact finally + * PostgreSQL/Addressbook/All Apps: fix SQL error in history tab if there are attachments + * PostgreSQL: fix SQL error when accessing eg. InfoLog + * Calendar/Addressbook/InfoLog: no longer allow to immediately delete entries as it breaks CalDAV/CardDAV sync + * Setup: support uninstalling automatic installed apps (no more reinstalling next update) + * EPL/Univention: support permanent uninstalling EPL features / downgrade to CE + * Chrome 94.0.4606.54: fix CSP error clicking on sidebox menu + + -- Ralf Becker Thu, 23 Sep 2021 09:59:41 +0200 + egroupware-docker (21.1.20210723) hardy; urgency=low * Security Update: all 21.1 users should upgrade ASAP, 20.1 and below is not affected diff --git a/filemanager/lang/egw_it.lang b/filemanager/lang/egw_it.lang index 23f3fcc846..9ab8034bb5 100644 --- a/filemanager/lang/egw_it.lang +++ b/filemanager/lang/egw_it.lang @@ -51,6 +51,7 @@ cannot create directory because it begins or ends in a space filemanager it Non cautiously rejecting to remove folder '%1'! filemanager it Eliminazione della cartella %1 rigettata per precauzione! check all filemanager it Seleziona tutto check virtual filesystem common it Controlla il filesystem virtuale +classic filemanager it Barra degli strumenti predefinita clear search filemanager it Reimposta la ricerca clipboard is empty! filemanager it Gli appunti sono vuoti! collab editor settings filemanager it Impostazioni Collab Editor @@ -80,8 +81,10 @@ current directory filemanager it Cartella corrente custom fields filemanager it Campi personalizzati cut filemanager it Taglia cut to clipboard filemanager it Taglia e metti negli appunti +default action on double-click filemanager it Azione predefinita al doppio click default behavior is no. the link will not be shown, but you are still able to navigate to this location, or configure this paricular location as startfolder or folderlink. filemanager it Predefinito: No. Il collegamento non verrà mostrato, ma potrai comunque raggiungere questo percorso, oppure configurarlo come cartella di avvio o collegamento a cartella. default document to insert entries filemanager it Documento predefinito per l'inserimento +defines how to handle double click action on a document file. images are always opened in the expose-view and emails with email application. all other mime-types are handled by the browser itself. filemanager it Definisce come gestire di doppio-click su un file. Le immagini sono sempre aperte in modalità visualizzazione e le email con l'applicazione E-Mail. Tutti gli altri file sono gestiti dal browser come oggetti MIME-type. defines how to open a merge print document filemanager it Definisce il modo di apertura di un documento per la stampa unione delete all older versions and deleted files older then %s days filemanager it Eliminare tutte le versioni passate e le cartelle più vecchie di %s giorni delete these files or directories? filemanager it Eliminare i file o le cartelle? @@ -105,14 +108,17 @@ do you want more information about epl subscription? common it Si vorrebbero ric do you want to overwrite existing file %1 in directory %2? filemanager it Vuoi sovrascrivere il file esistente %1 nella cartella %2? do you want to overwrite the existing file %1? filemanager it Vuoi sovrascrivere il file esistente? download filemanager it Download +download documents filemanager it scarica documenti edit comments filemanager it Modifica commenti edit settings filemanager it Modifica le impostazioni +edit share filemanager it modifica condivisione enable filemanager it Abilita enable versioning for given mountpoint filemanager it Abilitare il controllo delle versioni per il mountpoint selezionato enter setup user and password filemanager it Inserisci il nome utente e la password dell'utente di setup. enter setup user and password to get root rights filemanager it Inserisci il nome utente e la password dell'utente di setup per ottenere diritti amministrativi (root) enter the complete vfs path to specify a fast access link to a folder filemanager it Inserisci il percorso VFS completo per specificare un link veloce verso una cartella enter the complete vfs path to specify your desired start folder. filemanager it Inserisci il percorso VFS completo per specificare la tua cartella di avvio +enter your file name filemanager it inserisci il nome del file error adding the acl! filemanager it Errore nell'aggiunta della regola ACL! error creating symlink to target %1! filemanager it Errore di creazione del link simbolico verso %1! error deleting the acl entry! filemanager it Errore di eliminazione della regola ACL! @@ -167,6 +173,8 @@ go home filemanager it Vai alla Home go to filemanager it Vai A go to your home directory filemanager it Vai alla tua directory Home go up filemanager it Vai su +hidden upload filemanager it Caricamento nascosto +hidden uploads filemanager it Caricamenti nascosti id filemanager it ID if you specify a directory (full vfs path) here, %1 displays an action for each document. that action allows to download the specified document with the %1 data inserted. filemanager it Se specifichi una directory (percorso vfs completo), %1 mostrerà l'azione per ogni documento. Quella azione permette di scaricare il documento specificato con i dati %1 inseriti if you specify a document (full vfs path) here, %1 displays an extra document icon for each entry. that icon allows to download the specified document with the data inserted. filemanager it Se specifichi una directory (percorso vfs completo), %1 mostrerà un'icona in più per ogni voce. Quell'icona permette di scaricare il documento specificato con i dati inseriti @@ -207,6 +215,8 @@ noone filemanager it Nessuno older versions or deleted files filemanager it Versioni vecchie o file eliminati only owner can rename or delete the content filemanager it Solo il proprietario può rinominare o eliminare il contenuto open filemanager it Apri +open documents with collabora, if permissions are given filemanager it apri i documenti utilizzando Collabora, se hai i permessi +open odt documents with collabeditor filemanager it Apri i file di testo (odt) con CollabEditor operation filemanager it Operazione paste link filemanager it Incolla collegamento path %1 not found or not a directory! filemanager it Il percorso %1 non è stato trovato, oppure non è una directory! @@ -249,6 +259,7 @@ select file to upload in current directory filemanager it Seleziona il file da c select file(s) from vfs common it Seleziona file dal sistema virtuale di file (vfs) setting for document merge saved. filemanager it Impostazione stampa unione salvata. share files filemanager it Condividi file +share link copied into clipboard filemanager it Condividi il collegamento copiato negli appunti shared files filemanager it File condivisi shared with filemanager it Condivisi con show filemanager it Mostra @@ -278,6 +289,7 @@ there's already a file with that name! filemanager it Esiste già un file con qu tile view filemanager it Vista affiancata to overwrite the existing file store again. filemanager it Per sovrascrivere l'esistente salva di nuovo total files filemanager it File totali +ui mode filemanager it Barra degli strumenti predefinita under directory filemanager it sotto la cartella unmount filemanager it Smonta unused space filemanager it Spazio inutilizzato @@ -291,6 +303,7 @@ user color indicator filemanager it Indicatore colorato utente users and groups filemanager it Utenti e gruppo versioning filemanager it Registrazione Versioni di file vfs mounts and versioning common it Montaggi VFS e registrazione versioni +view link filemanager it Mostra Indirizzo webdav link copied into clipboard filemanager it Link webDAV copiato negli appunti who should be allowed to finally delete deleted files or old versions of a file: filemanager it Chi dovrebbe poter eliminare definitivamente file o vecchie versioni di file: writable share link filemanager it Collegamento condiviso in scrittura diff --git a/importexport/lang/egw_it.lang b/importexport/lang/egw_it.lang index 64e9e38a8d..ca702b298b 100644 --- a/importexport/lang/egw_it.lang +++ b/importexport/lang/egw_it.lang @@ -13,6 +13,7 @@ addressbook csv import importexport it Importazione da CSV rubrica addressbook vcard import importexport it Importazione da vCard rubrica admin disabled exporting importexport it L'amministratore ha disabilitato l'esportazione +all custom fields importexport it Tutti i campi personalizzati all encodings importexport it Tutte le codifiche all users importexport it Tutti gli utenti allowed users importexport it Utenti consentiti @@ -33,6 +34,7 @@ column mismatch: %1 should be %2, not %3 importexport it Discrepanza di colonne: condition importexport it Condizione copied importexport it Copiato create a new definition for this file importexport it Crea una nuova definizione per questo file +create a matching export definition based on this import definition importexport it Crea una definizione di esportazione in base a questa definizione di importazione create export importexport it Crea esportazione created importexport it Creato csv field importexport it Campo CSV @@ -41,6 +43,7 @@ day of week importexport it Giorni della settimana define imports|exports common it Definisci Importazioni | Esportazioni definition importexport it Definizione delete all selected definitions importexport it Rimuovi TUTTE le definizioni selezionate +delete files after import importexport it Elimina dopo l'importazione deleted importexport it Eliminato delimiter importexport it Separatore duplicate name, please choose another. importexport it Nome duplicato, selezionare un altro @@ -66,6 +69,7 @@ fieldseperator importexport it Separatore campo filters importexport it Filtri finish importexport it Termina from definition importexport it Da definizione +from file importexport it Da file general preferences it Generale general fields: preferences it Campi generici: header lines to skip importexport it Linee di intestazione da saltare @@ -110,8 +114,11 @@ schedule import | export importexport it Pianifica importazione | esportazione schedule not found importexport it Pianificazione non trovata schedule times are server time! currently %s. importexport it Gli orari di pianificazine sono quelli del server! Attualmente sono le %s select definition importexport it Seleziona definizione +select groups importexport it Seleziona gruppi +select owner importexport it Seleziona proprietario select plugin importexport it Seleziona plugin select... importexport it Seleziona: +set import values importexport it Imposta parametri di importazione skipped importexport it Saltati some nice text importexport it Un po' di testo some records may not have been imported importexport it Alcune voci potrebbero non essere state importate @@ -137,6 +144,7 @@ user template folder importexport it Usa cartella modelli users allowed to create their own definitions importexport it Utenti a cui è consentito modificare le loro definizioni users allowed to share their own definitions importexport it Utenti a cui è consentito condividere le loro definizioni warnings importexport it Avvisi +what should be done with unknown values? importexport it Cosa si dovrebbe fare con i valori sconosciuti? which useres are allowed for this definition importexport it A quali utenti è permesso di usare questa definizione which users are allowed to use this definition importexport it A quali utenti è permesso di usare questa definizione you may want to backup first. importexport it Potresti effettuare un backup dei dati prima di procedere. diff --git a/infolog/lang/egw_it.lang b/infolog/lang/egw_it.lang index cdd1a836b4..c2f7aca96d 100644 --- a/infolog/lang/egw_it.lang +++ b/infolog/lang/egw_it.lang @@ -82,6 +82,7 @@ change completed infolog it Modifica completata change completion infolog it Modifica la percentuale di completamento change history infolog it Cambia storico change owner when updating infolog it Modifica il proprietario quando aggiorni +change responsible infolog it Cambia responsabile change the status of an entry, eg. close it infolog it Cambia lo stato di una voce, es. chiudi changed category to %1 infolog it Categoria modificata in %1 changed completion to %1% infolog it Completamento modificato in %1% @@ -170,6 +171,7 @@ done infolog it eseguita download infolog it Download download url for links infolog it URL di scaricamento per i collegamenti due %1 infolog it In scadenza %1 +due date infolog it Data di scadenza duration infolog it Durata e-mail: infolog it Email each value is a line like [=