diff --git a/addressbook/inc/class.addressbook_groupdav.inc.php b/addressbook/inc/class.addressbook_groupdav.inc.php index aecac673ef..56146fc509 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']) : 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) { @@ -757,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; } @@ -1105,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/addressbook/inc/class.addressbook_hooks.inc.php b/addressbook/inc/class.addressbook_hooks.inc.php index fd39c71b41..9a514656a6 100644 --- a/addressbook/inc/class.addressbook_hooks.inc.php +++ b/addressbook/inc/class.addressbook_hooks.inc.php @@ -304,17 +304,27 @@ class addressbook_hooks 'admin' => False, ); $settings['document_dir'] = array( - 'type' => 'vfs_dirs', - 'size' => 60, - 'label' => 'Directory with documents to insert contacts', - 'name' => 'document_dir', - 'help' => lang('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 data inserted.',lang('addressbook')).' '. - lang('The document can contain placeholder like {{%1}}, to be replaced with the data.','n_fn').' '. - lang('The following document-types are supported:'). implode(',',Api\Storage\Merge::get_file_extensions()), + 'type' => 'vfs_dirs', + 'size' => 60, + 'label' => 'Directory with documents to insert contacts', + 'name' => 'document_dir', + 'help' => lang('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 data inserted.', lang('addressbook')) . ' ' . + lang('The document can contain placeholder like {{%1}}, to be replaced with the data.', 'n_fn') . ' ' . + lang('The following document-types are supported:') . implode(',', Api\Storage\Merge::get_file_extensions()), 'run_lang' => false, - 'xmlrpc' => True, - 'admin' => False, - 'default' => '/templates/addressbook', + 'xmlrpc' => True, + 'admin' => False, + 'default' => '/templates/addressbook', + ); + $settings[Api\Storage\Merge::PREF_DOCUMENT_FILENAME] = array( + 'type' => 'taglist', + 'label' => 'Document download filename', + 'name' => 'document_download_name', + 'values' => Api\Storage\Merge::DOCUMENT_FILENAME_OPTIONS, + 'help' => 'Choose the default filename for downloaded documents.', + 'xmlrpc' => True, + 'admin' => False, + 'default' => 'document', ); } diff --git a/addressbook/inc/class.addressbook_ui.inc.php b/addressbook/inc/class.addressbook_ui.inc.php index 242d13eaf6..3f50d10406 100644 --- a/addressbook/inc/class.addressbook_ui.inc.php +++ b/addressbook/inc/class.addressbook_ui.inc.php @@ -183,12 +183,12 @@ class addressbook_ui extends addressbook_bo $msg = ''; } } - if ($_content['nm']['rows']['infolog']) + if (!empty($_content['nm']['rows']['infolog'])) { $org = key($_content['nm']['rows']['infolog']); return $this->infolog_org_view($org); } - if ($_content['nm']['rows']['view']) // show all contacts of an organisation + if (!empty($_content['nm']['rows']['view'])) // show all contacts of an organisation { $grouped_view = key($_content['nm']['rows']['view']); } @@ -1736,9 +1736,13 @@ class addressbook_ui extends addressbook_bo if (isset($this->grouped_views[(string) $query['grouped_view']])) { // we have a grouped view, reset the advanced search - if(!$query['search'] && $old_state['advanced_search']) $query['advanced_search'] = $old_state['advanced_search']; + if (empty($query['search']) && !empty($old_state['advanced_search'])) + { + $query['advanced_search'] = $old_state['advanced_search']; + } } - elseif(!$query['search'] && array_key_exists('advanced_search',$old_state)) // eg. paging in an advanced search + // eg. paging in an advanced search + elseif(empty($query['search']) && is_array($old_state) && array_key_exists('advanced_search', $old_state)) { $query['advanced_search'] = $old_state['advanced_search']; } @@ -2256,7 +2260,7 @@ class addressbook_ui extends addressbook_bo // remove invalid shared-with entries (should not happen, as we validate already on client-side) $this->check_shared_with($content['shared']); - $button = @key($content['button']); + $button = @key($content['button'] ?? []); unset($content['button']); $content['private'] = (int) ($content['owner'] && substr($content['owner'],-1) == 'p'); $content['owner'] = (string) (int) $content['owner']; @@ -2984,7 +2988,7 @@ class addressbook_ui extends addressbook_bo if(is_array($content)) { - $button = is_array($content['button']) ? key($content['button']) : ""; + $button = key($content['button'] ?? []); switch ($button) { case 'vcard': @@ -3067,7 +3071,7 @@ class addressbook_ui extends addressbook_bo $_GET['contact_id'] = array_shift($rows); $_GET['index'] = 0; } - $contact_id = $_GET['contact_id'] ? $_GET['contact_id'] : ((int)$_GET['account_id'] ? 'account:'.(int)$_GET['account_id'] : 0); + $contact_id = $_GET['contact_id'] ?? ((int)$_GET['account_id'] ? 'account:'.(int)$_GET['account_id'] : 0); if(!$contact_id || !is_array($content = $this->read($contact_id))) { Egw::redirect_link('/index.php',array( diff --git a/admin/inc/class.admin_categories.inc.php b/admin/inc/class.admin_categories.inc.php index 0ee6634d6d..3dbfa81af8 100644 --- a/admin/inc/class.admin_categories.inc.php +++ b/admin/inc/class.admin_categories.inc.php @@ -139,7 +139,7 @@ class admin_categories $button = 'delete'; $delete_subs = $content['delete']['subs']?true:false; } - else + elseif (!empty($content['button'])) { $button = key($content['button']); unset($content['button']); @@ -564,7 +564,7 @@ class admin_categories { $content = array_merge($content,$content[$action.'_popup']); } - $content['nm']['action'] .= '_' . key($content[$action . '_action']); + $content['nm']['action'] .= '_' . key($content[$action . '_action'] ?? []); if(is_array($content[$action])) { @@ -680,7 +680,7 @@ class admin_categories { $cmd = new admin_cmd_delete_category( $cat_id, - key($content['button']) == 'delete_sub', + key($content['button'] ?? []) == 'delete_sub', $content['admin_cmd'] ); $cmd->run(); diff --git a/admin/inc/class.admin_cmds.inc.php b/admin/inc/class.admin_cmds.inc.php index 8dc65e060a..2836f772a3 100644 --- a/admin/inc/class.admin_cmds.inc.php +++ b/admin/inc/class.admin_cmds.inc.php @@ -70,7 +70,7 @@ class admin_cmds } $content['nm']['actions'] = self::cmd_actions(); } - elseif ($content['nm']['rows']['delete']) + elseif (!empty($content['nm']['rows']['delete'])) { $id = key($content['nm']['rows']['delete']); unset($content['nm']['rows']); diff --git a/admin/inc/class.admin_mail.inc.php b/admin/inc/class.admin_mail.inc.php index 9d4b8311de..f8539e40f5 100644 --- a/admin/inc/class.admin_mail.inc.php +++ b/admin/inc/class.admin_mail.inc.php @@ -212,7 +212,7 @@ class admin_mail public function autoconfig(array $content) { // user pressed [Skip IMAP] --> jump to SMTP config - if ($content['button'] && key($content['button']) == 'skip_imap') + if (!empty($content['button']) && key($content['button']) === 'skip_imap') { unset($content['button']); if (!isset($content['acc_smtp_host'])) $content['acc_smtp_host'] = ''; // do manual mode right away @@ -361,7 +361,7 @@ class admin_mail */ public function folder(array $content, $msg='', Horde_Imap_Client_Socket $imap=null) { - if (isset($content['button'])) + if (!empty($content['button'])) { $button = key($content['button']); unset($content['button']); @@ -482,7 +482,7 @@ class admin_mail ); $content['msg'] = $msg; - if (isset($content['button'])) + if (!empty($content['button'])) { $button = key($content['button']); unset($content['button']); @@ -619,7 +619,7 @@ class admin_mail ); $content['msg'] = $msg; - if (isset($content['button'])) + if (!empty($content['button'])) { $button = key($content['button']); unset($content['button']); @@ -835,7 +835,7 @@ class admin_mail { $content['called_for'] = (int)$_GET['account_id']; $content['accounts'] = iterator_to_array(Mail\Account::search($content['called_for'])); - if ($content['accounts']) + if (!empty($content['accounts'])) { $content['acc_id'] = key($content['accounts']); //error_log(__METHOD__.__LINE__.'.'.array2string($content['acc_id'])); @@ -949,7 +949,7 @@ class admin_mail $tpl->disableElement('notify_save_default', !$is_multiple || !$edit_access); $tpl->disableElement('notify_use_default', !$is_multiple); - if (isset($content['button'])) + if (!empty($content['button'])) { $button = key($content['button']); unset($content['button']); @@ -1031,7 +1031,7 @@ class admin_mail unset($content['smimeKeyUpload']); } self::fix_account_id_0($content['account_id'], true); - $content = Mail\Account::write($content, $content['called_for'] || !$this->is_admin ? + $content = Mail\Account::write($content, !empty($content['called_for']) && $this->is_admin ? $content['called_for'] : $GLOBALS['egw_info']['user']['account_id']); self::fix_account_id_0($content['account_id']); $msg = lang('Account saved.'); diff --git a/admin/inc/class.admin_ui.inc.php b/admin/inc/class.admin_ui.inc.php index 0eeae274a0..ef1f147912 100644 --- a/admin/inc/class.admin_ui.inc.php +++ b/admin/inc/class.admin_ui.inc.php @@ -204,7 +204,7 @@ class admin_ui $item['id'] = substr($item['extradata'], 11); unset($item['extradata']); $matches = null; - if ($item['options'] && preg_match('/(egw_openWindowCentered2?|window.open)\([^)]+,(\d+),(\d+).*(title="([^"]+)")?/', $item['options'], $matches)) + if (!empty($item['options']) && preg_match('/(egw_openWindowCentered2?|window.open)\([^)]+,(\d+),(\d+).*(title="([^"]+)")?/', $item['options'], $matches)) { $item['popup'] = $matches[2].'x'.$matches[3]; if (isset($matches[5])) $item['tooltip'] = $matches[5]; @@ -213,7 +213,7 @@ class admin_ui } if (empty($item['icon'])) $item['icon'] = $app.'/navbar'; if (empty($item['group'])) $item['group'] = $group; - if (empty($item['onExecute'])) $item['onExecute'] = $item['popup'] ? + if (empty($item['onExecute'])) $item['onExecute'] = !empty($item['popup']) ? 'javaScript:nm_action' : 'javaScript:app.admin.iframe_location'; if (!isset($item['allowOnMultiple'])) $item['allowOnMultiple'] = false; @@ -297,7 +297,7 @@ class admin_ui $item['id'] = substr($item['extradata'], 11); unset($item['extradata']); $matches = null; - if ($item['options'] && preg_match('/(egw_openWindowCentered2?|window.open)\([^)]+,(\d+),(\d+).*(title="([^"]+)")?/', $item['options'], $matches)) + if (!empty($item['options']) && preg_match('/(egw_openWindowCentered2?|window.open)\([^)]+,(\d+),(\d+).*(title="([^"]+)")?/', $item['options'], $matches)) { $item['popup'] = $matches[2].'x'.$matches[3]; $item['onExecute'] = 'javaScript:nm_action'; @@ -326,7 +326,7 @@ class admin_ui public static function get_users(array $query, array &$rows=null) { $params = array( - 'type' => (int)$query['filter'] ? (int)$query['filter'] : 'accounts', + 'type' => (int)($query['filter'] ?? 0) ?: 'accounts', 'start' => $query['start'], 'offset' => $query['num_rows'], 'order' => $query['order'], @@ -334,7 +334,7 @@ class admin_ui 'active' => !empty($query['active']) ? $query['active'] : false, ); // Make sure active filter give status what it needs - switch($query['filter2']) + switch($query['filter2'] ?? '') { case 'disabled': case 'expired': @@ -356,12 +356,12 @@ class admin_ui break; } - if ($query['searchletter']) + if (!empty($query['searchletter'])) { $params['query'] = $query['searchletter']; $params['query_type'] = 'start'; } - elseif($query['search']) + elseif(!empty($query['search'])) { $params['query'] = $query['search']; $params['query_type'] = 'all'; @@ -377,7 +377,7 @@ class admin_ui foreach($rows as $key => &$row) { // Filter by status - if ($need_status_filter && !static::filter_status($need_status_filter, $row)) + if (!empty($need_status_filter) && !static::filter_status($need_status_filter, $row)) { unset($rows[$key]); $total--; @@ -391,8 +391,8 @@ class admin_ui if (!self::$accounts->is_active($row)) $row['status_class'] = 'adminAccountInactive'; } - // finally limit query, if status filter was used - if ($need_status_filter) + // finally, limit query, if status filter was used + if (!empty($need_status_filter)) { $rows = array_values(array_slice($rows, (int)$query['start'], $query['num_rows'] ?: count($rows))); } @@ -436,9 +436,9 @@ class admin_ui { $groups = $GLOBALS['egw']->accounts->search(array( 'type' => 'groups', - 'query' => $query['search'], - 'order' => $query['order'], - 'sort' => $query['sort'], + 'query' => $query['search'] ?? null, + 'order' => $query['order'] ?? null, + 'sort' => $query['sort'] ?? null, 'start' => (int)$query['start'], 'offset' => (int)$query['num_rows'] )); @@ -463,7 +463,7 @@ class admin_ui $run_rights = $GLOBALS['egw']->acl->get_user_applications($group['account_id'], false, false); foreach($apps as $app) { - if((boolean)$run_rights[$app]) + if(!empty($run_rights[$app])) { $group['apps'][] = $app; } @@ -537,7 +537,7 @@ class admin_ui if (!empty($data['icon'])) { $icon = Etemplate\Widget\Tree::imagePath($data['icon']); - if ($data['child'] || $data[Tree::CHILDREN]) + if (!empty($data['child']) || !empty($data[Tree::CHILDREN])) { $data[Tree::IMAGE_FOLDER_OPEN] = $data[Tree::IMAGE_FOLDER_CLOSED] = $icon; } diff --git a/api/js/etemplate/et2_widget_box.ts b/api/js/etemplate/et2_widget_box.ts index dc181b5f24..dbc9402244 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 diff --git a/api/js/etemplate/et2_widget_placeholder.ts b/api/js/etemplate/et2_widget_placeholder.ts index 06d42a3245..bdbef7b34a 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); @@ -132,7 +138,13 @@ export class et2_placeholder_select extends et2_inputWidget let data = { content: {app: '', group: '', entry: {}}, sel_options: {app: [], group: []}, - modifications: {outer_box: {entry: {}}} + modifications: { + outer_box: { + entry: { + application_list: [] + } + } + } }; Object.keys(_data).map((key) => @@ -145,9 +157,16 @@ 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.group = data.sel_options.group[0]?.value; + data.content.entry = {app: data.content.app}; data.modifications.outer_box.entry.application_list = Object.keys(_data); + // Remove non-app placeholders (user & general) + let non_apps = ['user', 'general']; + for(let i = 0; i < non_apps.length; i++) + { + let index = data.modifications.outer_box.entry.application_list.indexOf(non_apps[i]); + data.modifications.outer_box.entry.application_list.splice(index, 1); + } // callback for dialog this.submit_callback = function(submit_button_id, submit_value) @@ -162,7 +181,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, @@ -207,14 +226,35 @@ 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()}); + preview.set_value(""); + if(['user'].indexOf(widget.get_value()) >= 0) + { + entry.set_disabled(true); + entry.app_select.val('user'); + entry.set_value({app: 'user', id: '', query: ''}); + } + else if(widget.get_value() == 'general') + { + // Don't change entry app, leave it + entry.set_disabled(false); + } + else + { + entry.set_disabled(false); + entry.app_select.val(widget.get_value()); + entry.set_value({app: widget.get_value(), id: '', query: ''}); + } + let groups = this._get_group_options(widget.get_value()); + group.set_select_options(groups); + group.set_value(groups[0].value); + group.onchange(); } 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 +267,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 +292,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) @@ -277,11 +321,37 @@ export class et2_placeholder_select extends et2_inputWidget let options = []; Object.keys(et2_placeholder_select.placeholders[appname]).map((key) => { - options.push( + // @ts-ignore + if(Object.keys(et2_placeholder_select.placeholders[appname][key]).filter((key) => isNaN(key)).length > 0) + { + // Handle groups of groups + if(typeof et2_placeholder_select.placeholders[appname][key].label !== "undefined") { + options[key] = et2_placeholder_select.placeholders[appname][key]; + } + else + { + options[this.egw().lang(key)] = []; + for(let sub of Object.keys(et2_placeholder_select.placeholders[appname][key])) + { + if(!et2_placeholder_select.placeholders[appname][key][sub]) + { + continue; + } + options[key].push({ + value: key + '-' + sub, + label: this.egw().lang(sub) + }); + } + } + } + else + { + options.push({ value: key, label: this.egw().lang(key) }); + } }); return options; } @@ -295,16 +365,13 @@ export class et2_placeholder_select extends et2_inputWidget */ _get_placeholders(appname : string, group : string) { - let options = []; - Object.keys(et2_placeholder_select.placeholders[appname][group]).map((key) => + let _group = group.split('-', 2); + let ph = et2_placeholder_select.placeholders[appname]; + for(let i = 0; typeof ph !== "undefined" && i < _group.length; i++) { - options.push( - { - value: key, - label: et2_placeholder_select.placeholders[appname][group][key] - }); - }); - return options; + ph = ph[_group[i]]; + } + return ph || []; } /** @@ -342,8 +409,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" } } }; @@ -385,6 +453,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 +474,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(), {app: "addressbook", id: 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'; @@ -459,7 +532,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/js/etemplate/et2_widget_selectbox.ts b/api/js/etemplate/et2_widget_selectbox.ts index d3e2b923c3..7addc5ea16 100644 --- a/api/js/etemplate/et2_widget_selectbox.ts +++ b/api/js/etemplate/et2_widget_selectbox.ts @@ -991,7 +991,8 @@ export class et2_selectbox extends et2_inputWidget if(sub == 'value') continue; if (typeof _options[key][sub] === 'object' && _options[key][sub] !== null) { - this._appendOptionElement(sub, + this._appendOptionElement( + typeof _options[key][sub]["value"] !== "undefined" ? _options[key][sub]["value"] : sub, _options[key][sub]["label"] ? _options[key][sub]["label"] : "", _options[key][sub]["title"] ? _options[key][sub]["title"] : "", group diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 4750ffae04..396d3169ca 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -853,6 +853,38 @@ export class etemplate2 } } + /** + * Check if there is an invalid widget / all widgets are valid + * + * @param container + * @param values + * @return et2_widget|null first invalid widget or null, if all are valid + */ + isInvalid(container : et2_container|undefined, values : object|undefined) : et2_widget|null + { + if (typeof container === 'undefined') + { + container = this._widgetContainer; + } + if (typeof values === 'undefined') + { + values = this.getValues(container); + } + let invalid = null; + container.iterateOver(function (_widget) + { + if (_widget.submit(values) === false) + { + if(!invalid && !_widget.isValid([])) + { + invalid = _widget; + } + } + }, this, et2_ISubmitListener); + + return invalid; + } + /** * Submit form via ajax * @@ -881,17 +913,7 @@ export class etemplate2 let invalid = null; if (!no_validation) { - container.iterateOver(function (_widget) - { - if (_widget.submit(values) === false) - { - if (!invalid && !_widget.isValid()) - { - invalid = _widget; - } - canSubmit = false; - } - }, this, et2_ISubmitListener); + canSubmit = !(invalid = this.isInvalid(container, values)); } if (canSubmit) @@ -1098,7 +1120,6 @@ export class etemplate2 return result; } - /** * "Intelligently" refresh the template based on the given ID * diff --git a/api/js/jsapi/egw_app.ts b/api/js/jsapi/egw_app.ts index 5dc1b549aa..a4e5a459b7 100644 --- a/api/js/jsapi/egw_app.ts +++ b/api/js/jsapi/egw_app.ts @@ -725,7 +725,7 @@ export abstract class EgwApp framework.pushState('view'); if(templateName) { - this.et2_view.load(this.appname+'.'+templateName,templateURL, data, typeof et2_callback == 'function'?et2_callback:function(){}, app); + this.et2_view.load(this.appname + '.' + templateName, templateURL, data, typeof et2_callback == 'function' ? et2_callback : function() {}, app); } // define a global close function for view template @@ -733,6 +733,49 @@ export abstract class EgwApp this.et2_view.close = destroy; } + /** + * Merge selected entries into template document + * + * @param {egwAction} _action + * @param {egwActionObject[]} _selected + */ + merge(_action : egwAction, _selected : egwActionObject[]) + { + // Find what we need + let nm = null; + let action = _action; + let as_pdf = false; + + // Find Select all + while(nm == null && action != null) + { + if(action.data != null && action.data.nextmatch) + { + nm = action.data.nextmatch; + } + action = action.parent; + } + let all = nm?.getSelection().all || false; + + as_pdf = action.getActionById('as_pdf')?.checked || false; + + // Get list of entry IDs + let ids = []; + for(let i = 0; !all && i < _selected.length; i++) + { + let split = _selected[i].id.split("::"); + ids.push(split[1]); + } + + let vars = { + ..._action.data.merge_data, + pdf: as_pdf, + select_all: all, + id: JSON.stringify(ids) + }; + egw.open_link(egw.link('/index.php', vars), '_blank'); + } + /** * Initializes actions and handlers on sidebox (delete) * @@ -741,12 +784,12 @@ export abstract class EgwApp _init_sidebox(sidebox) { // Initialize egw tutorial sidebox, but only for non-popups, as calendar edit app.js has this.et2 set to tutorial et2 object - if (!this.egw.is_popup()) + if(!this.egw.is_popup()) { var egw_fw = egw_getFramework(); - var tutorial = jQuery('#egw_tutorial_'+this.appname+'_sidebox', egw_fw ? egw_fw.sidemenuDiv : document); + var tutorial = jQuery('#egw_tutorial_' + this.appname + '_sidebox', egw_fw ? egw_fw.sidemenuDiv : document); // _init_sidebox gets currently called multiple times, which needs to be fixed - if (tutorial.length && !this.tutorial_initialised) + if(tutorial.length && !this.tutorial_initialised) { this.egwTutorial_init(tutorial[0]); this.tutorial_initialised = true; 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 diff --git a/api/src/Accounts.php b/api/src/Accounts.php index 76e80b4035..889f6c1907 100644 --- a/api/src/Accounts.php +++ b/api/src/Accounts.php @@ -223,7 +223,7 @@ class Accounts if (!empty($param['offset']) && !isset($param['start'])) $param['start'] = 0; // Check for lang(Group) in search - if there, we search all groups - $group_index = array_search(strtolower(lang('Group')), array_map('strtolower', $query = explode(' ',$param['query']))); + $group_index = array_search(strtolower(lang('Group')), array_map('strtolower', $query = explode(' ',$param['query'] ?? ''))); if($group_index !== FALSE && !( in_array($param['type'], array('accounts', 'groupmembers')) || is_int($param['type']) )) @@ -595,12 +595,12 @@ class Accounts /** * Return formatted username for a given account_id * - * @param string $account_id =null account id - * @return string full name of user or "#$accountid" if user not found + * @param int $account_id account id + * @return string full name of user or "#$account_id" if user not found */ - static function username($account_id=null) + static function username(int $account_id) { - if ($account_id && !($account = self::cache_read((int)$account_id))) + if (!($account = self::cache_read($account_id))) { return '#'.$account_id; } 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.php b/api/src/Contacts.php index 09acb061ad..2409357a24 100755 --- a/api/src/Contacts.php +++ b/api/src/Contacts.php @@ -483,7 +483,7 @@ class Contacts extends Contacts\Storage 'bday' => (int)$contact['bday'] ? DateTime::to($contact['bday'], true) : $contact['bday'], ))); - while ($fileas[0] == ':' || $fileas[0] == ',') + while (!empty($fileas) && ($fileas[0] == ':' || $fileas[0] == ',')) { $fileas = substr($fileas,2); } @@ -764,10 +764,10 @@ class Contacts extends Contacts\Storage $data[$name] = DateTime::server2user($data[$name], $date_format); } } - $data['photo'] = $this->photo_src($data['id'],$data['jpegphoto'] || ($data['files'] & self::FILES_BIT_PHOTO), '', $data['etag']); + $data['photo'] = $this->photo_src($data['id'],!empty($data['jpegphoto']) || (($data['files']??0) & self::FILES_BIT_PHOTO), '', $data['etag'] ?? null); // set freebusy_uri for accounts - if (!$data['freebusy_uri'] && !$data['owner'] && $data['account_id'] && !is_object($GLOBALS['egw_setup'])) + if (empty($data['freebusy_uri']) && empty($data['owner']) && !empty($data['account_id']) && empty($GLOBALS['egw_setup'])) { if ($fb_url || @is_dir(EGW_SERVER_ROOT.'/calendar/inc')) { @@ -1686,7 +1686,7 @@ class Contacts extends Contacts\Storage { $result[$contact['id']] = $this->link_title($contact+(array)$cfs[$contact['id']]); // make sure to return a correctly quoted rfc822 address, if requested - if ($options['type'] === 'email') + if (isset($options['type']) && $options['type'] === 'email') { $args = explode('@', $contact['email']); $args[] = $result[$contact['id']]; diff --git a/api/src/Contacts/JsContact.php b/api/src/Contacts/JsContact.php index da494921cd..e6493dc5a7 100644 --- a/api/src/Contacts/JsContact.php +++ b/api/src/Contacts/JsContact.php @@ -78,16 +78,34 @@ 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 bool $check_at_type 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, bool $check_at_type=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 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); + } + if (!isset($data['uid'])) $data['uid'] = null; // to fail below, if it does not exist $contact = []; @@ -96,53 +114,72 @@ 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': $contact['n_fn'] = self::parseString($value); + // if no separate name-components given, simply split first word off as n_given and rest as n_family + if (!isset($data['name']) && !empty($contact['n_fn'])) + { + if (preg_match('/^([^ ,]+)(,?) (.*)$/', $contact['n_fn'], $matches)) + { + if (!empty($matches[2])) + { + list(, $contact['n_family'], , $contact['n_given']) = $matches; + } + else + { + list(, $contact['n_given'], , $contact['n_family']) = $matches; + } + } + else + { + $contact['n_family'] = $contact['n_fn']; + } + } 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': @@ -150,7 +187,7 @@ class JsContact break; case 'egroupware.org:customfields': - $contact += self::parseCustomfields($value); + $contact += self::parseCustomfields($value, $strict); break; case 'egroupware.org:assistant': @@ -197,11 +234,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 +249,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 +294,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 +352,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])); } @@ -397,8 +447,12 @@ class JsContact foreach($definitions as $name => $definition) { $data = $cfs[$name]; - if (isset($data[$name])) + if (isset($data)) { + if (is_scalar($data)) + { + $data = ['value' => $data]; + } if (!is_array($data) || !array_key_exists('value', $data)) { throw new \InvalidArgumentException("Invalid customfield object $name: ".json_encode($data, self::JSON_OPTIONS_ERROR)); @@ -526,21 +580,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 +621,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 +632,10 @@ 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']); + if (!empty($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])) @@ -586,6 +644,17 @@ class JsContact } $contact[$prefix.$attr] = $address[$js]; } + // no country-code but a name translating to a code --> use it + if (empty($contact[$prefix.'countrycode']) && !empty($contact[$prefix.'countryname']) && + strlen($code = Api\Country::country_code($contact[$prefix.'countryname'])) === 2) + { + $contact[$prefix.'countrycode'] = $code; + } + // if we have a valid code, the untranslated name as our UI does + if (!empty($contact[$prefix.'countrycode']) && !empty($name = Api\Country::get_full_name($contact[$prefix.'countrycode'], false))) + { + $contact[$prefix.'countryname'] = $name; + } return $contact; } @@ -637,12 +706,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 +728,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 +789,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 +899,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 +974,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)); } @@ -905,7 +994,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']; } @@ -953,11 +1042,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 +1097,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 +1146,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 +1175,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 +1359,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/api/src/Contacts/Merge.php b/api/src/Contacts/Merge.php index 0e9a496813..4dc6d5a47c 100644 --- a/api/src/Contacts/Merge.php +++ b/api/src/Contacts/Merge.php @@ -275,7 +275,30 @@ class Merge extends Api\Storage\Merge */ public function get_placeholder_list($prefix = '') { - $placeholders = []; + // Specific order for these ones + $placeholders = [ + 'contact' => [], + 'details' => [ + [ + 'value' => $this->prefix($prefix, 'categories', '{'), + 'label' => lang('Category path') + ], + ['value' => $this->prefix($prefix, 'note', '{'), + 'label' => $this->contacts->contact_fields['note']], + ['value' => $this->prefix($prefix, 'id', '{'), + 'label' => $this->contacts->contact_fields['id']], + ['value' => $this->prefix($prefix, 'owner', '{'), + 'label' => $this->contacts->contact_fields['owner']], + ['value' => $this->prefix($prefix, 'private', '{'), + 'label' => $this->contacts->contact_fields['private']], + ['value' => $this->prefix($prefix, 'cat_id', '{'), + 'label' => $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,25 +322,37 @@ 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'); + count(array_filter($a, function ($b) use ($marker) + { + return $b['value'] == $marker; + }) + ) > 0; + })) + { + $placeholders[$group][] = [ + 'value' => $marker, + 'label' => $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'][] = [ + 'value' => $this->prefix($prefix, 'adr_one_formatted', '{'), + 'label' => "Formatted business address" + ]; + $placeholders['private'][] = [ + 'value' => $this->prefix($prefix, 'adr_two_formatted', '{'), + 'label' => "Formatted private address" + ]; - $group = 'customfields'; - foreach($this->contacts->customfields as $name => $field) - { - $placeholders[$group]["{{" . ($prefix ? $prefix . '/' : '') . $name . "}}"] = $field['label']; - } + $this->add_customfield_placeholders($placeholders, $prefix); return $placeholders; } diff --git a/api/src/Country.php b/api/src/Country.php index 5b86e0e75d..9f9610d4dd 100755 --- a/api/src/Country.php +++ b/api/src/Country.php @@ -766,6 +766,19 @@ class Country { if (!$name) return ''; // nothing to do + // handle names like "Germany (Deutschland)" + if (preg_match('/^([^(]+) \(([^)]+)\)$/', $name, $matches)) + { + if (($code = self::country_code($matches[1])) && strlen($code) === 2) + { + return $code; + } + if (($code = self::country_code($matches[2])) && strlen($code) === 2) + { + return $code; + } + } + if (strlen($name) == 2 && isset(self::$country_array[$name])) { return $name; // $name is already a country-code diff --git a/api/src/Etemplate/Widget.php b/api/src/Etemplate/Widget.php index b5b02787a3..00a591f000 100644 --- a/api/src/Etemplate/Widget.php +++ b/api/src/Etemplate/Widget.php @@ -114,7 +114,7 @@ class Widget // Update content? if(self::$cont == null) self::$cont = is_array(self::$request->content) ? self::$request->content : array(); - if($this->id && is_array(self::$cont[$this->id])) + if($this->id && is_array(self::$cont[$this->id] ?? null)) { $old_cont = self::$cont; self::$cont = self::$cont[$this->id]; @@ -147,7 +147,8 @@ class Widget } // Reset content as we leave - if($old_cont) { + if (isset($old_cont)) + { self::$cont = $old_cont; } } @@ -206,7 +207,7 @@ class Widget $template = $this; while($reader->moveToNextAttribute()) { - if ($reader->name != 'id' && $template->attr[$reader->name] !== $reader->value) + if ($reader->name != 'id' && (!isset($template->attr[$reader->name]) || $template->attr[$reader->name] !== $reader->value)) { if (!$cloned) { @@ -218,7 +219,7 @@ class Widget $template->attrs[$reader->name] = $value = $reader->value; // expand attributes values, otherwise eg. validation can not use attrs referencing to content - if ($value[0] == '@' || strpos($value, '$cont') !== false) + if (!empty($value) && ($value[0] === '@' || strpos($value, '$cont') !== false)) { $value = self::expand_name($value, null, null, null, null, isset(self::$cont) ? self::$cont : self::$request->content); @@ -237,7 +238,7 @@ class Widget } // Add in anything in the modification array - if(is_array(self::$request->modifications[$this->id])) + if (is_array(self::$request->modifications[$this->id] ?? null)) { $this->attrs = array_merge($this->attrs,self::$request->modifications[$this->id]); } @@ -426,7 +427,7 @@ class Widget class_exists($class_name = $basetype.'_etemplate_widget')))) { // Try for base type, it's probably better than the root - if(self::$widget_registry[$basetype] && self::$widget_registry[$basetype] != $class_name) + if(isset(self::$widget_registry[$basetype]) && self::$widget_registry[$basetype] !== $class_name) { $class_name = self::$widget_registry[$basetype]; } @@ -535,12 +536,12 @@ class Widget // maintain $expand array name-expansion $cname = $params[0]; $expand =& $params[1]; - if ($expand['cname'] && $expand['cname'] !== $cname) + if (isset($expand['cname']) && $expand['cname'] !== $cname) { $expand['cont'] =& self::get_array(self::$request->content, $cname); $expand['cname'] = $cname; } - if ($respect_disabled && ($disabled = $this->attrs['disabled'] && self::check_disabled($this->attrs['disabled'], $expand))) + if ($respect_disabled && ($disabled = isset($this->attrs['disabled']) && self::check_disabled($this->attrs['disabled'], $expand))) { //error_log(__METHOD__."('$method_name', ".array2string($params).', '.array2string($respect_disabled).") $this disabled='{$this->attrs['disabled']}'=".array2string($disabled).": NOT running"); return; @@ -593,13 +594,13 @@ class Widget foreach($attrs as $name => &$value) { if(!is_string($value)) continue; - $value = self::expand_name($value,$expand['c'], $expand['row'], $expand['c_'], $expand['row_'], $expand['cont']); + $value = self::expand_name($value, $expand['c'] ?? null, $expand['row'] ?? null, $expand['c_'] ?? null, $expand['row_'] ?? null, $expand['cont'] ?? []); } - if($attrs['attributes']) + if (!empty($attrs['attributes'])) { $attrs = array_merge($attrs, $attrs['attributes']); } - if(strpos($child->attrs['type'], '@') !== false || strpos($child->attrs['type'], '$') !== false) + if (!empty($child->attrs['type']) && (strpos($child->attrs['type'], '@') !== false || strpos($child->attrs['type'], '$') !== false)) { $type = self::expand_name($child->attrs['type'],$expand['c'], $expand['row'], $expand['c_'], $expand['row_'], $expand['cont']); $id = self::expand_name($child->id,$expand['c'], $expand['row'], $expand['c_'], $expand['row_'], $expand['cont']); @@ -677,7 +678,7 @@ class Widget */ protected static function expand_name($name,$c,$row,$c_=0,$row_=0,$cont=array()) { - $is_index_in_content = $name[0] == '@'; + $is_index_in_content = !empty($name) && $name[0] == '@'; if (($pos_var=strpos($name,'$')) !== false) { if (!$cont) @@ -687,8 +688,8 @@ class Widget if (!is_numeric($c)) $c = self::chrs2num($c); $col = self::num2chrs($c-1); // $c-1 to get: 0:'@', 1:'A', ... if (is_numeric($c_)) $col_ = self::num2chrs($c_-1); - $row_cont = $cont[$row]; - $col_row_cont = $cont[$col.$row]; + $row_cont = $cont[$row] ?? null; + $col_row_cont = $cont[$col.$row] ?? null; try { eval('$name = "' . str_replace('"', '\\"', $name) . '";'); @@ -726,9 +727,9 @@ class Widget */ static function chrs2num($chrs) { + if (empty($chrs)) return 0; $min = ord('A'); $max = ord('Z') - $min + 1; - $num = 1+ord($chrs[0])-$min; if (strlen($chrs) > 1) { @@ -751,7 +752,7 @@ class Widget if ($num >= $max) { $chrs = chr(($num / $max) + $min - 1); - } + } else $chrs = ''; $chrs .= chr(($num % $max) + $min); return $chrs; @@ -829,7 +830,7 @@ class Widget { if ($expand && !empty($name)) { - $name = self::expand_name($name, $expand['c'], $expand['row'], $expand['c_'], $expand['row_'], $expand['cont']); + $name = self::expand_name($name, $expand['c'] ?? null, $expand['row'] ?? null, $expand['c_'] ?? null, $expand['row_'] ?? null, $expand['cont'] ?? []); } if (count($name_parts = explode('[', $name, 2)) > 1) { diff --git a/api/src/Etemplate/Widget/Box.php b/api/src/Etemplate/Widget/Box.php index 3a1bb39b1b..de3a379091 100644 --- a/api/src/Etemplate/Widget/Box.php +++ b/api/src/Etemplate/Widget/Box.php @@ -50,12 +50,12 @@ class Box extends Etemplate\Widget $old_expand = $params[1]; if ($this->id && $this->type != 'groupbox') $cname = self::form_name($cname, $this->id, $params[1]); - if ($expand['cname'] !== $cname && trim($cname) != '') + if (!empty($expand['cname']) && $expand['cname'] !== $cname && trim($cname)) { $expand['cont'] =& self::get_array(self::$request->content, $cname); $expand['cname'] = $cname; } - if ($respect_disabled && ($disabled = $this->attrs['disabled'] && self::check_disabled($this->attrs['disabled'], $expand))) + if ($respect_disabled && isset($this->attrs['disabled']) && self::check_disabled($this->attrs['disabled'], $expand)) { //error_log(__METHOD__."('$method_name', ".array2string($params).', '.array2string($respect_disabled).") $this disabled='{$this->attrs['disabled']}'=".array2string($disabled).": NOT running"); return; @@ -73,7 +73,7 @@ class Box extends Etemplate\Widget // Expand children $columns_disabled = null; - if($this->id && $this->children[0] && strpos($this->children[0]->id, '$') !== false) + if($this->id && isset($this->children[0]) && strpos($this->children[0]->id, '$') !== false) { // Need to set this so the first child can repeat $expand['row'] = 0; diff --git a/api/src/Etemplate/Widget/Date.php b/api/src/Etemplate/Widget/Date.php index b9842ffe51..acbaaef8b0 100644 --- a/api/src/Etemplate/Widget/Date.php +++ b/api/src/Etemplate/Widget/Date.php @@ -29,7 +29,7 @@ use EGroupware\Api; * &8 = dont show time for readonly and type date-time if time is 0:00, * &16 = prefix r/o display with dow * &32 = prefix r/o display with week-number - * &64 = prefix r/o display with weeknumber and dow + * &64 = prefix r/o display with weeknumber and dow * &128 = no icon to trigger popup, click into input trigers it, also removing the separators to save space * * @todo validation of date-duration @@ -120,7 +120,7 @@ class Date extends Transformer { $date = Api\DateTime::server2user($value); } - elseif($this->attrs['data_format'] && $this->attrs['data_format'] !== 'object') + elseif (!empty($this->attrs['data_format']) && $this->attrs['data_format'] !== 'object') { $date = Api\DateTime::createFromFormat($this->attrs['data_format'], $value, Api\DateTime::$user_timezone); } diff --git a/api/src/Etemplate/Widget/Grid.php b/api/src/Etemplate/Widget/Grid.php index 7a1ca0c84c..17433ba3e1 100644 --- a/api/src/Etemplate/Widget/Grid.php +++ b/api/src/Etemplate/Widget/Grid.php @@ -80,7 +80,7 @@ class Grid extends Box $columns_disabled = array(); } - if ($respect_disabled && ($disabled = $this->attrs['disabled'] && self::check_disabled($this->attrs['disabled'], $expand))) + if ($respect_disabled && isset($this->attrs['disabled']) && self::check_disabled($this->attrs['disabled'], $expand)) { //error_log(__METHOD__."('$method_name', ".array2string($params).', '.array2string($respect_disabled).") $this disabled='{$this->attrs['disabled']}'=".array2string($disabled).": NOT running"); $params[0] = $old_cname; @@ -89,7 +89,7 @@ class Grid extends Box } if ($this->id && $this->type !== 'row') $cname = self::form_name($cname, $this->id, $expand); - if ($expand['cname'] !== $cname && $cname) + if (!empty($expand['cname']) && $expand['cname'] !== $cname && $cname) { $expand['cont'] =& self::get_array(self::$request->content, $cname); $expand['cname'] = $cname; diff --git a/api/src/Etemplate/Widget/Nextmatch.php b/api/src/Etemplate/Widget/Nextmatch.php index 34d87826d2..d6ccd128c7 100644 --- a/api/src/Etemplate/Widget/Nextmatch.php +++ b/api/src/Etemplate/Widget/Nextmatch.php @@ -186,21 +186,21 @@ class Nextmatch extends Etemplate\Widget if (true) $value =& self::get_array(self::$request->content, $form_name, true); // Add favorite here so app doesn't save it in the session - if($_GET['favorite']) + if (!empty($_GET['favorite'])) { $send_value['favorite'] = $safe_name; } if (true) $value = $send_value; - $value['total'] = $total; + $value['total'] = $total ?? null; // Send categories - if(!$value['no_cat'] && !$value['cat_is_select']) + if(empty($value['no_cat']) && empty($value['cat_is_select'])) { - $cat_app = $value['cat_app'] ? $value['cat_app'] : $GLOBALS['egw_info']['flags']['current_app']; - $value['options-cat_id'] = self::$request->sel_options['cat_id'] ? self::$request->sel_options['cat_id'] : array(); + $cat_app = $value['cat_app'] ?? $GLOBALS['egw_info']['flags']['current_app'] ?? ''; + $value['options-cat_id'] = self::$request->sel_options['cat_id'] ?? []; // Add 'All', if not already there - if(!$value['options-cat_id'][''] && !$value['options-cat_id'][0]) + if(empty($value['options-cat_id']['']) && empty($value['options-cat_id'][0])) { $value['options-cat_id'][''] = lang('All categories'); } @@ -220,7 +220,7 @@ class Nextmatch extends Etemplate\Widget if(strpos($name, 'options-') !== false && $_value) { $select = substr($name, 8); - if(!self::$request->sel_options[$select]) + if (empty(self::$request->sel_options[$select])) { self::$request->sel_options[$select] = array(); } @@ -231,21 +231,21 @@ class Nextmatch extends Etemplate\Widget //unset($value[$name]); } } - if($value['rows']['sel_options']) + if (!empty($value['rows']['sel_options'])) { self::$request->sel_options = array_merge(self::$request->sel_options,$value['rows']['sel_options']); unset($value['rows']['sel_options']); } // If column selection preference is forced, set a flag to turn off UI - $pref_name = 'nextmatch-' . (isset($value['columnselection_pref']) ? $value['columnselection_pref'] : $this->attrs['template']); - $value['no_columnselection'] = $value['no_columnselection'] || ( - $GLOBALS['egw']->preferences->forced[$app][$pref_name] && + $pref_name = 'nextmatch-' . ($value['columnselection_pref'] ?? $this->attrs['template'] ?? ''); + $value['no_columnselection'] = !empty($value['no_columnselection']) || ( + !empty($GLOBALS['egw']->preferences->forced[$app][$pref_name]) && // Need to check admin too, or it will be impossible to turn off - !$GLOBALS['egw_info']['user']['apps']['admin'] + empty($GLOBALS['egw_info']['user']['apps']['admin']) ); // Use this flag to indicate to the admin that columns are forced (and that's why they can't change) - $value['columns_forced'] = (boolean)$GLOBALS['egw']->preferences->forced[$app][$pref_name]; + $value['columns_forced'] = !empty($GLOBALS['egw']->preferences->forced[$app][$pref_name]); // todo: no need to store rows in request, it's enought to send them to client @@ -256,7 +256,7 @@ class Nextmatch extends Etemplate\Widget if (isset($value['actions']) && !isset($value['actions'][0])) { $value['action_links'] = array(); - $template_name = isset($value['template']) ? $value['template'] : ($this->attrs['template'] ?: $this->attrs['options']); + $template_name = isset($value['template']) ? $value['template'] : ($this->attrs['template'] ?? $this->attrs['options'] ?? null); if (!is_array($value['action_links'])) $value['action_links'] = array(); $value['actions'] = self::egw_actions($value['actions'], $template_name, '', $value['action_links']); } @@ -375,8 +375,8 @@ class Nextmatch extends Etemplate\Widget $GLOBALS['egw']->session->commit_session(); - $row_id = isset($value['row_id']) ? $value['row_id'] : 'id'; - $row_modified = $value['row_modified']; + $row_id = $value['row_id'] ?? 'id'; + $row_modified = $value['row_modified'] ?? null; foreach($rows as $n => $row) { @@ -384,12 +384,12 @@ class Nextmatch extends Etemplate\Widget if (is_int($n) && $row) { if (!isset($row[$row_id])) unset($row_id); // unset default row_id of 'id', if not used - if (!isset($row[$row_modified])) unset($row_modified); + if (empty($row[$row_modified])) unset($row_modified); $id = $row_id ? $row[$row_id] : $n; $result['order'][] = $id; - $modified = $row[$row_modified]; + $modified = $row[$row_modified] ?? null; if (isset($modified) && !(is_int($modified) || is_string($modified) && is_numeric($modified))) { $modified = Api\DateTime::to(str_replace('Z', '', $modified), 'ts'); @@ -497,10 +497,10 @@ class Nextmatch extends Etemplate\Widget { continue; } - if($value_in[$key] == $value[$key]) continue; + if (($value_in[$key]??null) == ($value[$key]??null)) continue; // These keys we don't send row data back, as they cause a partial reload - if(in_array($key, array('template'))) $no_rows = true; + if (in_array($key, array('template'))) $no_rows = true; // Actions still need extra handling if($key == 'actions' && !isset($value['actions'][0])) @@ -626,7 +626,7 @@ class Nextmatch extends Etemplate\Widget ), array(), true); // true = no permission check // if we have a nextmatch widget, find the repeating row - if ($widget && $widget->attrs['template']) + if ($widget && !empty($widget->attrs['template'])) { $row_template = $widget->getElementById($widget->attrs['template']); if(!$row_template) @@ -642,12 +642,12 @@ class Nextmatch extends Etemplate\Widget if($child->type == 'row') $repeating_row = $child; } } - // otherwise we might get stoped by max_excutiontime + // otherwise, we might get stopped by max_excutiontime if ($total > 200) @set_time_limit(0); - $is_parent = $value['is_parent']; - $is_parent_value = $value['is_parent_value']; - $parent_id = $value['parent_id']; + $is_parent = $value['is_parent'] ?? null; + $is_parent_value = $value['is_parent_value'] ?? null; + $parent_id = $value['parent_id'] ?? null; // remove empty rows required by old etemplate to compensate for header rows $first = $total ? null : 0; @@ -658,14 +658,14 @@ class Nextmatch extends Etemplate\Widget { if (is_null($first)) $first = $n; - if ($row[$is_parent]) // if app supports parent_id / hierarchy, set parent_id and is_parent + if (!empty($row[$is_parent])) // if app supports parent_id / hierarchy, set parent_id and is_parent { $row['is_parent'] = isset($is_parent_value) ? $row[$is_parent] == $is_parent_value : (boolean)$row[$is_parent]; - $row['parent_id'] = $row[$parent_id]; // seems NOT used on client! + $row['parent_id'] = $row[$parent_id] ?? null; // seems NOT used on client! } // run beforeSendToClient methods of widgets in row on row-data - if($repeating_row) + if (!empty($repeating_row)) { // Change anything by widget for each row ($row set to 1) $_row = array(1 => &$row); @@ -894,7 +894,7 @@ class Nextmatch extends Etemplate\Widget if ($default_attrs) $action += $default_attrs; // Add 'Select All' after first group - if ($first_level && $group !== false && $action['group'] != $group && !$egw_actions[$prefix.'select_all']) + if ($first_level && $group !== false && $action['group'] != $group && empty($egw_actions[$prefix.'select_all'])) { $egw_actions[$prefix.'select_all'] = array( @@ -911,7 +911,7 @@ class Nextmatch extends Etemplate\Widget ); $action_links[] = $prefix.'select_all'; } - $group = $action['group']; + $group = $action['group'] ?? 0; if (!$first_level && $n == $max_length && count($actions) > $max_length) { @@ -941,29 +941,29 @@ class Nextmatch extends Etemplate\Widget } // add all first level popup actions plus ones with enabled = 'javaScript:...' to action_links - if ((!isset($action['type']) || in_array($action['type'],array('popup','drag','drop'))) && // popup is the default - ($first_level || substr($action['enabled'],0,11) == 'javaScript:')) + if ((!isset($action['type']) || in_array($action['type'], array('popup','drag','drop'))) && // popup is the default + ($first_level || isset($action['enabled']) && substr($action['enabled'],0,11) === 'javaScript:')) { $action_links[] = $prefix.$id; } - // add sub-menues - if ($action['children']) + // add sub-menus + if (!empty($action['children'])) { static $inherit_attrs = array('url','popup','nm_action','onExecute','type','egw_open','allowOnMultiple','confirm','confirm_multiple'); $inherit_keys = array_flip($inherit_attrs); - $action['children'] = self::egw_actions($action['children'], $template_name, $action['prefix'], $action_links, $max_length, + $action['children'] = self::egw_actions($action['children'], $template_name, $action['prefix'] ?? '', $action_links, $max_length, array_intersect_key($action, $inherit_keys)); unset($action['prefix']); // Allow default actions to keep their onExecute - if($action['default']) unset($inherit_keys['onExecute']); + if (!empty($action['default'])) unset($inherit_keys['onExecute']); $action = array_diff_key($action, $inherit_keys); } // link or popup action - if ($action['url']) + if (!empty($action['url'])) { $action['url'] = Api\Framework::link('/index.php',str_replace('$action',$id,$action['url'])); if ($action['popup']) @@ -984,7 +984,7 @@ class Nextmatch extends Etemplate\Widget } } } - if ($action['egw_open']) + if (!empty($action['egw_open'])) { $action['data']['nm_action'] = 'egw_open'; } @@ -997,10 +997,10 @@ class Nextmatch extends Etemplate\Widget // Make sure select all is in a group by itself foreach($egw_actions as $id => &$_action) { - if($id == $prefix . 'select_all') continue; - if($_action['group'] >= $egw_actions[$prefix.'select_all']['group'] ) + if ($id == $prefix . 'select_all') continue; + if (($_action['group'] ?? 0) >= (($egw_actions[$prefix.'select_all'] ?? [])['group'] ?? 0)) { - $egw_actions[$id]['group']+=1; + $egw_actions[$id]['group'] = ($egw_actions[$id]['group'] ?? 0) + 1; } } //echo "egw_actions="; _debug_array($egw_actions); @@ -1044,7 +1044,7 @@ class Nextmatch extends Etemplate\Widget 'no_lang' => true, ); // add category icon - if (is_array($cat['data']) && $cat['data']['icon'] && file_exists(EGW_SERVER_ROOT.self::ICON_PATH.'/'.basename($cat['data']['icon']))) + if (is_array($cat['data']) && !empty($cat['data']['icon']) && file_exists(EGW_SERVER_ROOT.self::ICON_PATH.'/'.basename($cat['data']['icon']))) { $cat_actions[$cat['id']]['iconUrl'] = $GLOBALS['egw_info']['server']['webserver_url'].self::ICON_PATH.'/'.$cat['data']['icon']; } @@ -1083,7 +1083,7 @@ class Nextmatch extends Etemplate\Widget 'prefix' => $prefix, ); // add category icon - if ($cat['data']['icon'] && file_exists(EGW_SERVER_ROOT.self::ICON_PATH.'/'.basename($cat['data']['icon']))) + if (!empty($cat['data']['icon']) && file_exists(EGW_SERVER_ROOT.self::ICON_PATH.'/'.basename($cat['data']['icon']))) { $cat_actions[$cat['id']]['iconUrl'] = $GLOBALS['egw_info']['server']['webserver_url'].self::ICON_PATH.'/'.$cat['data']['icon']; } @@ -1222,7 +1222,7 @@ class Nextmatch extends Etemplate\Widget // Run on all the sub-templates foreach(array('template', 'header_left', 'header_right', 'header_row') as $sub_template) { - if($this->attrs[$sub_template]) + if (!empty($this->attrs[$sub_template])) { $row_template = Template::instance($this->attrs[$sub_template]); $row_template->run($method_name, $params, $respect_disabled); @@ -1230,14 +1230,6 @@ class Nextmatch extends Etemplate\Widget } } $params[0] = $old_param0; - - // Prevent troublesome keys from breaking the nextmatch - // TODO: Figure out where these come from - foreach(array('$row','${row}', '$', '0','1','2') as $key) - { - if(is_array(self::$request->content[$cname])) unset(self::$request->content[$cname][$key]); - if(is_array(self::$request->preserve[$cname])) unset(self::$request->preserve[$cname][$key]); - } } /** diff --git a/api/src/Etemplate/Widget/Placeholder.php b/api/src/Etemplate/Widget/Placeholder.php index 873ef30608..a31d88c49d 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']; + $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) @@ -75,31 +77,53 @@ 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(); + 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; + // Remove if empty + foreach($list as $p_group => $p_list) + { + if(count($p_list) == 0) + { + unset($list[$p_group]); + } + } + + 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': + case 'user': + $entry = ['id' => $GLOBALS['egw_info']['user']['person_id']]; + // fall through 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/Framework.php b/api/src/Framework.php index 5d0af42bfc..a77e6cd406 100644 --- a/api/src/Framework.php +++ b/api/src/Framework.php @@ -496,17 +496,10 @@ abstract class Framework extends Framework\Extra { $lang_code = $GLOBALS['egw_info']['user']['preferences']['common']['lang']; } - // IE specific fixes - if (Header\UserAgent::type() == 'msie') - { - // tell IE to use it's own mode, not old compatibility modes (set eg. via group policy for all intranet sites) - // has to be before any other header tags, but meta and title!!! - $pngfix = ''."\n"; - } $app = $GLOBALS['egw_info']['flags']['currentapp']; $app_title = isset($GLOBALS['egw_info']['apps'][$app]) ? $GLOBALS['egw_info']['apps'][$app]['title'] : lang($app); - $app_header = $GLOBALS['egw_info']['flags']['app_header'] ? $GLOBALS['egw_info']['flags']['app_header'] : $app_title; + $app_header = $GLOBALS['egw_info']['flags']['app_header'] ?? $app_title; $site_title = strip_tags($GLOBALS['egw_info']['server']['site_title'].' ['.($app_header ? $app_header : $app_title).']'); // send appheader to clientside @@ -516,7 +509,7 @@ abstract class Framework extends Framework\Extra $var['favicon_file'] = self::get_login_logo_or_bg_url('favicon_file', 'favicon.ico'); - if ($GLOBALS['egw_info']['flags']['include_wz_tooltip'] && + if (!empty($GLOBALS['egw_info']['flags']['include_wz_tooltip']) && file_exists(EGW_SERVER_ROOT.($wz_tooltip = '/phpgwapi/js/wz_tooltip/wz_tooltip.js'))) { $include_wz_tooltip = '