From c7d2f40a7b6fe3e474580a9185d865db6e64b70a Mon Sep 17 00:00:00 2001 From: Hadi Nategh Date: Fri, 24 Sep 2021 10:23:51 +0200 Subject: [PATCH 01/56] Try to avoid running select all action on undesired mailbox --- mail/js/app.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mail/js/app.js b/mail/js/app.js index 08019a8055..565ccdbc8f 100644 --- a/mail/js/app.js +++ b/mail/js/app.js @@ -2445,10 +2445,18 @@ app.classes.mail = AppJS.extend( // we can NOT query global object manager for this.nm_index="nm", as we might not get the one from mail, // if other tabs are open, we have to query for obj_manager for "mail" and then it's child with id "nm" var obj_manager = egw_getObjectManager(this.appname).getObjectById(this.nm_index); + let tree = this.et2.getWidgetById('nm[foldertree]'); var that = this; var rvMain = false; if ((obj_manager && _elems.length>1 && obj_manager.getAllSelected() && !_action.paste) || _action.id=='readall') { + // Avoid possibly doing select all action on not desired mailbox e.g. INBOX + for (let n=0;n<_elems.length;n++) + { + // drop the action if there's a mixedup mailbox found in the selected messages + if (atob(_elems[n].id.split("::")[3]) != tree.getSelectedNode().id.split("::")[1]) return; + } + if (_confirm) { var buttons = [ From d9f759f517bc3d3fc900420364ed4890c403db32 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Fri, 24 Sep 2021 12:29:48 +0200 Subject: [PATCH 02/56] fix PHP 8.0 error: implode(): Argument #2 ($array) must be of type ?array, string given --- api/src/Storage/Merge.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index 6eb823a98b..d452c8da36 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -1780,13 +1780,12 @@ abstract class Merge if (strpos($param[0],'$$LETTERPREFIXCUSTOM') === 0) { //sets a Letterprefix $replaceprefixsort = array(); - // ToDo Stefan: $contentstart is NOT defined here!!! $replaceprefix = explode(' ',substr($param[0],21,-2)); foreach ($replaceprefix as $nameprefix) { if ($this->replacements['$$'.$nameprefix.'$$'] !='') $replaceprefixsort[] = $this->replacements['$$'.$nameprefix.'$$']; } - $replace = implode($replaceprefixsort,' '); + $replace = implode(' ', $replaceprefixsort); } return $replace; } From 1280de46d614614e4ff8e9010c2525a7f30f8d27 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Fri, 24 Sep 2021 18:06:13 +0200 Subject: [PATCH 03/56] REST API allow sending a JSON patch to update or create a new contact (currently only via POST or PUT, not as PATCH!) allows eg. to create a contact from a simple Wordpress contact-form only supporting POST requests and a flat object, see new example in the documentation --- .../inc/class.addressbook_groupdav.inc.php | 2 +- api/src/Contacts/JsContact.php | 200 +++++++++++++----- doc/REST-CalDAV-CardDAV/README.md | 23 ++ 3 files changed, 174 insertions(+), 51 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index aecac673ef..56dff82837 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -658,7 +658,7 @@ class addressbook_groupdav extends Api\CalDAV\Handler } } $contact = $type === JsContact::MIME_TYPE_JSCARD ? - JsContact::parseJsCard($options['content']) : JsContact::parseJsCardGroup($options['content']); + JsContact::parseJsCard($options['content'], $oldContact ?: []) : JsContact::parseJsCardGroup($options['content']); if (!empty($id) && strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0) { diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index da494921cd..6e3038cf0e 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -79,15 +79,27 @@ class JsContact * Parse JsCard * * @param string $json - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param array $old=[] existing contact + * @param bool $strict true: check if objects have their proper @type attribute * @return array */ - public static function parseJsCard(string $json, bool $check_at_type=true) + public static function parseJsCard(string $json, array $old=[], bool $strict=true) { try { $data = json_decode($json, true, 10, JSON_THROW_ON_ERROR); + // check if we have a patch: keys contain slashes + if (array_filter(array_keys($data), static function ($key) + { + return strpos($key, '/') !== false; + })) + { + // apply patch on JsCard of contact + $data = self::patch($data, $old ? self::getJsCard($old, false) : [], !$old); + $strict = false; + } + if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist $contact = []; @@ -96,11 +108,11 @@ class JsContact switch ($name) { case 'uid': - $contact['uid'] = self::parseUid($value); + $contact['uid'] = self::parseUid($value, $old['uid'], !$strict); break; case 'name': - $contact += self::parseNameComponents($value, $check_at_type); + $contact += self::parseNameComponents($value, $strict); break; case 'fullName': @@ -108,41 +120,41 @@ class JsContact break; case 'organizations': - $contact += self::parseOrganizations($value, $check_at_type); + $contact += self::parseOrganizations($value, $strict); break; case 'titles': - $contact += self::parseTitles($value, $check_at_type); + $contact += self::parseTitles($value, $strict); break; case 'emails': - $contact += self::parseEmails($value, $check_at_type); + $contact += self::parseEmails($value, $strict); break; case 'phones': - $contact += self::parsePhones($value, $check_at_type); + $contact += self::parsePhones($value, $strict); break; case 'online': - $contact += self::parseOnline($value, $check_at_type); + $contact += self::parseOnline($value, $strict); break; case 'addresses': - $contact += self::parseAddresses($value, $check_at_type); + $contact += self::parseAddresses($value, $strict); break; case 'photos': - $contact += self::parsePhotos($value, $check_at_type); + $contact += self::parsePhotos($value, $strict); break; case 'anniversaries': - $contact += self::parseAnniversaries($value); + $contact += self::parseAnniversaries($value, $strict); break; case 'notes': $contact['note'] = implode("\n", array_map(static function ($note) { return self::parseString($note); - }, $value)); + }, (array)$value)); break; case 'categories': @@ -197,11 +209,12 @@ class JsContact * Parse and optionally generate UID * * @param string|null $uid + * @param string|null $old old value, if given it must NOT change * @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) + protected static function parseUid(string $uid=null, string $old=null, bool $generate_when_empty=false) { if (empty($uid) || strlen($uid) < 12) { @@ -211,7 +224,15 @@ class JsContact } $uid = \HTTP_WebDAV_Server::_new_uuid(); } - return strpos($uid, self::URN_UUID_PREFIX) === 0 ? substr($uid, 9) : $uid; + if (strpos($uid, self::URN_UUID_PREFIX) === 0) + { + $uid = substr($uid, strlen(self::URN_UUID_PREFIX)); + } + if (isset($old) && $old !== $uid) + { + throw new \InvalidArgumentException("You must NOT change the UID ('$old'): ".json_encode($uid)); + } + return $uid; } /** @@ -248,15 +269,15 @@ class JsContact * 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 + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseOrganizations(array $orgas, bool $check_at_type=true) + protected static function parseOrganizations(array $orgas, bool $stict=true) { $contact = []; foreach($orgas as $orga) { - if ($check_at_type && $orga[self::AT_TYPE] !== self::TYPE_ORGANIZATION) + if ($stict && $orga[self::AT_TYPE] !== self::TYPE_ORGANIZATION) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($orga, self::JSON_OPTIONS_ERROR)); } @@ -306,15 +327,19 @@ class JsContact * 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 + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseTitles(array $titles, bool $check_at_type=true) + protected static function parseTitles(array $titles, bool $stict=true) { $contact = []; foreach($titles as $id => $title) { - if ($check_at_type && $title[self::AT_TYPE] !== self::TYPE_TITLE) + if (!$stict && is_string($title)) + { + $title = ['title' => $title]; + } + if ($stict && $title[self::AT_TYPE] !== self::TYPE_TITLE) { throw new \InvalidArgumentException("Missing or invalid @type: " . json_encode($title[self::AT_TYPE])); } @@ -526,21 +551,21 @@ class JsContact * Parse addresses object containing multiple addresses * * @param array $addresses - * @param bool $check_at_type true: check if objects have their proper @type attribute + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseAddresses(array $addresses, bool $check_at_type=true) + protected static function parseAddresses(array $addresses, bool $stict=true) { $n = 0; $last_type = null; $contact = []; foreach($addresses as $id => $address) { - if ($check_at_type && $address[self::AT_TYPE] !== self::TYPE_ADDRESS) + if ($stict && $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)); + $contact += ($values=self::parseAddress($address, $id, $last_type, $stict)); if (++$n > 2) { @@ -567,9 +592,10 @@ class JsContact * @param array $address address-object * @param string $id index * @param ?string $last_type "work" or "home" + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseAddress(array $address, string $id, string &$last_type=null) + protected static function parseAddress(array $address, string $id, string &$last_type=null, bool $stict=true) { $type = !isset($last_type) && (empty($address['contexts']['private']) || $id === 'work') || $last_type === 'home' ? 'work' : 'home'; @@ -577,7 +603,7 @@ class JsContact $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']); + list($contact[$prefix.'street'], $contact[$prefix.'street2']) = self::parseStreetComponents($address['street'], $stict); foreach(self::$jsAddress2attr+self::$jsAddress2workAttr as $js => $attr) { if (isset($address[$js]) && !is_string($address[$js])) @@ -637,12 +663,20 @@ class JsContact * 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 + * @param array|string $components string only for relaxed parsing + * @param bool $stict 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) + protected static function parseStreetComponents($components, bool $stict=true) { + if (!$stict && is_string($components)) + { + $components = [['type' => 'name', 'value' => $components]]; + } + if (!is_array($components)) + { + throw new \InvalidArgumentException("Invalid street-components: ".json_encode($components, self::JSON_OPTIONS_ERROR)); + } $street = []; $last_type = null; foreach($components as $component) @@ -651,7 +685,7 @@ class JsContact { 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) + if ($stict && $component[self::AT_TYPE] !== self::TYPE_STREET_COMPONENT) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($component, self::JSON_OPTIONS_ERROR)); } @@ -712,21 +746,25 @@ class JsContact * 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 + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parsePhones(array $phones, bool $check_at_type=true) + protected static function parsePhones(array $phones, bool $stict=true) { $contact = []; // check for good matches foreach($phones as $id => $phone) { + if (!$stict && is_string($phone)) + { + $phone = ['phone' => $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) + if ($stict && $phone[self::AT_TYPE] !== self::TYPE_PHONE) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($phone, self::JSON_OPTIONS_ERROR)); } @@ -818,19 +856,23 @@ class JsContact * 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 + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseOnline(array $values, bool $check_at_type) + protected static function parseOnline(array $values, bool $stict) { $contact = []; foreach($values as $id => $value) { + if (!$stict && is_string($value)) + { + $value = ['resource' => $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) + if ($stict && $value[self::AT_TYPE] !== self::TYPE_RESOURCE) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($value, self::JSON_OPTIONS_ERROR)); } @@ -889,15 +931,19 @@ class JsContact * * @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 + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseEmails(array $emails, bool $check_at_type=true) + protected static function parseEmails(array $emails, bool $stict=true) { $contact = []; foreach($emails as $id => $value) { - if ($check_at_type && $value[self::AT_TYPE] !== self::TYPE_EMAIL) + if (!$stict && is_string($value)) + { + $value = ['email' => $value]; + } + if ($stict && $value[self::AT_TYPE] !== self::TYPE_EMAIL) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($value, self::JSON_OPTIONS_ERROR)); } @@ -953,11 +999,11 @@ class JsContact * @return array * @ToDo */ - protected static function parsePhotos(array $photos, bool $check_at_type) + protected static function parsePhotos(array $photos, bool $stict) { foreach($photos as $id => $photo) { - if ($check_at_type && $photo[self::AT_TYPE] !== self::TYPE_FILE) + if ($stict && $photo[self::AT_TYPE] !== self::TYPE_FILE) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($photo, self::JSON_OPTIONS_ERROR)); } @@ -1008,18 +1054,23 @@ class JsContact * @param array $components * @return array */ - protected static function parseNameComponents(array $components, bool $check_at_type=true) + protected static function parseNameComponents(array $components, bool $stict=true) { $contact = array_combine(array_values(self::$nameType2attribute), array_fill(0, count(self::$nameType2attribute), null)); - foreach($components as $component) + foreach($components as $type => $component) { + // for relaxed checks, allow $type => $value pairs + if (!$stict && is_string($type) && is_scalar($component)) + { + $component = ['type' => $type, 'value' => $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) + if ($stict && $component[self::AT_TYPE] !== self::TYPE_NAME_COMPONENT) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($component, self::JSON_OPTIONS_ERROR)); } @@ -1052,14 +1103,28 @@ class JsContact * * @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 + * @param bool $stict true: check if objects have their proper @type attribute * @return array */ - protected static function parseAnniversaries(array $anniversaries, bool $check_at_type=true) + protected static function parseAnniversaries(array $anniversaries, bool $stict=true) { $contact = []; foreach($anniversaries as $id => $anniversary) { + if (!$stict && is_string($anniversary)) + { + // allow German date format "dd.mm.yyyy" + if (preg_match('/^(\d+)\.(\d+).(\d+)$/', $anniversary, $matches)) + { + $matches = sprintf('%04d-%02d-%02d', (int)$matches[3], (int)$matches[2], (int)$matches[1]); + } + // allow US date format "mm/dd/yyyy" + elseif (preg_match('#^(\d+)/(\d+)/(\d+)$#', $anniversary, $matches)) + { + $matches = sprintf('%04d-%02d-%02d', (int)$matches[3], (int)$matches[1], (int)$matches[2]); + } + $anniversary = ['type' => $id, 'date' => $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'])) || @@ -1067,7 +1132,7 @@ class JsContact { 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) + if ($stict && $anniversary[self::AT_TYPE] !== self::TYPE_ANNIVERSARY) { throw new \InvalidArgumentException("Missing or invalid @type: ".json_encode($anniversary, self::JSON_OPTIONS_ERROR)); } @@ -1251,16 +1316,51 @@ class JsContact return $members; } + /** + * Patch JsCard + * + * @param array $patches JSON path + * @param array $jscard to patch + * @param bool $create =false true: create missing components + * @return array patched $jscard + */ + public static function patch(array $patches, array $jscard, bool $create=false) + { + foreach($patches as $path => $value) + { + $parts = explode('/', $path); + $target = &$jscard; + foreach($parts as $n => $part) + { + if (!isset($target[$part]) && $n < count($parts)-1 && !$create) + { + throw new \InvalidArgumentException("Trying to patch not existing attribute with path $path!"); + } + $parent = $target; + $target = &$target[$part]; + } + if (isset($value)) + { + $target = $value; + } + else + { + unset($parent[$part]); + } + } + return $jscard; + } + /** * Map all kind of exceptions while parsing to a JsContactParseException * * @param \Throwable $e * @param string $type - * @param string $name + * @param ?string $name * @param mixed $value * @throws JsContactParseException */ - protected static function handleExceptions(\Throwable $e, $type='JsContact', string $name, $value) + protected static function handleExceptions(\Throwable $e, $type='JsContact', ?string $name, $value) { try { throw $e; diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index 8ad841ad9c..b173c4942d 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -253,6 +253,29 @@ Location: https://example.org/egroupware/groupdav.php//addressbook/123 ``` +
+ Example: POST request to create a new resource using flat attributes (JSON patch syntax) eg. for a simple Wordpress contact-form + +``` +cat </addressbook/' -X POST -d @- -H "Content-Type: application/json" --user +{ + "name/personal": "First", + "name/surname": "Tester", + "organizations/org/name": "Test Organization", + "emails/work": "test.user@test-user.org", + "addresses/work/locality": "Test-Town", + "addresses/work/postcode": "12345", + "addresses/work/street": "Teststr. 123", + "phones/tel_work": "+49 123 4567890", + "online/url": "https://www.example.org/" +} +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 From e640873fc0f6c8939e8d10e2c12327e3150a9803 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Sat, 25 Sep 2021 12:20:31 +0200 Subject: [PATCH 04/56] implement and document PATCH --- .../inc/class.addressbook_groupdav.inc.php | 9 ++-- api/src/CalDAV.php | 41 +++++++++++++++++-- api/src/CalDAV/Handler.php | 1 + api/src/Contacts/JsContact.php | 20 +++++---- api/src/WebDAV/Server.php | 6 +-- doc/REST-CalDAV-CardDAV/README.md | 40 +++++++++++++++++- 6 files changed, 99 insertions(+), 18 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 56dff82837..9bd7c7dec2 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -629,13 +629,15 @@ class addressbook_groupdav extends Api\CalDAV\Handler * @param int $id * @param int $user =null account_id of owner, default null * @param string $prefix =null user prefix from path (eg. /ralf from /ralf/addressbook) + * @param string $method='PUT' also called for POST and PATCH + * @param string $content_type=null * @return mixed boolean true on success, false on failure or string with http status (eg. '404 Not Found') */ - function put(&$options,$id,$user=null,$prefix=null) + function put(&$options, $id, $user=null, $prefix=null, string $method='PUT', string $content_type=null) { if ($this->debug) error_log(__METHOD__.'('.array2string($options).",$id,$user)"); - $oldContact = $this->_common_get_put_delete('PUT',$options,$id); + $oldContact = $this->_common_get_put_delete($method,$options,$id); if (!is_null($oldContact) && !is_array($oldContact)) { if ($this->debug) error_log(__METHOD__."(,'$id', $user, '$prefix') returning ".array2string($oldContact)); @@ -658,7 +660,8 @@ class addressbook_groupdav extends Api\CalDAV\Handler } } $contact = $type === JsContact::MIME_TYPE_JSCARD ? - JsContact::parseJsCard($options['content'], $oldContact ?: []) : JsContact::parseJsCardGroup($options['content']); + JsContact::parseJsCard($options['content'], $oldContact ?: [], $content_type, $method) : + JsContact::parseJsCardGroup($options['content']); if (!empty($id) && strpos($id, self::JS_CARDGROUP_ID_PREFIX) === 0) { diff --git a/api/src/CalDAV.php b/api/src/CalDAV.php index 5a0f0dc62d..2130dbbcd1 100644 --- a/api/src/CalDAV.php +++ b/api/src/CalDAV.php @@ -993,6 +993,34 @@ class CalDAV extends HTTP_WebDAV_Server parent::http_PROPFIND('REPORT'); } + /** + * REST API PATCH handler + * + * Currently, only implemented for REST not CalDAV/CardDAV + * + * @param $options + * @param $files + * @return string|void + */ + function PATCH(array &$options) + { + if (!preg_match('#^application/([^; +]+\+)?json#', $_SERVER['HTTP_CONTENT_TYPE'])) + { + return '501 Not implemented'; + } + return $this->PUT($options, 'PATCH'); + } + + /** + * REST API PATCH handler + * + * Just calls http_PUT() + */ + function http_PATCH() + { + return parent::http_PUT('PATCH'); + } + /** * Check if client want or sends JSON * @@ -1003,7 +1031,7 @@ class CalDAV extends HTTP_WebDAV_Server { if (!isset($type)) { - $type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST', 'PROPPATCH']) ? + $type = in_array($_SERVER['REQUEST_METHOD'], ['PUT', 'POST', 'PATCH', 'PROPPATCH']) ? $_SERVER['HTTP_CONTENT_TYPE'] : $_SERVER['HTTP_ACCEPT']; } return preg_match('#application/(([^+ ;]+)\+)?json#', $type, $matches) ? @@ -1427,7 +1455,7 @@ class CalDAV extends HTTP_WebDAV_Server substr($options['path'], -1) === '/' && self::isJSON()) { $_GET['add-member'] = ''; // otherwise we give no Location header - return $this->PUT($options); + return $this->PUT($options, 'POST'); } if ($this->debug) error_log(__METHOD__.'('.array2string($options).')'); @@ -1915,7 +1943,7 @@ class CalDAV extends HTTP_WebDAV_Server * @param array parameter passing array * @return bool true on success */ - function PUT(&$options) + function PUT(&$options, $method='PUT') { // read the content in a string, if a stream is given if (isset($options['stream'])) @@ -1934,9 +1962,14 @@ class CalDAV extends HTTP_WebDAV_Server { return '404 Not Found'; } + // REST API & PATCH only implemented for addressbook currently + if ($app !== 'addressbook' && $method === 'PATCH') + { + return '501 Not implemented'; + } if (($handler = self::app_handler($app))) { - $status = $handler->put($options,$id,$user,$prefix); + $status = $handler->put($options, $id, $user, $prefix, $method, $_SERVER['HTTP_CONTENT_TYPE']); // set default stati: true --> 204 No Content, false --> should be already handled if (is_bool($status)) $status = $status ? '204 No Content' : '400 Something went wrong'; diff --git a/api/src/CalDAV/Handler.php b/api/src/CalDAV/Handler.php index 6c360a769a..ca71dcc59f 100644 --- a/api/src/CalDAV/Handler.php +++ b/api/src/CalDAV/Handler.php @@ -60,6 +60,7 @@ abstract class Handler var $method2acl = array( 'GET' => Api\Acl::READ, 'PUT' => Api\Acl::EDIT, + 'PATCH' => Api\Acl::EDIT, 'DELETE' => Api\Acl::DELETE, ); /** diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index 6e3038cf0e..bb3b7338a2 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -78,26 +78,32 @@ class JsContact /** * Parse JsCard * + * We use strict parsing for "application/jscontact+json" content-type, not for "application/json". + * Strict parsing checks objects for proper @type attributes and value attributes, non-strict allows scalar values. + * + * Non-strict parsing also automatic detects patch for POST requests. + * * @param string $json - * @param array $old=[] existing contact - * @param bool $strict true: check if objects have their proper @type attribute + * @param array $old=[] existing contact for patch + * @param ?string $content_type=null application/json no strict parsing and automatic patch detection, if method not 'PATCH' or 'PUT' + * @param string $method='PUT' 'PUT', 'POST' or 'PATCH' * @return array */ - public static function parseJsCard(string $json, array $old=[], bool $strict=true) + public static function parseJsCard(string $json, array $old=[], string $content_type=null, $method='PUT') { try { + $strict = !isset($content_type) || !preg_match('#^application/json#', $content_type); $data = json_decode($json, true, 10, JSON_THROW_ON_ERROR); - // check if we have a patch: keys contain slashes - if (array_filter(array_keys($data), static function ($key) + // check if we use patch: method is PATCH or method is POST AND keys contain slashes + if ($method === 'PATCH' || !$strict && $method === 'POST' && array_filter(array_keys($data), static function ($key) { return strpos($key, '/') !== false; })) { // apply patch on JsCard of contact $data = self::patch($data, $old ? self::getJsCard($old, false) : [], !$old); - $strict = false; } if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist @@ -951,7 +957,7 @@ class JsContact { 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'])) + if (!isset($contact['email']) && ($id === 'work' || empty($value['contexts']['private']) || isset($contact['email_home']))) { $contact['email'] = $value['email']; } diff --git a/api/src/WebDAV/Server.php b/api/src/WebDAV/Server.php index 1fcd32416a..2352b31b53 100644 --- a/api/src/WebDAV/Server.php +++ b/api/src/WebDAV/Server.php @@ -1701,10 +1701,10 @@ class HTTP_WebDAV_Server /** * PUT method handler * - * @param void + * @param string $method='PUT' * @return void */ - function http_PUT() + function http_PUT(string $method='PUT') { if ($this->_check_lock_status($this->path)) { $options = Array(); @@ -1839,7 +1839,7 @@ class HTTP_WebDAV_Server } } - $stat = $this->PUT($options); + $stat = $this->$method($options); if ($stat === false) { $stat = "403 Forbidden"; diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index b173c4942d..d203c94df5 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -276,7 +276,37 @@ Location: https://example.org/egroupware/groupdav.php//addressbook/123 ``` -* **PUT** requests with a ```Content-Type: application/json``` header allow modifying single resources +* **PUT** requests with a ```Content-Type: application/json``` header allow modifying single resources (requires to specify all attributes!) + +* **PATCH** request with a ```Content-Type: application/json``` header allow to modify a single resource by only specifying changed attributes as a [PatchObject](https://www.rfc-editor.org/rfc/rfc8984.html#type-PatchObject) + +
+ Example: PATCH request to modify a contact with partial data + +``` +cat </addressbook/1234' -X PATCH -d @- -H "Content-Type: application/json" --user +{ + "name": [ + { + "@type": "NameComponent", + "type": "personal", + "value": "Testfirst" + }, + { + "@type": "NameComponent", + "type": "surname", + "value": "Username" + } + ], + "fullName": "Testfirst Username", + "organizations/org/name": "Test-User.org", + "emails/work/email": "test.user@test-user.org" +} +EOF + +HTTP/1.1 204 No content +``` +
* **DELETE** requests delete single resources @@ -289,3 +319,11 @@ 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``` + +### ToDos +- [x] Addressbook + - [ ] update of photos, keys, attachments +- [ ] InfoLog +- [ ] Calendar +- [ ] relatedTo / links +- [ ] storing not native supported attributes eg. localization \ No newline at end of file From 281e71ef713000bdc9d1da024db38ad96fcf1952 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Sat, 25 Sep 2021 13:04:49 +0200 Subject: [PATCH 05/56] use PUT with a UID as id to update an existing resource or create it, if not existing --- .../inc/class.addressbook_groupdav.inc.php | 5 ++ doc/REST-CalDAV-CardDAV/README.md | 54 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 9bd7c7dec2..019abb26d2 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -1108,6 +1108,11 @@ class addressbook_groupdav extends Api\CalDAV\Handler $keys['id'] = $id; } } + // json with uid + elseif (empty(self::$path_extension) && (string)$id !== (string)(int)$id) + { + $keys['uid'] = $id; + } else { $keys[self::$path_attr] = $id; diff --git a/doc/REST-CalDAV-CardDAV/README.md b/doc/REST-CalDAV-CardDAV/README.md index d203c94df5..aae51ee955 100644 --- a/doc/REST-CalDAV-CardDAV/README.md +++ b/doc/REST-CalDAV-CardDAV/README.md @@ -278,6 +278,60 @@ Location: https://example.org/egroupware/groupdav.php//addressbook/123 * **PUT** requests with a ```Content-Type: application/json``` header allow modifying single resources (requires to specify all attributes!) +
+ Example: PUT request to update a resource + +``` +cat </addressbook/1234' -X PUT -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 204 No Content +``` +
+ +
+ Example: PUT request with UID to update an existing resource or create it, if not exists + +``` +cat </addressbook/5638-8623c4830472a8ede9f9f8b30d435ea4' -X PUT -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 +``` +Update of an existing one: +``` +HTTP/1.1 204 No Content +``` +New contact: +``` +HTTP/1.1 201 Created +Location: https://example.org/egroupware/groupdav.php//addressbook/1234 +``` +
+ + * **PATCH** request with a ```Content-Type: application/json``` header allow to modify a single resource by only specifying changed attributes as a [PatchObject](https://www.rfc-editor.org/rfc/rfc8984.html#type-PatchObject)
From 5c19e0c699f6b54d9407239278de942623cd6497 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Sun, 26 Sep 2021 10:53:27 +0200 Subject: [PATCH 06/56] remove only chunks older then 2 days, to allow UI to still load them and not require a reload / F5 --- rollup.config.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index d36e92d712..8b9d0e246f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -11,14 +11,21 @@ import path from 'path'; import babel from '@babel/core'; -import { readFileSync, readdirSync, statSync } from "fs"; -import rimraf from 'rimraf'; +import { readFileSync, readdirSync, statSync, unlinkSync } from "fs"; +//import rimraf from 'rimraf'; import { minify } from 'terser'; import resolve from '@rollup/plugin-node-resolve'; // Best practice: use this //rimraf.sync('./dist/'); -rimraf.sync('./chunks/'); +//rimraf.sync('./chunks/'); + +// remove only chunks older then 2 days, to allow UI to still load them and not require a reload / F5 +const rm_older = Date.now() - 48*3600000; +readdirSync('./chunks').forEach(name => { + const stat = statSync('./chunks/'+name); + if (stat.atimeMs < rm_older) unlinkSync('./chunks/'+name); +}); // Turn on minification const do_minify = true; From 6a689231e5f728253d54da6dce53d6c3c98972c1 Mon Sep 17 00:00:00 2001 From: Hadi Nategh Date: Mon, 27 Sep 2021 10:50:44 +0200 Subject: [PATCH 07/56] Catch miss encoding exception while checking mailbox for commit bec53dc57d0196a9a4256983a8d8398afa5e987f --- mail/js/app.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mail/js/app.js b/mail/js/app.js index 565ccdbc8f..f0e3b70ffc 100644 --- a/mail/js/app.js +++ b/mail/js/app.js @@ -2450,13 +2450,19 @@ app.classes.mail = AppJS.extend( var rvMain = false; if ((obj_manager && _elems.length>1 && obj_manager.getAllSelected() && !_action.paste) || _action.id=='readall') { - // Avoid possibly doing select all action on not desired mailbox e.g. INBOX - for (let n=0;n<_elems.length;n++) + try { + // Avoid possibly doing select all action on not desired mailbox e.g. INBOX + for (let n=0;n<_elems.length;n++) + { + // drop the action if there's a mixedup mailbox found in the selected messages + if (atob(_elems[n].id.split("::")[3]) != tree.getSelectedNode().id.split("::")[1]) return; + } + }catch(e) { - // drop the action if there's a mixedup mailbox found in the selected messages - if (atob(_elems[n].id.split("::")[3]) != tree.getSelectedNode().id.split("::")[1]) return; + // continue } + if (_confirm) { var buttons = [ From 966e611941584120659484e6421dae06b1d525fb Mon Sep 17 00:00:00 2001 From: Hadi Nategh Date: Mon, 27 Sep 2021 11:22:49 +0200 Subject: [PATCH 08/56] Find out the mailbox from the rowID --- mail/js/app.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mail/js/app.js b/mail/js/app.js index f0e3b70ffc..53cf03230c 100644 --- a/mail/js/app.js +++ b/mail/js/app.js @@ -2451,11 +2451,16 @@ app.classes.mail = AppJS.extend( if ((obj_manager && _elems.length>1 && obj_manager.getAllSelected() && !_action.paste) || _action.id=='readall') { try { + let splitedID = []; + let mailbox = ''; // Avoid possibly doing select all action on not desired mailbox e.g. INBOX for (let n=0;n<_elems.length;n++) { + splitedID = _elems[n].id.split("::"); + // find the mailbox from the constructed rowID, sometimes the rowID may not contain the app name + mailbox = splitedID.length == 4?atob(splitedID[2]):atob(splitedID[3]); // drop the action if there's a mixedup mailbox found in the selected messages - if (atob(_elems[n].id.split("::")[3]) != tree.getSelectedNode().id.split("::")[1]) return; + if (mailbox != tree.getSelectedNode().id.split("::")[1]) return; } }catch(e) { From 9344f2df9ff81de84a69cc3e0fc0318ea0c6c54c Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 27 Sep 2021 09:10:18 -0600 Subject: [PATCH 09/56] Placeholder dialog translations --- api/lang/egw_de.lang | 2 ++ api/lang/egw_en.lang | 2 ++ 2 files changed, 4 insertions(+) diff --git a/api/lang/egw_de.lang b/api/lang/egw_de.lang index 9785ddaa92..ad9510f170 100644 --- a/api/lang/egw_de.lang +++ b/api/lang/egw_de.lang @@ -724,6 +724,7 @@ insert new column behind this one common de Neue Spalte hinter dieser einfügen insert new column in front of all common de Neue Spalte vor dieser einfügen insert new row after this one common de Neue Zeile nach dieser einfügen insert new row in front of first line common de Neue Zeile vor dieser einfügen +insert placeholder common de Platzhalter einfügen insert row after common de Zeile danach einfügen insert row before common de Zeile davor einfügen insert timestamp into description field common de Zeitstempel in das Beschreibungs-Feld einfügen @@ -1072,6 +1073,7 @@ preference common de Einstellung preferences common de Einstellungen preferences for the %1 template set preferences de Einstellungen für das %1 Template prev common de Vorheriger +preview with entry common de Vorschau aus Eintrag previous common de Vorherige previous page common de Vorherige Seite primary group common de Hauptgruppe diff --git a/api/lang/egw_en.lang b/api/lang/egw_en.lang index c24cdb1b26..09abb18ec0 100644 --- a/api/lang/egw_en.lang +++ b/api/lang/egw_en.lang @@ -724,6 +724,7 @@ insert new column behind this one common en Insert new column after insert new column in front of all common en Insert new column before all insert new row after this one common en Insert new row after insert new row in front of first line common en Insert new row before first line +insert placeholder common en Insert placeholder insert row after common en Insert row after insert row before common en Insert row before insert timestamp into description field common en Insert timestamp into description field @@ -1073,6 +1074,7 @@ preference common en Preference preferences common en Preferences preferences for the %1 template set preferences en Preferences for the %1 template set prev common en Prev +preview with entry common en Preview with entry previous common en Previous previous page common en Previous page primary group common en Primary group From 90b3b47dc9f38d86b1fda06efa13ac31047a9401 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 27 Sep 2021 09:13:33 -0600 Subject: [PATCH 10/56] Placeholder dialog upper case first letter of placeholder category & placeholder name --- api/templates/default/insert_merge_placeholder.xet | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api/templates/default/insert_merge_placeholder.xet b/api/templates/default/insert_merge_placeholder.xet index 59d358cd20..3e2d321416 100644 --- a/api/templates/default/insert_merge_placeholder.xet +++ b/api/templates/default/insert_merge_placeholder.xet @@ -24,7 +24,7 @@ - + /** Structural stuff **/ #api\.insert_merge_placeholder_outer_box > #api\.insert_merge_placeholder_selects { flex: 1 1 80%; } @@ -76,6 +76,11 @@ border-radius: 0px; background-color: transparent; } + + /** Cosmetics **/ + #api\.insert_merge_placeholder_outer_box option:first-letter { + text-transform: capitalize; + } From f6b7dc1474dc681322a6611b0461dc8bf63e82e8 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Mon, 27 Sep 2021 17:28:41 +0200 Subject: [PATCH 11/56] fix PHP 8.0 error: key(): Argument #1 ($array) must be of type array, null given --- filemanager/inc/class.filemanager_ui.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/filemanager/inc/class.filemanager_ui.inc.php b/filemanager/inc/class.filemanager_ui.inc.php index 9e55df5cea..3d124a7f2a 100644 --- a/filemanager/inc/class.filemanager_ui.inc.php +++ b/filemanager/inc/class.filemanager_ui.inc.php @@ -1193,7 +1193,7 @@ class filemanager_ui //_debug_array($content); $path =& $content['path']; - $button = @key($content['button']); + $button = @key($content['button'] ?? []); unset($content['button']); if(!$button && $content['sudo'] && $content['sudouser']) { From 47cf12869b7edefee6235c2106338b38f84917ea Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 27 Sep 2021 10:52:39 -0600 Subject: [PATCH 12/56] Utility function to add prefix to placeholder & optionally wrap it with markers, because I was writing it out so many times --- api/src/Storage/Merge.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index d452c8da36..9539f88f46 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -1654,6 +1654,30 @@ abstract class Merge return $replacements; } + /** + * Prefix a placeholder, taking care of $$ or {{}} markers + * + * @param string $prefix Placeholder prefix + * @param string $placeholder Placeholder, with or without {{...}} or $$...$$ markers + * @param null|string $wrap "{" or "$" to add markers, omit to exclude markers + * @return string + */ + protected function prefix($prefix, $placeholder, $wrap = null) + { + $marker = ['', '']; + if($placeholder[0] == '{' && is_null($wrap) || $wrap[0] == '{') + { + $marker = ['{{', '}}']; + } + elseif($placeholder[0] == '$' && is_null($wrap) || $wrap[0] == '$') + { + $marker = ['$$', '$$']; + } + + $placeholder = str_replace(['{{', '}}', '$$'], '', $placeholder); + return $marker[0] . ($prefix ? $prefix . '/' : '') . $placeholder . $marker[1]; + } + /** * Process special flags, such as IF or NELF * From 369de2e3d59113ecd99c0607c29cfda56dd1c904 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 27 Sep 2021 10:54:00 -0600 Subject: [PATCH 13/56] Specific ordering for contact merge placeholders also, use switch to using prefix() --- api/src/Contacts/Merge.php | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/api/src/Contacts/Merge.php b/api/src/Contacts/Merge.php index 0e9a496813..4c0dcf6681 100644 --- a/api/src/Contacts/Merge.php +++ b/api/src/Contacts/Merge.php @@ -275,7 +275,22 @@ class Merge extends Api\Storage\Merge */ public function get_placeholder_list($prefix = '') { - $placeholders = []; + // Specific order for these ones + $placeholders = [ + 'contact' => [], + 'details' => [ + $this->prefix($prefix, 'categories', '{') => lang('Category path'), + $this->prefix($prefix, 'note', '{') => $this->contacts->contact_fields['note'], + $this->prefix($prefix, 'id', '{') => $this->contacts->contact_fields['id'], + $this->prefix($prefix, 'owner', '{') => $this->contacts->contact_fields['owner'], + $this->prefix($prefix, 'private', '{') => $this->contacts->contact_fields['private'], + $this->prefix($prefix, 'cat_id', '{') => $this->contacts->contact_fields['cat_id'], + ], + + ]; + + // Iterate through the list & switch groups as we go + // Hopefully a little better than assigning each field to a group $group = 'contact'; foreach($this->contacts->contact_fields as $name => $label) { @@ -299,24 +314,27 @@ class Merge extends Api\Storage\Merge case 'email_home': $group = 'email'; break; - case 'url': + case 'freebusy_uri': $group = 'details'; } - $placeholders[$group]["{{" . ($prefix ? $prefix . '/' : '') . $name . "}}"] = $label; - if($name == 'cat_id') + $marker = $this->prefix($prefix, $name, '{'); + if(!array_filter($placeholders, function ($a) use ($marker) { - $placeholders[$group]["{{" . ($prefix ? $prefix . '/' : '') . $name . "}}"] = lang('Category path'); + return array_key_exists($marker, $a); + })) + { + $placeholders[$group][$marker] = $label; } } // 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"; + $placeholders['business'][$this->prefix($prefix, 'adr_one_formatted', '{')] = "Formatted business address"; + $placeholders['private'][$this->prefix($prefix, 'adr_two_formatted', '{')] = "Formatted private address"; $group = 'customfields'; foreach($this->contacts->customfields as $name => $field) { - $placeholders[$group]["{{" . ($prefix ? $prefix . '/' : '') . $name . "}}"] = $field['label']; + $placeholders[$group][$this->prefix($prefix, $name, '{')] = $field['label']; } return $placeholders; } From eb5729414614a306b4972c5ca5a3d713788a50fa Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 27 Sep 2021 11:09:38 -0600 Subject: [PATCH 14/56] Placeholder dialog: Allow & show general fields --- api/src/Etemplate/Widget/Placeholder.php | 5 +- api/src/Storage/Merge.php | 58 ++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/api/src/Etemplate/Widget/Placeholder.php b/api/src/Etemplate/Widget/Placeholder.php index 873ef30608..8eca0b211a 100644 --- a/api/src/Etemplate/Widget/Placeholder.php +++ b/api/src/Etemplate/Widget/Placeholder.php @@ -64,7 +64,7 @@ class Placeholder extends Etemplate\Widget if(is_null($apps)) { - $apps = ['addressbook', 'user']; + $apps = ['addressbook', 'user', 'general']; } foreach($apps as $appname) @@ -75,6 +75,9 @@ class Placeholder extends Etemplate\Widget case 'user': $list = $merge->get_user_placeholder_list(); break; + case 'general': + $list = $merge->get_common_placeholder_list(); + break; default: $list = $merge->get_placeholder_list(); break; diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index 9539f88f46..220d54a154 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -2644,17 +2644,67 @@ abstract class Merge ); } + /** + * Get a list of common placeholders + * + * @param string $prefix + */ + public function get_common_placeholder_list($prefix = '') + { + $placeholders = [ + 'URLs' => [], + 'Egroupware links' => [], + 'General' => [], + 'Repeat' => [], + 'Commands' => [] + ]; + // Iterate through the list & switch groups as we go + // Hopefully a little better than assigning each field to a group + $group = 'URLs'; + foreach($this->get_common_replacements() as $name => $label) + { + if(in_array($name, array('user/n_fn', 'user/account_lid'))) + { + continue; + } // don't show them, they're in 'User' + + switch($name) + { + case 'links': + $group = 'Egroupware links'; + break; + case 'date': + $group = 'General'; + break; + case 'pagerepeat': + $group = 'Repeat'; + break; + case 'IF fieldname': + $group = 'Commands'; + } + $marker = $this->prefix($prefix, $name, '{'); + if(!array_filter($placeholders, function ($a) use ($marker) + { + return array_key_exists($marker, $a); + })) + { + $placeholders[$group][$marker] = $label; + } + } + return $placeholders; + } + /** * 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 = $contacts->get_placeholder_list($this->prefix($prefix, 'user')); + unset($replacements['details'][$this->prefix($prefix, 'user/account_id', '{')]); $replacements['account'] = [ - '{{' . ($prefix ? $prefix . '/' : '') . 'user/account_id}}' => 'Account ID', - '{{' . ($prefix ? $prefix . '/' : '') . 'user/account_lid}}' => 'Login ID' + $this->prefix($prefix, 'user/account_id', '{') => 'Account ID', + $this->prefix($prefix, 'user/account_lid', '{') => 'Login ID' ]; return $replacements; From 7f930a62211353d310ed0d37adb3df75d38be706 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 27 Sep 2021 14:46:41 -0600 Subject: [PATCH 15/56] Placeholder dialog: Support for other apps, starting with Infolog --- api/js/etemplate/et2_widget_placeholder.ts | 47 ++++++++++++++++++---- api/src/Contacts/Merge.php | 2 +- api/src/Etemplate/Widget/Placeholder.php | 26 ++++++++---- api/src/Storage/Merge.php | 23 ++++++++++- infolog/inc/class.infolog_merge.inc.php | 34 +++++++++++++++- 5 files changed, 111 insertions(+), 21 deletions(-) diff --git a/api/js/etemplate/et2_widget_placeholder.ts b/api/js/etemplate/et2_widget_placeholder.ts index 06d42a3245..1ac6847123 100644 --- a/api/js/etemplate/et2_widget_placeholder.ts +++ b/api/js/etemplate/et2_widget_placeholder.ts @@ -87,6 +87,12 @@ export class et2_placeholder_select extends et2_inputWidget [], function(_content) { + if(typeof _content === 'object' && _content.message) + { + // Something went wrong + this.egw().message(_content.message, 'error'); + return; + } this.egw().loading_prompt('placeholder_select', false); et2_placeholder_select.placeholders = _content; callback.apply(self, arguments); @@ -146,7 +152,7 @@ export class et2_placeholder_select extends et2_inputWidget data.sel_options.group = this._get_group_options(Object.keys(_data)[0]); data.content.app = data.sel_options.app[0].value; data.content.group = data.sel_options.group[0].value; - data.content.entry = data.modifications.outer_box.entry.only_app = data.content.app; + data.content.entry = {app: data.content.app}; data.modifications.outer_box.entry.application_list = Object.keys(_data); // callback for dialog @@ -207,14 +213,30 @@ export class et2_placeholder_select extends et2_inputWidget // 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()}); + let groups = this._get_group_options(widget.get_value()); + group.set_select_options(groups); + group.set_value(groups[0].value); + if(['user'].indexOf(widget.get_value()) >= 0) + { + entry.app_select.val('api-accounts'); + entry.set_value({app: 'api-accounts', id: '', query: ''}); + } + else if(widget.get_value() == 'general') + { + // Don't change entry app, leave it + } + else + { + entry.app_select.val(widget.get_value()); + entry.set_value({app: widget.get_value(), id: '', query: ''}); + } } group.onchange = (select_node, select_widget) => { - console.log(this, arguments); - placeholder_list.set_select_options(this._get_placeholders(app.get_value(), group.get_value())); + let options = this._get_placeholders(app.get_value(), group.get_value()) + placeholder_list.set_select_options(options); preview.set_value(""); + placeholder_list.set_value(options[0].value); } placeholder_list.onchange = this._on_placeholder_select.bind(this); entry.onchange = this._on_placeholder_select.bind(this); @@ -227,7 +249,7 @@ export class et2_placeholder_select extends et2_inputWidget this.options.insert_callback(this.dialog.template.widgetContainer.getDOMWidgetById("preview_content").getDOMNode().textContent); }; - this._on_placeholder_select(); + app.set_value(app.get_value()); } /** @@ -252,9 +274,13 @@ export class et2_placeholder_select extends et2_inputWidget // 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()], + [placeholder_list.get_value(), entry.get_value()], function(_content) { + if(!_content) + { + _content = ''; + } preview_content.set_value(_content); preview_content.getDOMNode().parentNode.style.visibility = _content.trim() ? null : 'hidden'; }.bind(this) @@ -385,6 +411,7 @@ export class et2_placeholder_snippet_select extends et2_placeholder_select placeholder_list.onchange = this._on_placeholder_select.bind(this); entry.onchange = this._on_placeholder_select.bind(this); + app.set_value(app.get_value()); this._on_placeholder_select(); } @@ -405,9 +432,13 @@ export class et2_placeholder_snippet_select extends et2_placeholder_select // 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()], + [placeholder_list.get_value(), entry.get_value()], function(_content) { + if(!_content) + { + _content = ''; + } this.set_value(_content); preview_content.set_value(_content); preview_content.getDOMNode().parentNode.style.visibility = _content.trim() ? null : 'hidden'; diff --git a/api/src/Contacts/Merge.php b/api/src/Contacts/Merge.php index 4c0dcf6681..e699886ed0 100644 --- a/api/src/Contacts/Merge.php +++ b/api/src/Contacts/Merge.php @@ -334,7 +334,7 @@ class Merge extends Api\Storage\Merge $group = 'customfields'; foreach($this->contacts->customfields as $name => $field) { - $placeholders[$group][$this->prefix($prefix, $name, '{')] = $field['label']; + $placeholders[$group][$this->prefix($prefix, '#' . $name, '{')] = $field['label']; } return $placeholders; } diff --git a/api/src/Etemplate/Widget/Placeholder.php b/api/src/Etemplate/Widget/Placeholder.php index 8eca0b211a..253aa7f93e 100644 --- a/api/src/Etemplate/Widget/Placeholder.php +++ b/api/src/Etemplate/Widget/Placeholder.php @@ -64,7 +64,9 @@ class Placeholder extends Etemplate\Widget if(is_null($apps)) { - $apps = ['addressbook', 'user', 'general']; + $apps = ['addressbook', 'user', 'general'] + + // We use linking for preview, so limit to apps that support links + array_keys(Api\Link::app_list('query')); } foreach($apps as $appname) @@ -79,30 +81,38 @@ class Placeholder extends Etemplate\Widget $list = $merge->get_common_placeholder_list(); break; default: - $list = $merge->get_placeholder_list(); + if(get_class($merge) === 'EGroupware\Api\Contacts\Merge' && $appname !== 'addressbook' || $placeholders[$appname]) + { + // Looks like app doesn't support merging + continue 2; + } + $list = method_exists($merge, 'get_placeholder_list') ? $merge->get_placeholder_list() : []; break; } - if(!is_null($group)) + if(!is_null($group) && is_array($list)) { $list = array_intersect_key($list, $group); } - $placeholders[$appname] = $list; + if($list) + { + $placeholders[$appname] = $list; + } } $response = Api\Json\Response::get(); $response->data($placeholders); } - public function ajax_fill_placeholders($app, $content, $entry) + public function ajax_fill_placeholders($content, $entry) { - $merge = Api\Storage\Merge::get_app_class($app); + $merge = Api\Storage\Merge::get_app_class($entry['app']); $err = ""; - switch($app) + switch($entry['app']) { case 'addressbook': default: - $merged = $merge->merge_string($content, [$entry], $err, 'text/plain'); + $merged = $merge->merge_string($content, [$entry['id']], $err, 'text/plain'); } $response = Api\Json\Response::get(); $response->data($merged); diff --git a/api/src/Storage/Merge.php b/api/src/Storage/Merge.php index 220d54a154..feb196491e 100644 --- a/api/src/Storage/Merge.php +++ b/api/src/Storage/Merge.php @@ -1600,9 +1600,9 @@ abstract class Merge */ public static function get_app_class($appname) { - if(class_exists($appname) && is_subclass_of($appname, 'EGroupware\\Api\\Storage\\Merge')) + $classname = "{$appname}_merge"; + if(class_exists($classname) && is_subclass_of($classname, 'EGroupware\\Api\\Storage\\Merge')) { - $classname = "{$appname}_merge"; $document_merge = new $classname(); } else @@ -2709,4 +2709,23 @@ abstract class Merge return $replacements; } + + /** + * Get a list of placeholders provided. + * + * Placeholders are grouped logically. Group key should have a user-friendly translation. + * Override this method and specify the placeholders, as well as groups or a specific order + */ + public function get_placeholder_list($prefix = '') + { + $placeholders = [ + 'placeholders' => [] + ]; + foreach(Customfields::get($this->get_app()) as $name => $field) + { + $placeholders['customfields'][$this->prefix($prefix, '#' . $name, '{')] = $field['label'] . ($field['type'] == 'select-account' ? '*' : ''); + } + + return $placeholders; + } } diff --git a/infolog/inc/class.infolog_merge.inc.php b/infolog/inc/class.infolog_merge.inc.php index 75ff5120ea..74bbcd0887 100644 --- a/infolog/inc/class.infolog_merge.inc.php +++ b/infolog/inc/class.infolog_merge.inc.php @@ -250,14 +250,44 @@ class infolog_merge extends Api\Storage\Merge echo '{{info_contact/#'.$name.'}}'.$field['label']."\n"; } - echo '

'.lang('General fields:')."

"; + echo '

' . lang('General fields:') . "

"; foreach($this->get_common_replacements() as $name => $label) { - echo '{{'.$name.'}}'.$label."\n"; + echo '{{' . $name . '}}' . $label . "\n"; } echo "\n"; echo $GLOBALS['egw']->framework->footer(); } + + public function get_placeholder_list($prefix = '') + { + $placeholders = parent::get_placeholder_list($prefix); + + $tracking = new infolog_tracking($this->bo); + $fields = array('info_id' => lang('Infolog ID'), 'pm_id' => lang('Project ID'), + 'project' => lang('Project name')) + $tracking->field2label + array('info_sum_timesheets' => lang('Used time')); + Api\Translation::add_app('projectmanager'); + + $group = 'placeholders'; + foreach($fields as $name => $label) + { + if(in_array($name, array('custom'))) + { + // dont show them + continue; + } + $marker = $this->prefix($prefix, $name, '{'); + if(!array_filter($placeholders, function ($a) use ($marker) + { + return array_key_exists($marker, $a); + })) + { + $placeholders[$group][$marker] = $label; + } + } + + return $placeholders; + } } From 2c9949f362af97aa93077b196cabbd4c957fc073 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Tue, 28 Sep 2021 11:33:50 +0200 Subject: [PATCH 16/56] REST API, do NOT take "Sync all in one addressbook" preference into account, but store in given AB --- addressbook/inc/class.addressbook_groupdav.inc.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index 019abb26d2..56146fc509 100644 --- a/addressbook/inc/class.addressbook_groupdav.inc.php +++ b/addressbook/inc/class.addressbook_groupdav.inc.php @@ -760,10 +760,10 @@ class addressbook_groupdav extends Api\CalDAV\Handler } else { - $contact['carddav_name'] = $id; + $contact['carddav_name'] = (!empty($id) ? basename($id, '.vcf') : $contact['uid']).'.vcf'; // only set owner, if user is explicitly specified in URL (check via prefix, NOT for /addressbook/) or sync-all-in-one!) - if ($prefix && !in_array('O',$this->home_set_pref) && $user) + if ($prefix && ($is_json || !in_array('O',$this->home_set_pref)) && $user) { $contact['owner'] = $user; } From ae5e11f7a2de7d49d0e967408ed2593b48e357ec Mon Sep 17 00:00:00 2001 From: Hadi Nategh Date: Tue, 28 Sep 2021 17:08:22 +0200 Subject: [PATCH 17/56] Translate details title before setting it into the DOM --- api/js/etemplate/et2_widget_box.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/js/etemplate/et2_widget_box.ts b/api/js/etemplate/et2_widget_box.ts index 6d63865419..70ae6ead83 100644 --- a/api/js/etemplate/et2_widget_box.ts +++ b/api/js/etemplate/et2_widget_box.ts @@ -241,7 +241,7 @@ export class et2_details extends et2_box .click(function () { self._toggle(); }) - .text(this.options.title); + .text(this.egw().lang(this.options.title)); } // Align toggle button left/right From a39eeef7e7f6a6384e65cc5900ddd6c4e3439e83 Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 28 Sep 2021 09:49:29 -0600 Subject: [PATCH 18/56] Placeholder dialog: Fix some missing translation issues --- api/js/etemplate/et2_widget_placeholder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/js/etemplate/et2_widget_placeholder.ts b/api/js/etemplate/et2_widget_placeholder.ts index 1ac6847123..3ae08beca1 100644 --- a/api/js/etemplate/et2_widget_placeholder.ts +++ b/api/js/etemplate/et2_widget_placeholder.ts @@ -168,7 +168,7 @@ export class et2_placeholder_select extends et2_inputWidget this.dialog = et2_createWidget("dialog", { callback: this.submit_callback, - title: this.options.dialog_title || this.egw().lang("Insert Placeholder"), + title: this.egw().lang(this.options.dialog_title) || this.egw().lang("Insert Placeholder"), buttons: buttons, minWidth: 500, minHeight: 400, From f6828a820585130deb9f82989ce0e5f3864abb6a Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 28 Sep 2021 10:16:57 -0600 Subject: [PATCH 19/56] Placeholder dialog: Add "name, email, phone snippet", fix some more missing translation issues --- api/js/etemplate/et2_widget_placeholder.ts | 5 +++-- api/templates/default/placeholder_snippet.xet | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/js/etemplate/et2_widget_placeholder.ts b/api/js/etemplate/et2_widget_placeholder.ts index 3ae08beca1..fe9696f344 100644 --- a/api/js/etemplate/et2_widget_placeholder.ts +++ b/api/js/etemplate/et2_widget_placeholder.ts @@ -368,8 +368,9 @@ export class et2_placeholder_snippet_select extends et2_placeholder_select static placeholders = { "addressbook": { "addresses": { - "{{n_fn}}\n{{adr_one_street}}{{NELF adr_one_street2}}\n{{adr_one_formatted}}": "Work address", + "{{org_name}}\n{{n_fn}}\n{{adr_one_street}}{{NELF adr_one_street2}}\n{{adr_one_formatted}}": "Business address", "{{n_fn}}\n{{adr_two_street}}{{NELF adr_two_street2}}\n{{adr_two_formatted}}": "Home address", + "{{n_fn}}\n{{email}}\n{{tel_work}}": "Name, email, phone" } } }; @@ -490,7 +491,7 @@ export class et2_placeholder_snippet_select extends et2_placeholder_select options.push( { value: key, - label: et2_placeholder_snippet_select.placeholders[appname][group][key] + label: this.egw().lang(et2_placeholder_snippet_select.placeholders[appname][group][key]) }); }); return options; diff --git a/api/templates/default/placeholder_snippet.xet b/api/templates/default/placeholder_snippet.xet index 504c51c7d7..1644649f3d 100644 --- a/api/templates/default/placeholder_snippet.xet +++ b/api/templates/default/placeholder_snippet.xet @@ -9,7 +9,7 @@